commit: ef2b50c9acf8bdc2119bfe3d8fe127d92f32c0a7
parent: a41c3487bd0564d0a27be5e3937aaf898e34f6f3
Author: Eugen Rochko <eugen@zeonfederated.com>
Date: Fri, 30 Sep 2016 00:00:45 +0200
Deleting statuses from UI
Diffstat:
12 files changed, 242 insertions(+), 34 deletions(-)
diff --git 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
@@ -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 (
- <a href='#' title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={{ display: 'inline-block', fontSize: `${this.props.size}px`, width: `${this.props.size}px`, height: `${this.props.size}px`, lineHeight: `${this.props.size}px`}}>
+ <a href='#' title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}>
<i className={`fa fa-fw fa-${this.props.icon}`}></i>
</a>
);
diff --git 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}
- <div style={{ marginTop: '10px', overflow: 'hidden' }}>
- <div style={{ float: 'left', marginRight: '10px'}}><IconButton title='Reply' icon='reply' onClick={this.handleReplyClick} /></div>
- <div style={{ float: 'left', marginRight: '10px'}}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={this.handleReblogClick} /></div>
- <div style={{ float: 'left'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div>
- </div>
+ <StatusActionBar {...this.props} />
</div>
);
}
diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx 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 = (
+ <ul>
+ <li><a href='#' onClick={this.handleDeleteClick}>Delete</a></li>
+ </ul>
+ );
+ }
+
+ return (
+ <div style={{ marginTop: '10px', overflow: 'hidden' }}>
+ <div style={{ float: 'left', marginRight: '18px'}}><IconButton title='Reply' icon='reply' onClick={this.handleReplyClick} /></div>
+ <div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={this.handleReblogClick} /></div>
+ <div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div>
+
+ <div onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}>
+ <Dropdown>
+ <DropdownTrigger className='icon-button' style={{ fontSize: '18px', lineHeight: '18px', width: '18px', height: '18px' }}>
+ <i className='fa fa-fw fa-ellipsis-h' />
+ </DropdownTrigger>
+
+ <DropdownContent>{menu}</DropdownContent>
+ </Dropdown>
+ </div>
+ </div>
+ );
+ }
+
+});
+
+export default StatusActionBar;
diff --git 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 (
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable' onScroll={this.handleScroll}>
<div>
- {this.props.statuses.map((status) => {
- return <Status key={status.get('id')} status={status} onReply={this.props.onReply} onReblog={this.props.onReblog} onFavourite={this.props.onFavourite} />;
+ {statuses.map((status) => {
+ return <Status key={status.get('id')} {...other} status={status} />;
})}
</div>
</div>
diff --git 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({
<div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}>
<Header account={account} />
<ActionBar account={account} me={me} onFollow={this.handleFollow} onUnfollow={this.handleUnfollow} />
- <StatusList statuses={statuses} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} />
+ <StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} />
</div>
);
}
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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