Deleting statuses from UI
This commit is contained in:
		| @@ -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 | ||||
|   }; | ||||
| }; | ||||
|   | ||||
| @@ -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> | ||||
|     ); | ||||
|   | ||||
| @@ -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> | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -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; | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -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'))); | ||||
|     } | ||||
|   }; | ||||
| }; | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| class Api::V1::AppsController < ApplicationController | ||||
| class Api::V1::AppsController < ApiController | ||||
|   respond_to :json | ||||
|  | ||||
|   def create | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user