From 249fd6b5967fd23a5f14a2067da0159a924e519f Mon Sep 17 00:00:00 2001
From: Kristina <kristin.fefelova@gmail.com>
Date: Wed, 15 May 2024 19:38:11 +0400
Subject: [PATCH] Group inbox message notifications by author (#5599)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
---
 .../components/DocNotifyContextCard.svelte    | 100 ++++++++++++++--
 .../src/components/MessagePopup.svelte        | 110 ++++++++++++++++++
 .../NotificationCollaboratorsChanged.svelte   |   2 +-
 .../inbox/InboxGroupedListView.svelte         |  18 +--
 plugins/notification-resources/src/utils.ts   |  14 +++
 5 files changed, 221 insertions(+), 23 deletions(-)
 create mode 100644 plugins/notification-resources/src/components/MessagePopup.svelte

diff --git a/plugins/notification-resources/src/components/DocNotifyContextCard.svelte b/plugins/notification-resources/src/components/DocNotifyContextCard.svelte
index 80b6bdb9f9..66d2b3fe75 100644
--- a/plugins/notification-resources/src/components/DocNotifyContextCard.svelte
+++ b/plugins/notification-resources/src/components/DocNotifyContextCard.svelte
@@ -13,20 +13,30 @@
 // limitations under the License.
 -->
 <script lang="ts">
-  import { ButtonIcon, CheckBox, Component, IconMoreV, Label, showPopup, Spinner } from '@hcengineering/ui'
+  import { ButtonIcon, CheckBox, Component, IconMoreV, Label, showPopup, Spinner, tooltip } from '@hcengineering/ui'
   import notification, {
     ActivityNotificationViewlet,
     DisplayInboxNotification,
-    DocNotifyContext
+    DocNotifyContext,
+    InboxNotification
   } from '@hcengineering/notification'
   import { getClient } from '@hcengineering/presentation'
   import { getDocTitle, getDocIdentifier, Menu } from '@hcengineering/view-resources'
   import { createEventDispatcher } from 'svelte'
-  import { WithLookup } from '@hcengineering/core'
+  import { Class, Doc, IdMap, Ref, WithLookup } from '@hcengineering/core'
+  import chunter from '@hcengineering/chunter'
+  import { personAccountByIdStore } from '@hcengineering/contact-resources'
+  import { Person, PersonAccount } from '@hcengineering/contact'
 
+  import MessagesPopup from './MessagePopup.svelte'
   import InboxNotificationPresenter from './inbox/InboxNotificationPresenter.svelte'
   import NotifyContextIcon from './NotifyContextIcon.svelte'
-  import { archiveContextNotifications, unarchiveContextNotifications } from '../utils'
+  import {
+    archiveContextNotifications,
+    isActivityNotification,
+    isMentionNotification,
+    unarchiveContextNotifications
+  } from '../utils'
 
   export let value: DocNotifyContext
   export let notifications: WithLookup<DisplayInboxNotification>[]
@@ -60,6 +70,62 @@
     notification.mixin.NotificationContextPresenter
   )
 
+  let groupedNotifications: Array<InboxNotification[]> = []
+
+  $: groupedNotifications = groupNotificationsByUser(notifications, $personAccountByIdStore)
+
+  function isTextMessage (_class: Ref<Class<Doc>>): boolean {
+    return hierarchy.isDerived(_class, chunter.class.ChatMessage)
+  }
+
+  const canGroup = (it: InboxNotification): boolean => {
+    if (isActivityNotification(it) && isTextMessage(it.attachedToClass)) {
+      return true
+    }
+
+    return isMentionNotification(it) && isTextMessage(it.mentionedInClass)
+  }
+
+  function groupNotificationsByUser (
+    notifications: WithLookup<InboxNotification>[],
+    personAccountById: IdMap<PersonAccount>
+  ): Array<InboxNotification[]> {
+    const result: Array<InboxNotification[]> = []
+    let group: InboxNotification[] = []
+    let person: Ref<Person> | undefined = undefined
+
+    for (const it of notifications) {
+      const account = it.createdBy ?? it.modifiedBy
+      const curPerson = personAccountById.get(account as Ref<PersonAccount>)?.person
+      const allowGroup = canGroup(it)
+
+      if (!allowGroup || curPerson === undefined) {
+        if (group.length > 0) {
+          result.push(group)
+          group = []
+          person = undefined
+        }
+        result.push([it])
+        continue
+      }
+
+      if (curPerson === person || person === undefined) {
+        group.push(it)
+      } else {
+        result.push(group)
+        group = [it]
+      }
+
+      person = curPerson
+    }
+
+    if (group.length > 0) {
+      result.push(group)
+    }
+
+    return result
+  }
+
   function showMenu (ev: MouseEvent): void {
     ev.stopPropagation()
     ev.preventDefault()
@@ -99,6 +165,16 @@
     await archivingPromise
     archivingPromise = undefined
   }
+
+  function canShowTooltip (group: InboxNotification[]): boolean {
+    const first = group[0]
+
+    return canGroup(first)
+  }
+
+  function getKey (group: InboxNotification[]): string {
+    return group.map((it) => it._id).join('-')
+  }
 </script>
 
 <!-- svelte-ignore a11y-click-events-have-key-events -->
@@ -152,16 +228,24 @@
 
   <div class="content">
     <div class="notifications">
-      {#each notifications.slice(0, maxNotifications) as notification}
-        <div class="notification">
+      {#each groupedNotifications.slice(0, maxNotifications) as group (getKey(group))}
+        <div
+          class="notification"
+          use:tooltip={canShowTooltip(group)
+            ? {
+                component: MessagesPopup,
+                props: { context: value, notifications: group }
+              }
+            : undefined}
+        >
           <div class="embeddedMarker" />
           <InboxNotificationPresenter
-            value={notification}
+            value={group[0]}
             {viewlets}
             on:click={(e) => {
               e.preventDefault()
               e.stopPropagation()
-              dispatch('click', { context: value, notification })
+              dispatch('click', { context: value, notification: group[0] })
             }}
           />
         </div>
diff --git a/plugins/notification-resources/src/components/MessagePopup.svelte b/plugins/notification-resources/src/components/MessagePopup.svelte
new file mode 100644
index 0000000000..fc9fc3a1d0
--- /dev/null
+++ b/plugins/notification-resources/src/components/MessagePopup.svelte
@@ -0,0 +1,110 @@
+<!--
+// Copyright © 2024 Hardcore Engineering Inc.
+//
+// Licensed under the Eclipse Public License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License. You may
+// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//
+// See the License for the specific language governing permissions and
+// limitations under the License.
+-->
+<script lang="ts">
+  import { Ref, WithLookup } from '@hcengineering/core'
+  import { getClient } from '@hcengineering/presentation'
+  import activity, { ActivityMessage } from '@hcengineering/activity'
+  import { Lazy, Spinner } from '@hcengineering/ui'
+  import { ActivityMessagePresenter, canGroupMessages } from '@hcengineering/activity-resources'
+  import { ActivityInboxNotification, InboxNotification } from '@hcengineering/notification'
+
+  import { isActivityNotification, isMentionNotification } from '../utils'
+
+  export let notifications: InboxNotification[]
+
+  const client = getClient()
+  const hierarchy = client.getHierarchy()
+
+  let loading = true
+  let messages: ActivityMessage[] = []
+
+  $: void updateMessages(notifications)
+
+  async function updateMessages (notifications: InboxNotification[]): Promise<void> {
+    const result: ActivityMessage[] = []
+
+    for (const notification of notifications) {
+      if (isActivityNotification(notification)) {
+        const it = notification as WithLookup<ActivityInboxNotification>
+        if (it.$lookup?.attachedTo) {
+          result.push(it.$lookup?.attachedTo)
+        }
+      }
+
+      if (isMentionNotification(notification)) {
+        const it = notification
+        if (hierarchy.isDerived(it.mentionedInClass, activity.class.ActivityMessage)) {
+          const message = await client.findOne<ActivityMessage>(it.mentionedInClass, {
+            _id: it.mentionedIn as Ref<ActivityMessage>
+          })
+          if (message !== undefined) {
+            result.push(message)
+          }
+        }
+      }
+    }
+
+    messages = result.reverse()
+    loading = false
+  }
+</script>
+
+<div class="commentPopup-container">
+  <div class="messages">
+    {#if loading}
+      <div class="flex-center">
+        <Spinner />
+      </div>
+    {:else}
+      {#each messages as message, index}
+        {@const canGroup = canGroupMessages(message, messages[index - 1])}
+        <div class="item">
+          <Lazy>
+            <ActivityMessagePresenter
+              value={message}
+              hideLink
+              skipLabel
+              type={canGroup ? 'short' : 'default'}
+              hoverStyles="filledHover"
+            />
+          </Lazy>
+        </div>
+      {/each}
+    {/if}
+  </div>
+</div>
+
+<style lang="scss">
+  .commentPopup-container {
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+    padding: 0;
+    min-width: 20rem;
+    min-height: 0;
+    max-height: 20rem;
+
+    .messages {
+      overflow: auto;
+      flex: 1;
+      min-width: 0;
+      min-height: 0;
+
+      .item {
+        max-width: 30rem;
+      }
+    }
+  }
+</style>
diff --git a/plugins/notification-resources/src/components/NotificationCollaboratorsChanged.svelte b/plugins/notification-resources/src/components/NotificationCollaboratorsChanged.svelte
index 7895f45024..a532a9748e 100644
--- a/plugins/notification-resources/src/components/NotificationCollaboratorsChanged.svelte
+++ b/plugins/notification-resources/src/components/NotificationCollaboratorsChanged.svelte
@@ -39,7 +39,7 @@
   }
 </script>
 
-<BaseMessagePreview {actions} {message}>
+<BaseMessagePreview {actions} {message} on:click>
   <span class="overflow-label flex-presenter flex-gap-1-5">
     <Icon icon={contact.icon.Person} size="small" />
     <Label
diff --git a/plugins/notification-resources/src/components/inbox/InboxGroupedListView.svelte b/plugins/notification-resources/src/components/inbox/InboxGroupedListView.svelte
index 6b3fe2e735..b3d8c1fe65 100644
--- a/plugins/notification-resources/src/components/inbox/InboxGroupedListView.svelte
+++ b/plugins/notification-resources/src/components/inbox/InboxGroupedListView.svelte
@@ -25,7 +25,7 @@
 
   import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient'
   import DocNotifyContextCard from '../DocNotifyContextCard.svelte'
-  import { archiveContextNotifications, unarchiveContextNotifications } from '../../utils'
+  import { archiveContextNotifications, notificationsComparator, unarchiveContextNotifications } from '../../utils'
   import { InboxData } from '../../types'
 
   export let data: InboxData
@@ -51,19 +51,9 @@
   $: updateDisplayData(data)
 
   function updateDisplayData (data: InboxData): void {
-    displayData = Array.from(data.entries()).sort(([, notifications1], [, notifications2]) => {
-      const createdOn1 = notifications1[0].createdOn ?? 0
-      const createdOn2 = notifications2[0].createdOn ?? 0
-
-      if (createdOn1 > createdOn2) {
-        return -1
-      }
-      if (createdOn1 < createdOn2) {
-        return 1
-      }
-
-      return 0
-    })
+    displayData = Array.from(data.entries()).sort(([, notifications1], [, notifications2]) =>
+      notificationsComparator(notifications1[0], notifications2[0])
+    )
   }
 
   function onKeydown (key: KeyboardEvent): void {
diff --git a/plugins/notification-resources/src/utils.ts b/plugins/notification-resources/src/utils.ts
index 361d789121..24aa063179 100644
--- a/plugins/notification-resources/src/utils.ts
+++ b/plugins/notification-resources/src/utils.ts
@@ -672,3 +672,17 @@ function arrayBufferToBase64 (buffer: ArrayBuffer | null): string {
     return ''
   }
 }
+
+export function notificationsComparator (notifications1: InboxNotification, notifications2: InboxNotification): number {
+  const createdOn1 = notifications1.createdOn ?? 0
+  const createdOn2 = notifications2.createdOn ?? 0
+
+  if (createdOn1 > createdOn2) {
+    return -1
+  }
+  if (createdOn1 < createdOn2) {
+    return 1
+  }
+
+  return 0
+}