Deleting statuses from UI

This commit is contained in:
Eugen Rochko 2016-09-30 00:00:45 +02:00
parent a41c3487bd
commit ef2b50c9ac
12 changed files with 242 additions and 34 deletions

View File

@ -5,6 +5,10 @@ export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; 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) { export function fetchStatusRequest(id) {
return { return {
type: STATUS_FETCH_REQUEST, type: STATUS_FETCH_REQUEST,
@ -41,3 +45,37 @@ export function fetchStatusFail(id, error) {
error: 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
};
};

View File

@ -26,8 +26,16 @@ const IconButton = React.createClass({
}, },
render () { 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 ( 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> <i className={`fa fa-fw fa-${this.props.icon}`}></i>
</a> </a>
); );

View File

@ -2,11 +2,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from './avatar'; import Avatar from './avatar';
import RelativeTimestamp from './relative_timestamp'; import RelativeTimestamp from './relative_timestamp';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button';
import DisplayName from './display_name'; import DisplayName from './display_name';
import MediaGallery from './media_gallery'; import MediaGallery from './media_gallery';
import VideoPlayer from './video_player'; import VideoPlayer from './video_player';
import StatusContent from './status_content'; import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
const Status = React.createClass({ const Status = React.createClass({
@ -19,23 +19,13 @@ const Status = React.createClass({
wrapped: React.PropTypes.bool, wrapped: React.PropTypes.bool,
onReply: React.PropTypes.func, onReply: React.PropTypes.func,
onFavourite: 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], mixins: [PureRenderMixin],
handleReplyClick () {
this.props.onReply(this.props.status);
},
handleFavouriteClick () {
this.props.onFavourite(this.props.status);
},
handleReblogClick () {
this.props.onReblog(this.props.status);
},
handleClick () { handleClick () {
const { status } = this.props; const { status } = this.props;
this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
@ -96,11 +86,7 @@ const Status = React.createClass({
{media} {media}
<div style={{ marginTop: '10px', overflow: 'hidden' }}> <StatusActionBar {...this.props} />
<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>
</div> </div>
); );
} }

View File

@ -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;

View File

@ -9,7 +9,9 @@ const StatusList = React.createClass({
onReply: React.PropTypes.func, onReply: React.PropTypes.func,
onReblog: React.PropTypes.func, onReblog: React.PropTypes.func,
onFavourite: 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], mixins: [PureRenderMixin],
@ -23,11 +25,13 @@ const StatusList = React.createClass({
}, },
render () { render () {
const { statuses, onScrollToBottom, ...other } = this.props;
return ( return (
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable' onScroll={this.handleScroll}> <div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable' onScroll={this.handleScroll}>
<div> <div>
{this.props.statuses.map((status) => { {statuses.map((status) => {
return <Status key={status.get('id')} status={status} onReply={this.props.onReply} onReblog={this.props.onReblog} onFavourite={this.props.onFavourite} />; return <Status key={status.get('id')} {...other} status={status} />;
})} })}
</div> </div>
</div> </div>

View File

@ -8,6 +8,7 @@ import {
fetchAccountTimeline, fetchAccountTimeline,
expandAccountTimeline expandAccountTimeline
} from '../../actions/accounts'; } from '../../actions/accounts';
import { deleteStatus } from '../../actions/statuses';
import { replyCompose } from '../../actions/compose'; import { replyCompose } from '../../actions/compose';
import { favourite, reblog } from '../../actions/interactions'; import { favourite, reblog } from '../../actions/interactions';
import Header from './components/header'; import Header from './components/header';
@ -72,6 +73,10 @@ const Account = React.createClass({
this.props.dispatch(favourite(status)); this.props.dispatch(favourite(status));
}, },
handleDelete (status) {
this.props.dispatch(deleteStatus(status.get('id')));
},
handleScrollToBottom () { handleScrollToBottom () {
this.props.dispatch(expandAccountTimeline(this.props.account.get('id'))); 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%' }}> <div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}>
<Header account={account} /> <Header account={account} />
<ActionBar account={account} me={me} onFollow={this.handleFollow} onUnfollow={this.handleUnfollow} /> <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> </div>
); );
} }

View File

@ -4,29 +4,35 @@ import { replyCompose } from '../../../actions/compose';
import { reblog, favourite } from '../../../actions/interactions'; import { reblog, favourite } from '../../../actions/interactions';
import { expandTimeline } from '../../../actions/timelines'; import { expandTimeline } from '../../../actions/timelines';
import { selectStatus } from '../../../reducers/timelines'; import { selectStatus } from '../../../reducers/timelines';
import { deleteStatus } from '../../../actions/statuses';
const mapStateToProps = function (state, props) { const mapStateToProps = function (state, props) {
return { 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) { const mapDispatchToProps = function (dispatch, props) {
return { return {
onReply: function (status) { onReply (status) {
dispatch(replyCompose(status)); dispatch(replyCompose(status));
}, },
onFavourite: function (status) { onFavourite (status) {
dispatch(favourite(status)); dispatch(favourite(status));
}, },
onReblog: function (status) { onReblog (status) {
dispatch(reblog(status)); dispatch(reblog(status));
}, },
onScrollToBottom: function () { onScrollToBottom () {
dispatch(expandTimeline(props.type)); dispatch(expandTimeline(props.type));
},
onDelete (status) {
dispatch(deleteStatus(status.get('id')));
} }
}; };
}; };

View File

@ -13,7 +13,10 @@ import {
ACCOUNT_TIMELINE_FETCH_FAIL, ACCOUNT_TIMELINE_FETCH_FAIL,
ACCOUNT_TIMELINE_EXPAND_FAIL ACCOUNT_TIMELINE_EXPAND_FAIL
} from '../actions/accounts'; } from '../actions/accounts';
import { STATUS_FETCH_FAIL } from '../actions/statuses'; import {
STATUS_FETCH_FAIL,
STATUS_DELETE_FAIL
} from '../actions/statuses';
import Immutable from 'immutable'; import Immutable from 'immutable';
const initialState = Immutable.List(); const initialState = Immutable.List();
@ -51,6 +54,7 @@ export default function notifications(state = initialState, action) {
case ACCOUNT_TIMELINE_FETCH_FAIL: case ACCOUNT_TIMELINE_FETCH_FAIL:
case ACCOUNT_TIMELINE_EXPAND_FAIL: case ACCOUNT_TIMELINE_EXPAND_FAIL:
case STATUS_FETCH_FAIL: case STATUS_FETCH_FAIL:
case STATUS_DELETE_FAIL:
return notificationFromError(state, action.error); return notificationFromError(state, action.error);
case NOTIFICATION_DISMISS: case NOTIFICATION_DISMISS:
return state.filterNot(item => item.get('key') === action.notification.key); return state.filterNot(item => item.get('key') === action.notification.key);

View File

@ -16,7 +16,10 @@ import {
ACCOUNT_TIMELINE_FETCH_SUCCESS, ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS ACCOUNT_TIMELINE_EXPAND_SUCCESS
} from '../actions/accounts'; } 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 { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
import Immutable from 'immutable'; import Immutable from 'immutable';
@ -142,10 +145,28 @@ function updateTimeline(state, timeline, status) {
}; };
function deleteStatus(state, id) { function deleteStatus(state, id) {
const status = state.getIn(['statuses', id]);
if (!status) {
return state;
}
// Remove references from timelines
['home', 'mentions'].forEach(function (timeline) { ['home', 'mentions'].forEach(function (timeline) {
state = state.update(timeline, list => list.filterNot(item => item === id)); 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]); return state.deleteIn(['statuses', id]);
}; };
@ -194,6 +215,7 @@ export default function timelines(state = initialState, action) {
case TIMELINE_UPDATE: case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status)); return updateTimeline(state, action.timeline, Immutable.fromJS(action.status));
case TIMELINE_DELETE: case TIMELINE_DELETE:
case STATUS_DELETE_SUCCESS:
return deleteStatus(state, action.id); return deleteStatus(state, action.id);
case REBLOG_SUCCESS: case REBLOG_SUCCESS:
case FAVOURITE_SUCCESS: case FAVOURITE_SUCCESS:

View File

@ -156,3 +156,64 @@
.transparent-background { .transparent-background {
background: image-url('void.png'); 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;
}
}
}

View File

@ -1,4 +1,4 @@
class Api::V1::AppsController < ApplicationController class Api::V1::AppsController < ApiController
respond_to :json respond_to :json
def create def create

View File

@ -1,12 +1,19 @@
!!! 5 !!! 5
%html %html
%head %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 %title
= "#{yield(:page_title)} - " if content_for?(:page_title) = "#{yield(:page_title)} - " if content_for?(:page_title)
Mastodon Mastodon
= stylesheet_link_tag 'application', media: 'all' = stylesheet_link_tag 'application', media: 'all'
= csrf_meta_tags = csrf_meta_tags
= yield :header_tags = yield :header_tags
%body{ class: @body_classes } %body{ class: @body_classes }
= content_for?(:content) ? yield(:content) : yield = content_for?(:content) ? yield(:content) : yield