about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/api/v1/notifications_controller.rb4
-rw-r--r--app/javascript/glitch/components/notification/container.js9
-rw-r--r--app/javascript/glitch/components/notification/follow_notification.js78
-rw-r--r--app/javascript/glitch/components/notification/index.js31
-rw-r--r--app/javascript/glitch/components/status/action_bar.js8
-rw-r--r--app/javascript/glitch/components/status/container.js4
-rw-r--r--app/javascript/glitch/components/status/index.js4
-rw-r--r--app/javascript/glitch/components/status/prepend.js29
-rw-r--r--app/javascript/mastodon/actions/notifications.js17
-rw-r--r--app/javascript/mastodon/locales/en.json1
-rw-r--r--app/javascript/mastodon/reducers/notifications.js7
-rw-r--r--app/javascript/styles/components.scss21
-rw-r--r--config/routes.rb2
13 files changed, 192 insertions, 23 deletions
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index 8910b77e9..55f35fa4b 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -24,6 +24,10 @@ class Api::V1::NotificationsController < Api::BaseController
     render_empty
   end
 
+  def destroy
+    dismiss
+  end
+
   def dismiss
     current_account.notifications.find_by!(id: params[:id]).destroy!
     render_empty
diff --git a/app/javascript/glitch/components/notification/container.js b/app/javascript/glitch/components/notification/container.js
index c58ef4bd2..60303537d 100644
--- a/app/javascript/glitch/components/notification/container.js
+++ b/app/javascript/glitch/components/notification/container.js
@@ -6,6 +6,7 @@ import { makeGetNotification } from '../../../mastodon/selectors';
 
 //  Our imports  //
 import Notification from '.';
+import { deleteNotification } from '../../../mastodon/actions/notifications';
 
 const makeMapStateToProps = () => {
   const getNotification = makeGetNotification();
@@ -18,4 +19,10 @@ const makeMapStateToProps = () => {
   return mapStateToProps;
 };
 
-export default connect(makeMapStateToProps)(Notification);
+const mapDispatchToProps = (dispatch) => ({
+  onDeleteNotification (id) {
+    dispatch(deleteNotification(id));
+  },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
diff --git a/app/javascript/glitch/components/notification/follow_notification.js b/app/javascript/glitch/components/notification/follow_notification.js
new file mode 100644
index 000000000..7cabd91f6
--- /dev/null
+++ b/app/javascript/glitch/components/notification/follow_notification.js
@@ -0,0 +1,78 @@
+//  Package imports  //
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import escapeTextContentForBrowser from 'escape-html';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Mastodon imports  //
+import emojify from '../../../mastodon/emoji';
+import Permalink from '../../../mastodon/components/permalink';
+import AccountContainer from '../../../mastodon/containers/account_container';
+
+const messages = defineMessages({
+  deleteNotification: { id: 'status.dismiss_notification', defaultMessage: 'Dismiss notification' },
+});
+
+
+@injectIntl
+export default class FollowNotification extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    notificationId: PropTypes.number.isRequired,
+    onDeleteNotification: PropTypes.func.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  // Avoid checking props that are functions (and whose equality will always
+  // evaluate to false. See react-immutable-pure-component for usage.
+  updateOnProps = [
+    'account',
+  ]
+
+  handleNotificationDeleteClick = () => {
+    this.props.onDeleteNotification(this.props.notificationId);
+  }
+
+  render () {
+    const { account, intl } = this.props;
+
+    const dismissTitle = intl.formatMessage(messages.deleteNotification);
+    const dismiss = (
+      <button
+        aria-label={dismissTitle}
+        title={dismissTitle}
+        onClick={this.handleNotificationDeleteClick}
+        className='status__prepend-dismiss-button'
+      >
+        <i className='fa fa-eraser' />
+      </button>
+    );
+
+    const displayName      = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
+    const displayNameHTML  = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
+    return (
+      <div className='notification notification-follow'>
+        <div className='notification__message'>
+          <div className='notification__favourite-icon-wrapper'>
+            <i className='fa fa-fw fa-user-plus' />
+          </div>
+
+          <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
+
+          {dismiss}
+        </div>
+
+        <AccountContainer id={account.get('id')} withNote={false} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/glitch/components/notification/index.js b/app/javascript/glitch/components/notification/index.js
index 83ac8dfc1..0cdc03cbe 100644
--- a/app/javascript/glitch/components/notification/index.js
+++ b/app/javascript/glitch/components/notification/index.js
@@ -1,42 +1,30 @@
 //  Package imports  //
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
-import escapeTextContentForBrowser from 'escape-html';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
 
 //  Mastodon imports  //
-import AccountContainer from '../../../mastodon/containers/account_container';
-import Permalink from '../../../mastodon/components/permalink';
-import emojify from '../../../mastodon/emoji';
 
 //  Our imports  //
 import StatusContainer from '../status/container';
+import FollowNotification from './follow_notification';
 
 export default class Notification extends ImmutablePureComponent {
 
   static propTypes = {
     notification: ImmutablePropTypes.map.isRequired,
     settings: ImmutablePropTypes.map.isRequired,
+    onDeleteNotification: PropTypes.func.isRequired,
   };
 
   renderFollow (notification) {
-    const account          = notification.get('account');
-    const displayName      = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
-    const displayNameHTML  = { __html: emojify(escapeTextContentForBrowser(displayName)) };
-    const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
     return (
-      <div className='notification notification-follow'>
-        <div className='notification__message'>
-          <div className='notification__favourite-icon-wrapper'>
-            <i className='fa fa-fw fa-user-plus' />
-          </div>
-
-          <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
-        </div>
-
-        <AccountContainer id={account.get('id')} withNote={false} />
-      </div>
+      <FollowNotification
+        notificationId={notification.get('id')}
+        account={notification.get('account')}
+        onDeleteNotification={this.props.onDeleteNotification}
+      />
     );
   }
 
@@ -44,6 +32,7 @@ export default class Notification extends ImmutablePureComponent {
     return (
       <StatusContainer
         id={notification.get('status')}
+        notificationId={notification.get('id')}
         withDismiss
       />
     );
@@ -56,6 +45,7 @@ export default class Notification extends ImmutablePureComponent {
         account={notification.get('account')}
         prepend='favourite'
         muted
+        notificationId={notification.get('id')}
         withDismiss
       />
     );
@@ -68,6 +58,7 @@ export default class Notification extends ImmutablePureComponent {
         account={notification.get('account')}
         prepend='reblog'
         muted
+        notificationId={notification.get('id')}
         withDismiss
       />
     );
diff --git a/app/javascript/glitch/components/status/action_bar.js b/app/javascript/glitch/components/status/action_bar.js
index f298dcaa8..6aa088c04 100644
--- a/app/javascript/glitch/components/status/action_bar.js
+++ b/app/javascript/glitch/components/status/action_bar.js
@@ -24,6 +24,7 @@ const messages = defineMessages({
   report: { id: 'status.report', defaultMessage: 'Report @{name}' },
   muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
   unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+  deleteNotification: { id: 'status.dismiss_notification', defaultMessage: 'Dismiss notification' },
 });
 
 @injectIntl
@@ -35,6 +36,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
 
   static propTypes = {
     status: ImmutablePropTypes.map.isRequired,
+    notificationId: PropTypes.number,
     onReply: PropTypes.func,
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
@@ -44,6 +46,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
     onBlock: PropTypes.func,
     onReport: PropTypes.func,
     onMuteConversation: PropTypes.func,
+    onDeleteNotification: PropTypes.func,
     me: PropTypes.number.isRequired,
     withDismiss: PropTypes.bool,
     intl: PropTypes.object.isRequired,
@@ -97,6 +100,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.props.onMuteConversation(this.props.status);
   }
 
+  handleNotificationDeleteClick = () => {
+    this.props.onDeleteNotification(this.props.notificationId);
+  }
+
   render () {
     const { status, me, intl, withDismiss } = this.props;
     const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
@@ -112,6 +119,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
 
     if (withDismiss) {
       menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+      menu.push({ text: intl.formatMessage(messages.deleteNotification), action: this.handleNotificationDeleteClick });
       menu.push(null);
     }
 
diff --git a/app/javascript/glitch/components/status/container.js b/app/javascript/glitch/components/status/container.js
index a8aa6efe9..c45b2e0ec 100644
--- a/app/javascript/glitch/components/status/container.js
+++ b/app/javascript/glitch/components/status/container.js
@@ -50,6 +50,7 @@ import {
 } from '../../../mastodon/actions/statuses';
 import { initReport } from '../../../mastodon/actions/reports';
 import { openModal } from '../../../mastodon/actions/modal';
+import { deleteNotification } from '../../../mastodon/actions/notifications';
 
 //  Our imports  //
 import Status from '.';
@@ -245,6 +246,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onDeleteNotification (id) {
+    dispatch(deleteNotification(id));
+  },
 });
 
 export default injectIntl(
diff --git a/app/javascript/glitch/components/status/index.js b/app/javascript/glitch/components/status/index.js
index 1d135754a..314e8b51c 100644
--- a/app/javascript/glitch/components/status/index.js
+++ b/app/javascript/glitch/components/status/index.js
@@ -170,6 +170,7 @@ export default class Status extends ImmutablePureComponent {
     onReport                    : PropTypes.func,
     onOpenMedia                 : PropTypes.func,
     onOpenVideo                 : PropTypes.func,
+    onDeleteNotification        : PropTypes.func,
     reblogModal                 : PropTypes.bool,
     deleteModal                 : PropTypes.bool,
     autoPlayGif                 : PropTypes.bool,
@@ -177,6 +178,7 @@ export default class Status extends ImmutablePureComponent {
     collapse                    : PropTypes.bool,
     prepend                     : PropTypes.string,
     withDismiss                 : PropTypes.bool,
+    notificationId              : PropTypes.number,
     intersectionObserverWrapper : PropTypes.object,
   };
 
@@ -685,6 +687,8 @@ collapsed.
             type={prepend}
             account={account}
             parseClick={parseClick}
+            notificationId={this.props.notificationId}
+            onDeleteNotification={this.props.onDeleteNotification}
           />
         ) : null}
         <StatusHeader
diff --git a/app/javascript/glitch/components/status/prepend.js b/app/javascript/glitch/components/status/prepend.js
index ef9209e81..d9b04b5ec 100644
--- a/app/javascript/glitch/components/status/prepend.js
+++ b/app/javascript/glitch/components/status/prepend.js
@@ -23,11 +23,17 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import escapeTextContentForBrowser from 'escape-html';
+import { defineMessages, injectIntl } from 'react-intl';
 import { FormattedMessage } from 'react-intl';
 
 //  Mastodon imports  //
 import emojify from '../../../mastodon/emoji';
 
+
+const messages = defineMessages({
+  deleteNotification: { id: 'status.dismiss_notification', defaultMessage: 'Dismiss notification' },
+});
+
                             /* * * * */
 
 /*
@@ -53,12 +59,16 @@ element.
 
 */
 
+@injectIntl
 export default class StatusPrepend extends React.PureComponent {
 
   static propTypes = {
     type: PropTypes.string.isRequired,
     account: ImmutablePropTypes.map.isRequired,
     parseClick: PropTypes.func.isRequired,
+    notificationId: PropTypes.number,
+    onDeleteNotification: PropTypes.func,
+    intl: PropTypes.object.isRequired,
   };
 
 /*
@@ -77,6 +87,10 @@ an account link is clicked.
     parseClick(e, `/accounts/${+account.get('id')}`);
   }
 
+  handleNotificationDeleteClick = () => {
+    this.props.onDeleteNotification(this.props.notificationId);
+  }
+
 /*
 
 ####  `<Message>`.
@@ -145,7 +159,19 @@ the `<Message>` inside of an <aside>.
 
   render () {
     const { Message } = this;
-    const { type } = this.props;
+    const { type, intl } = this.props;
+
+    const dismissTitle = intl.formatMessage(messages.deleteNotification);
+    const dismiss = this.props.notificationId ? (
+      <button
+        aria-label={dismissTitle}
+        title={dismissTitle}
+        onClick={this.handleNotificationDeleteClick}
+        className='status__prepend-dismiss-button'
+      >
+        <i className='fa fa-eraser' />
+      </button>
+    ) : null;
 
     return !type ? null : (
       <aside className={type === 'reblogged_by' ? 'status__prepend' : 'notification__message'}>
@@ -157,6 +183,7 @@ the `<Message>` inside of an <aside>.
           />
         </div>
         <Message />
+        {dismiss}
       </aside>
     );
   }
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index c7d248122..b2a0f7ac3 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -6,6 +6,8 @@ import { defineMessages } from 'react-intl';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
 
+export const NOTIFICATION_DELETE_SUCCESS = 'NOTIFICATION_DELETE_SUCCESS';
+
 export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
 export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
 export const NOTIFICATIONS_REFRESH_FAIL    = 'NOTIFICATIONS_REFRESH_FAIL';
@@ -187,3 +189,18 @@ export function scrollTopNotifications(top) {
     top,
   };
 };
+
+export function deleteNotification(id) {
+  return (dispatch, getState) => {
+    api(getState).delete(`/api/v1/notifications/${id}`).then(() => {
+      dispatch(deleteNotificationSuccess(id));
+    });
+  };
+};
+
+export function deleteNotificationSuccess(id) {
+  return {
+    type: NOTIFICATION_DELETE_SUCCESS,
+    id: id,
+  };
+};
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index cf29e38da..d2e5f90ea 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -190,6 +190,7 @@
   "status.show_more": "Show more",
   "status.uncollapse": "Uncollapse",
   "status.unmute_conversation": "Unmute conversation",
+  "status.dismiss_notification": "Dismiss notification",
   "tabs_bar.compose": "Compose",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index 0063d24e4..da5fcde84 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -8,6 +8,7 @@ import {
   NOTIFICATIONS_EXPAND_FAIL,
   NOTIFICATIONS_CLEAR,
   NOTIFICATIONS_SCROLL_TOP,
+  NOTIFICATION_DELETE_SUCCESS,
 } from '../actions/notifications';
 import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
 import { TIMELINE_DELETE } from '../actions/timelines';
@@ -92,6 +93,10 @@ const deleteByStatus = (state, statusId) => {
   return state.update('items', list => list.filterNot(item => item.get('status') === statusId));
 };
 
+const deleteById = (state, notificationId) => {
+  return state.update('items', list => list.filterNot(item => item.get('id') === notificationId));
+};
+
 export default function notifications(state = initialState, action) {
   switch(action.type) {
   case NOTIFICATIONS_REFRESH_REQUEST:
@@ -113,6 +118,8 @@ export default function notifications(state = initialState, action) {
     return state.set('items', ImmutableList()).set('next', null);
   case TIMELINE_DELETE:
     return deleteByStatus(state, action.id);
+  case NOTIFICATION_DELETE_SUCCESS:
+    return deleteById(state, action.id);
   default:
     return state;
   }
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index 6cca3666a..b06c99c95 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -617,6 +617,27 @@
   position: absolute;
 }
 
+.status__prepend-dismiss-button {
+  border: 0;
+  background: transparent;
+  position: absolute;
+  right: -3px;
+  opacity: 0;
+  transition: opacity 0.1s ease-in-out;
+
+  i.fa {
+    color: crimson;
+  }
+
+  .notification__message:hover & {
+    opacity: 1;
+  }
+
+  .notification-follow & {
+    right: 6px;
+  }
+}
+
 .status {
   padding: 8px 10px;
   padding-left: 68px;
diff --git a/config/routes.rb b/config/routes.rb
index 963fedcb4..a63fb3ae6 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -175,7 +175,7 @@ Rails.application.routes.draw do
         end
       end
 
-      resources :notifications, only: [:index, :show] do
+      resources :notifications, only: [:index, :show, :destroy] do
         collection do
           post :clear
           post :dismiss