diff options
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 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 ( - <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 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} - <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 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 = ( + <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 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 ( <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 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({ <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 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 |