From 7e31cce96db080bb26200021d7a5679469335819 Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Mon, 29 Jul 2019 20:34:28 +0200
Subject: [PATCH] Change adjacent notifications about the same status to
 display as one

Fix #7698
---
 .../notifications/components/notification.js  | 10 ++++++++
 .../containers/notification_container.js      |  1 +
 .../mastodon/features/notifications/index.js  | 23 ++++++++++++++++---
 app/javascript/mastodon/selectors/index.js    |  5 ++--
 4 files changed, 34 insertions(+), 5 deletions(-)

diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 41e9324e6f6..fc50b5c4ab3 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -17,6 +17,8 @@ const notificationForScreenReader = (intl, message, timestamp) => {
   return output.join(', ');
 };
 
+const appendDisplayNames = (base, accounts) => <span>{base} <abbr title={accounts.map(a => `@${a.get('acct')}`).join(', ')}><FormattedMessage id='notification.and_n_others' defaultMessage='and {count, plural, one {# other} other {# others}}' values={{ count: accounts.size }} /></abbr></span>;
+
 export default @injectIntl
 class Notification extends ImmutablePureComponent {
 
@@ -144,6 +146,10 @@ class Notification extends ImmutablePureComponent {
   renderFavourite (notification, link) {
     const { intl } = this.props;
 
+    if (notification.get('collapsed_accounts')) {
+      link = appendDisplayNames(link, notification.get('collapsed_accounts'));
+    }
+
     return (
       <HotKeys handlers={this.getHandlers()}>
         <div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.favourite', defaultMessage: '{name} favourited your status' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
@@ -176,6 +182,10 @@ class Notification extends ImmutablePureComponent {
   renderReblog (notification, link) {
     const { intl } = this.props;
 
+    if (notification.get('collapsed_accounts')) {
+      link = appendDisplayNames(link, notification.get('collapsed_accounts'));
+    }
+
     return (
       <HotKeys handlers={this.getHandlers()}>
         <div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js
index 78576c760cb..81c5872f44b 100644
--- a/app/javascript/mastodon/features/notifications/containers/notification_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js
@@ -21,6 +21,7 @@ const makeMapStateToProps = () => {
 
   const mapStateToProps = (state, props) => {
     const notification = getNotification(state, props.notification, props.accountId);
+
     return {
       notification: notification,
       status: notification.get('status') ? getStatus(state, { id: notification.get('status') }) : null,
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index e708c4fcf15..7e80d6ff481 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -20,6 +20,22 @@ const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
 });
 
+const collapsibleNotifications = (a, b) => {
+  if (!['reblog', 'favourite'].includes(a.get('type'))) {
+    return false;
+  }
+
+  return a.get('type') === b.get('type') && a.get('status') === b.get('status');
+};
+
+const reduceNotifications = (list, notification) => {
+  if (!list.isEmpty() && collapsibleNotifications(list.last(), notification)) {
+    return list.update(list.size - 1, item => item.update('collapsed_account_ids', ImmutableList(), collapsed_account_ids => collapsed_account_ids.push(notification.get('account'))));
+  } else {
+    return list.push(notification);
+  }
+};
+
 const getNotifications = createSelector([
   state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
   state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
@@ -27,12 +43,13 @@ const getNotifications = createSelector([
   state => state.getIn(['notifications', 'items']),
 ], (showFilterBar, allowedType, excludedTypes, notifications) => {
   if (!showFilterBar || allowedType === 'all') {
-    // used if user changed the notification settings after loading the notifications from the server
+    // Used if user changed the notification settings after loading the notifications from the server
     // otherwise a list of notifications will come pre-filtered from the backend
     // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
-    return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
+    return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))).reduce(reduceNotifications, ImmutableList());
   }
-  return notifications.filter(item => item !== null && allowedType === item.get('type'));
+
+  return notifications.filter(item => item !== null && allowedType === item.get('type')).reduce(reduceNotifications, ImmutableList());
 });
 
 const mapStateToProps = state => ({
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index c87654547d4..fc837f771bd 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -144,8 +144,9 @@ export const makeGetNotification = () => {
   return createSelector([
     (_, base)             => base,
     (state, _, accountId) => state.getIn(['accounts', accountId]),
-  ], (base, account) => {
-    return base.set('account', account);
+    (state, base)         => base.get('collapsed_account_ids') && base.get('collapsed_account_ids').map(id => state.getIn(['accounts', id])),
+  ], (base, account, collapsedAccounts) => {
+    return base.set('account', account).set('collapsed_accounts', collapsedAccounts);
   });
 };
 
-- 
GitLab