From ef2b50c9acf8bdc2119bfe3d8fe127d92f32c0a7 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 30 Sep 2016 00:00:45 +0200 Subject: [PATCH] Deleting statuses from UI --- .../components/actions/statuses.jsx | 38 +++++++++++ .../components/components/icon_button.jsx | 10 ++- .../components/components/status.jsx | 24 ++----- .../components/status_action_bar.jsx | 67 +++++++++++++++++++ .../components/components/status_list.jsx | 10 ++- .../components/features/account/index.jsx | 7 +- .../ui/containers/status_list_container.jsx | 16 +++-- .../components/reducers/notifications.jsx | 6 +- .../components/reducers/timelines.jsx | 26 ++++++- app/assets/stylesheets/components.scss | 61 +++++++++++++++++ app/controllers/api/v1/apps_controller.rb | 2 +- app/views/layouts/application.html.haml | 9 ++- 12 files changed, 242 insertions(+), 34 deletions(-) create mode 100644 app/assets/javascripts/components/components/status_action_bar.jsx diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx index 9b757fceb..2fb2d1ba1 100644 --- a/app/assets/javascripts/components/actions/statuses.jsx +++ b/app/assets/javascripts/components/actions/statuses.jsx @@ -5,6 +5,10 @@ export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; +export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; +export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; +export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; + export function fetchStatusRequest(id) { return { type: STATUS_FETCH_REQUEST, @@ -41,3 +45,37 @@ export function fetchStatusFail(id, error) { error: error }; }; + +export function deleteStatus(id) { + return (dispatch, getState) => { + dispatch(deleteStatusRequest(id)); + + api(getState).delete(`/api/v1/statuses/${id}`).then(response => { + dispatch(deleteStatusSuccess(id)); + }).catch(error => { + dispatch(deleteStatusFail(id, error)); + }); + }; +}; + +export function deleteStatusRequest(id) { + return { + type: STATUS_DELETE_REQUEST, + id: id + }; +}; + +export function deleteStatusSuccess(id) { + return { + type: STATUS_DELETE_SUCCESS, + id: id + }; +}; + +export function deleteStatusFail(id, error) { + return { + type: STATUS_DELETE_FAIL, + id: id, + error: error + }; +}; diff --git a/app/assets/javascripts/components/components/icon_button.jsx b/app/assets/javascripts/components/components/icon_button.jsx index b7f2366ba..509192260 100644 --- a/app/assets/javascripts/components/components/icon_button.jsx +++ b/app/assets/javascripts/components/components/icon_button.jsx @@ -26,8 +26,16 @@ const IconButton = React.createClass({ }, render () { + const style = { + display: 'inline-block', + fontSize: `${this.props.size}px`, + width: `${this.props.size}px`, + height: `${this.props.size}px`, + lineHeight: `${this.props.size}px` + }; + return ( - + ); diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx index a1ab60ae1..7115d154e 100644 --- a/app/assets/javascripts/components/components/status.jsx +++ b/app/assets/javascripts/components/components/status.jsx @@ -2,11 +2,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import Avatar from './avatar'; import RelativeTimestamp from './relative_timestamp'; import PureRenderMixin from 'react-addons-pure-render-mixin'; -import IconButton from './icon_button'; import DisplayName from './display_name'; import MediaGallery from './media_gallery'; import VideoPlayer from './video_player'; import StatusContent from './status_content'; +import StatusActionBar from './status_action_bar'; const Status = React.createClass({ @@ -19,23 +19,13 @@ const Status = React.createClass({ wrapped: React.PropTypes.bool, onReply: React.PropTypes.func, onFavourite: React.PropTypes.func, - onReblog: React.PropTypes.func + onReblog: React.PropTypes.func, + onDelete: React.PropTypes.func, + me: React.PropTypes.number }, mixins: [PureRenderMixin], - handleReplyClick () { - this.props.onReply(this.props.status); - }, - - handleFavouriteClick () { - this.props.onFavourite(this.props.status); - }, - - handleReblogClick () { - this.props.onReblog(this.props.status); - }, - handleClick () { const { status } = this.props; this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); @@ -96,11 +86,7 @@ const Status = React.createClass({ {media} -
-
-
-
-
+ ); } diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx new file mode 100644 index 000000000..76f0ac5f1 --- /dev/null +++ b/app/assets/javascripts/components/components/status_action_bar.jsx @@ -0,0 +1,67 @@ +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import IconButton from './icon_button'; +import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; + +const StatusActionBar = React.createClass({ + propTypes: { + status: ImmutablePropTypes.map.isRequired, + onReply: React.PropTypes.func, + onFavourite: React.PropTypes.func, + onReblog: React.PropTypes.func, + onDelete: React.PropTypes.func + }, + + mixins: [PureRenderMixin], + + handleReplyClick () { + this.props.onReply(this.props.status); + }, + + handleFavouriteClick () { + this.props.onFavourite(this.props.status); + }, + + handleReblogClick () { + this.props.onReblog(this.props.status); + }, + + handleDeleteClick(e) { + e.preventDefault(); + this.props.onDelete(this.props.status); + }, + + render () { + const { status, me } = this.props; + let menu = ''; + + if (status.getIn(['account', 'id']) === me) { + menu = ( + + ); + } + + return ( +
+
+
+
+ +
e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}> + + + + + + {menu} + +
+
+ ); + } + +}); + +export default StatusActionBar; diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx index 381653d5d..9855ec141 100644 --- a/app/assets/javascripts/components/components/status_list.jsx +++ b/app/assets/javascripts/components/components/status_list.jsx @@ -9,7 +9,9 @@ const StatusList = React.createClass({ onReply: React.PropTypes.func, onReblog: React.PropTypes.func, onFavourite: React.PropTypes.func, - onScrollToBottom: React.PropTypes.func + onDelete: React.PropTypes.func, + onScrollToBottom: React.PropTypes.func, + me: React.PropTypes.number }, mixins: [PureRenderMixin], @@ -23,11 +25,13 @@ const StatusList = React.createClass({ }, render () { + const { statuses, onScrollToBottom, ...other } = this.props; + return (
- {this.props.statuses.map((status) => { - return ; + {statuses.map((status) => { + return ; })}
diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx index 5b09594cc..db0925d78 100644 --- a/app/assets/javascripts/components/features/account/index.jsx +++ b/app/assets/javascripts/components/features/account/index.jsx @@ -8,6 +8,7 @@ import { fetchAccountTimeline, expandAccountTimeline } from '../../actions/accounts'; +import { deleteStatus } from '../../actions/statuses'; import { replyCompose } from '../../actions/compose'; import { favourite, reblog } from '../../actions/interactions'; import Header from './components/header'; @@ -72,6 +73,10 @@ const Account = React.createClass({ this.props.dispatch(favourite(status)); }, + handleDelete (status) { + this.props.dispatch(deleteStatus(status.get('id'))); + }, + handleScrollToBottom () { this.props.dispatch(expandAccountTimeline(this.props.account.get('id'))); }, @@ -87,7 +92,7 @@ const Account = React.createClass({
- +
); } diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx index 4757ba448..7a8407b09 100644 --- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx @@ -4,29 +4,35 @@ import { replyCompose } from '../../../actions/compose'; import { reblog, favourite } from '../../../actions/interactions'; import { expandTimeline } from '../../../actions/timelines'; import { selectStatus } from '../../../reducers/timelines'; +import { deleteStatus } from '../../../actions/statuses'; const mapStateToProps = function (state, props) { return { - statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id)) + statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id)), + me: state.getIn(['timelines', 'me']) }; }; const mapDispatchToProps = function (dispatch, props) { return { - onReply: function (status) { + onReply (status) { dispatch(replyCompose(status)); }, - onFavourite: function (status) { + onFavourite (status) { dispatch(favourite(status)); }, - onReblog: function (status) { + onReblog (status) { dispatch(reblog(status)); }, - onScrollToBottom: function () { + onScrollToBottom () { dispatch(expandTimeline(props.type)); + }, + + onDelete (status) { + dispatch(deleteStatus(status.get('id'))); } }; }; diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx index 995947b2d..8011c419d 100644 --- a/app/assets/javascripts/components/reducers/notifications.jsx +++ b/app/assets/javascripts/components/reducers/notifications.jsx @@ -13,7 +13,10 @@ import { ACCOUNT_TIMELINE_FETCH_FAIL, ACCOUNT_TIMELINE_EXPAND_FAIL } from '../actions/accounts'; -import { STATUS_FETCH_FAIL } from '../actions/statuses'; +import { + STATUS_FETCH_FAIL, + STATUS_DELETE_FAIL +} from '../actions/statuses'; import Immutable from 'immutable'; const initialState = Immutable.List(); @@ -51,6 +54,7 @@ export default function notifications(state = initialState, action) { case ACCOUNT_TIMELINE_FETCH_FAIL: case ACCOUNT_TIMELINE_EXPAND_FAIL: case STATUS_FETCH_FAIL: + case STATUS_DELETE_FAIL: return notificationFromError(state, action.error); case NOTIFICATION_DISMISS: return state.filterNot(item => item.get('key') === action.notification.key); diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index 3b8beafaa..c4aae7172 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -16,7 +16,10 @@ import { ACCOUNT_TIMELINE_FETCH_SUCCESS, ACCOUNT_TIMELINE_EXPAND_SUCCESS } from '../actions/accounts'; -import { STATUS_FETCH_SUCCESS } from '../actions/statuses'; +import { + STATUS_FETCH_SUCCESS, + STATUS_DELETE_SUCCESS +} from '../actions/statuses'; import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow'; import Immutable from 'immutable'; @@ -142,10 +145,28 @@ function updateTimeline(state, timeline, status) { }; function deleteStatus(state, id) { + const status = state.getIn(['statuses', id]); + + if (!status) { + return state; + } + + // Remove references from timelines ['home', 'mentions'].forEach(function (timeline) { state = state.update(timeline, list => list.filterNot(item => item === id)); }); + // Remove references from account timelines + state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List(), list => list.filterNot(item => item === id)); + + // Remove reblogs of deleted status + const references = state.get('statuses').filter(item => item.get('reblog') === id); + + references.forEach(referencingId => { + state = deleteStatus(state, referencingId); + }); + + // Remove normalized status return state.deleteIn(['statuses', id]); }; @@ -153,7 +174,7 @@ function normalizeAccount(state, account, relationship) { if (relationship) { state = normalizeRelationship(state, relationship); } - + return state.setIn(['accounts', account.get('id')], account); }; @@ -194,6 +215,7 @@ export default function timelines(state = initialState, action) { case TIMELINE_UPDATE: return updateTimeline(state, action.timeline, Immutable.fromJS(action.status)); case TIMELINE_DELETE: + case STATUS_DELETE_SUCCESS: return deleteStatus(state, action.id); case REBLOG_SUCCESS: case FAVOURITE_SUCCESS: diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 134d34ccb..fa11f94ab 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -156,3 +156,64 @@ .transparent-background { background: image-url('void.png'); } + +.dropdown { + display: inline-block; +} + +.dropdown__content { + display: none; + position: absolute; +} + +.dropdown--active .dropdown__content { + display: block; + z-index: 9999; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); + + &:before { + content: ""; + display: block; + position: absolute; + width: 0; + height: 0; + border-style: solid; + border-width: 0 4.5px 7.8px 4.5px; + border-color: transparent transparent #d9e1e8 transparent; + top: -7px; + left: 8px; + } + + ul { + list-style: none; + } + + li { + &:first-child a { + border-radius: 4px 4px 0 0; + } + + &:last-child a { + border-radius: 0 0 4px 4px; + } + + &:first-child:last-child a { + border-radius: 4px; + } + } + + a { + font-size: 13px; + display: block; + padding: 6px 16px; + width: 120px; + text-decoration: none; + background: #d9e1e8; + color: #282c37; + + &:hover { + background: #2b90d9; + color: #d9e1e8; + } + } +} diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index f7a5e0b0a..f0822b83f 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::AppsController < ApplicationController +class Api::V1::AppsController < ApiController respond_to :json def create diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 8cb062cfb..4f4f2add9 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,12 +1,19 @@ !!! 5 %html %head - %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/ + %meta{:content => 'text/html; charset=UTF-8', 'http-equiv' => 'Content-Type'}/ + %meta{:charset => 'utf-8'}/ + %meta{:name => 'viewport', :content => 'width=device-width, initial-scale=1'}/ + %meta{'http-equiv' => 'X-UA-Compatible', :content => 'IE=edge'}/ + %title = "#{yield(:page_title)} - " if content_for?(:page_title) Mastodon + = stylesheet_link_tag 'application', media: 'all' = csrf_meta_tags + = yield :header_tags + %body{ class: @body_classes } = content_for?(:content) ? yield(:content) : yield