about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/components/actions/statuses.jsx38
-rw-r--r--app/assets/javascripts/components/components/icon_button.jsx10
-rw-r--r--app/assets/javascripts/components/components/status.jsx24
-rw-r--r--app/assets/javascripts/components/components/status_action_bar.jsx67
-rw-r--r--app/assets/javascripts/components/components/status_list.jsx10
-rw-r--r--app/assets/javascripts/components/features/account/index.jsx7
-rw-r--r--app/assets/javascripts/components/features/ui/containers/status_list_container.jsx16
-rw-r--r--app/assets/javascripts/components/reducers/notifications.jsx6
-rw-r--r--app/assets/javascripts/components/reducers/timelines.jsx26
-rw-r--r--app/assets/stylesheets/components.scss61
-rw-r--r--app/controllers/api/v1/apps_controller.rb2
-rw-r--r--app/views/layouts/application.html.haml9
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