From ce570ab82792656902c6e72e0b1fbc7542b4692b Mon Sep 17 00:00:00 2001
From: Germain <germains@element.io>
Date: Mon, 13 Dec 2021 14:05:42 +0000
Subject: [PATCH] Hook threads notification state to UI (#7298)

---
 res/css/views/rooms/_EventTile.scss           | 18 ++++++
 .../views/right_panel/HeaderButtons.tsx       |  3 +
 .../views/right_panel/RoomHeaderButtons.tsx   | 62 ++++++++++++++-----
 src/components/views/rooms/EventTile.tsx      | 55 +++++++++++++++-
 .../notifications/ThreadNotificationState.ts  | 22 ++++---
 .../ThreadsRoomNotificationState.ts           |  7 ++-
 6 files changed, 139 insertions(+), 28 deletions(-)

diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss
index ef04fb47d0..b7ad40d0e7 100644
--- a/res/css/views/rooms/_EventTile.scss
+++ b/res/css/views/rooms/_EventTile.scss
@@ -24,6 +24,24 @@ $left-gutter: 64px;
     font-size: $font-14px;
     position: relative;
 
+    &[data-shape=thread_list][data-notification]::before {
+        content: "";
+        position: absolute;
+        width: 8px;
+        height: 8px;
+        border-radius: 50%;
+        right: -16px;
+        top: 7px;
+    }
+
+    &[data-shape=thread_list][data-notification=total]::before {
+        background-color: $roomtile-default-badge-bg-color;
+    }
+
+    &[data-shape=thread_list][data-notification=highlight]::before {
+        background-color: $alert;
+    }
+
     .mx_ThreadInfo {
         margin-right: 110px;
         margin-left: 64px;
diff --git a/src/components/views/right_panel/HeaderButtons.tsx b/src/components/views/right_panel/HeaderButtons.tsx
index 862f25238d..20ab61cfee 100644
--- a/src/components/views/right_panel/HeaderButtons.tsx
+++ b/src/components/views/right_panel/HeaderButtons.tsx
@@ -30,6 +30,7 @@ import {
 } from '../../../dispatcher/payloads/SetRightPanelPhasePayload';
 import type { EventSubscription } from "fbemitter";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
+import { NotificationColor } from '../../../stores/notifications/NotificationColor';
 
 export enum HeaderKind {
   Room = "room",
@@ -39,6 +40,7 @@ export enum HeaderKind {
 interface IState {
     headerKind: HeaderKind;
     phase: RightPanelPhases;
+    threadNotificationColor: NotificationColor;
 }
 
 interface IProps {}
@@ -54,6 +56,7 @@ export default abstract class HeaderButtons<P = {}> extends React.Component<IPro
         const rps = RightPanelStore.getSharedInstance();
         this.state = {
             headerKind: kind,
+            threadNotificationColor: NotificationColor.None,
             phase: kind === HeaderKind.Room ? rps.visibleRoomPanelPhase : rps.visibleGroupPanelPhase,
         };
     }
diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx
index a9a0b0e7c3..7062a6b111 100644
--- a/src/components/views/right_panel/RoomHeaderButtons.tsx
+++ b/src/components/views/right_panel/RoomHeaderButtons.tsx
@@ -19,6 +19,7 @@ limitations under the License.
 */
 
 import React from "react";
+import classNames from "classnames";
 import { Room } from "matrix-js-sdk/src/models/room";
 
 import { _t } from '../../../languageHandler';
@@ -36,6 +37,8 @@ import SettingsStore from "../../../settings/SettingsStore";
 import dis from "../../../dispatcher/dispatcher";
 import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
 import { NotificationColor } from "../../../stores/notifications/NotificationColor";
+import { ThreadsRoomNotificationState } from "../../../stores/notifications/ThreadsRoomNotificationState";
+import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
 
 const ROOM_INFO_PHASES = [
     RightPanelPhases.RoomSummary,
@@ -48,14 +51,22 @@ const ROOM_INFO_PHASES = [
 ];
 
 interface IUnreadIndicatorProps {
-    className: string;
+    color?: NotificationColor;
 }
 
-const UnreadIndicator = ({ className }: IUnreadIndicatorProps) => {
-    return <React.Fragment>
+const UnreadIndicator = ({ color }: IUnreadIndicatorProps) => {
+    if (color === NotificationColor.None) {
+        return null;
+    }
+
+    const classes = classNames({
+        "mx_RightPanel_headerButton_unreadIndicator": true,
+        "mx_Indicator_gray": color === NotificationColor.Grey,
+    });
+    return <>
         <div className="mx_RightPanel_headerButton_unreadIndicator_bg" />
-        <div className={className} />
-    </React.Fragment>;
+        <div className={classes} />
+    </>;
 };
 
 interface IHeaderButtonProps {
@@ -72,7 +83,7 @@ const PinnedMessagesHeaderButton = ({ room, isHighlighted, onClick }: IHeaderBut
 
     let unreadIndicator;
     if (pinnedEvents.some(id => !readPinnedEvents.has(id))) {
-        unreadIndicator = <UnreadIndicator className="mx_RightPanel_headerButton_unreadIndicator" />;
+        unreadIndicator = <UnreadIndicator />;
     }
 
     return <HeaderButton
@@ -89,17 +100,11 @@ const PinnedMessagesHeaderButton = ({ room, isHighlighted, onClick }: IHeaderBut
 const TimelineCardHeaderButton = ({ room, isHighlighted, onClick }: IHeaderButtonProps) => {
     if (!SettingsStore.getValue("feature_maximised_widgets")) return null;
     let unreadIndicator;
-    switch (RoomNotificationStateStore.instance.getRoomState(room).color) {
+    const color = RoomNotificationStateStore.instance.getRoomState(room).color;
+    switch (color) {
         case NotificationColor.Grey:
-            unreadIndicator =
-                <UnreadIndicator className="mx_RightPanel_headerButton_unreadIndicator mx_Indicator_gray" />;
-            break;
         case NotificationColor.Red:
-            unreadIndicator =
-                <UnreadIndicator className="mx_RightPanel_headerButton_unreadIndicator" />;
-            break;
-        default:
-            break;
+            unreadIndicator = <UnreadIndicator color={color} />;
     }
     return <HeaderButton
         name="timelineCardButton"
@@ -123,11 +128,30 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
         RightPanelPhases.ThreadPanel,
         RightPanelPhases.ThreadView,
     ];
+    private threadNotificationState: ThreadsRoomNotificationState;
 
     constructor(props: IProps) {
         super(props, HeaderKind.Room);
+
+        this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room);
     }
 
+    public componentDidMount(): void {
+        super.componentDidMount();
+        this.threadNotificationState.on(NotificationStateEvents.Update, this.onThreadNotification);
+    }
+
+    public componentWillUnmount(): void {
+        super.componentWillUnmount();
+        this.threadNotificationState.off(NotificationStateEvents.Update, this.onThreadNotification);
+    }
+
+    private onThreadNotification = (): void => {
+        this.setState({
+            threadNotificationColor: this.threadNotificationState.color,
+        });
+    };
+
     protected onAction(payload: ActionPayload) {
         if (payload.action === Action.ViewUser) {
             if (payload.member) {
@@ -188,12 +212,14 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
 
         rightPanelPhaseButtons.set(RightPanelPhases.PinnedMessages,
             <PinnedMessagesHeaderButton
+                key="pinnedMessagesButton"
                 room={this.props.room}
                 isHighlighted={this.isPhase(RightPanelPhases.PinnedMessages)}
                 onClick={this.onPinnedMessagesClicked} />,
         );
         rightPanelPhaseButtons.set(RightPanelPhases.Timeline,
             <TimelineCardHeaderButton
+                key="timelineButton"
                 room={this.props.room}
                 isHighlighted={this.isPhase(RightPanelPhases.Timeline)}
                 onClick={this.onTimelineCardClicked} />,
@@ -205,11 +231,14 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
                     title={_t("Threads")}
                     onClick={this.onThreadsPanelClicked}
                     isHighlighted={this.isPhase(RoomHeaderButtons.THREAD_PHASES)}
-                    analytics={['Right Panel', 'Threads List Button', 'click']} />
+                    analytics={['Right Panel', 'Threads List Button', 'click']}>
+                    <UnreadIndicator color={this.threadNotificationState.color} />
+                </HeaderButton>
                 : null,
         );
         rightPanelPhaseButtons.set(RightPanelPhases.NotificationPanel,
             <HeaderButton
+                key="notifsButton"
                 name="notifsButton"
                 title={_t('Notifications')}
                 isHighlighted={this.isPhase(RightPanelPhases.NotificationPanel)}
@@ -218,6 +247,7 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
         );
         rightPanelPhaseButtons.set(RightPanelPhases.RoomSummary,
             <HeaderButton
+                key="roomSummaryButton"
                 name="roomSummaryButton"
                 title={_t('Room Info')}
                 isHighlighted={this.isPhase(ROOM_INFO_PHASES)}
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index afc37ed5d1..0d0df6d519 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -23,6 +23,7 @@ import { Relations } from "matrix-js-sdk/src/models/relations";
 import { RoomMember } from "matrix-js-sdk/src/models/room-member";
 import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
 import { logger } from "matrix-js-sdk/src/logger";
+import { NotificationCountType } from 'matrix-js-sdk/src/models/room';
 
 import ReplyChain from "../elements/ReplyChain";
 import { _t } from '../../../languageHandler';
@@ -67,6 +68,10 @@ import Toolbar from '../../../accessibility/Toolbar';
 import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
 import { RovingAccessibleTooltipButton } from '../../../accessibility/roving/RovingAccessibleTooltipButton';
 import ThreadListContextMenu from '../context_menus/ThreadListContextMenu';
+import { ThreadNotificationState } from '../../../stores/notifications/ThreadNotificationState';
+import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore';
+import { NotificationStateEvents } from '../../../stores/notifications/NotificationState';
+import { NotificationColor } from '../../../stores/notifications/NotificationColor';
 
 const eventTileTypes = {
     [EventType.RoomMessage]: 'messages.MessageEvent',
@@ -346,6 +351,7 @@ interface IState {
     hover: boolean;
     isQuoteExpanded?: boolean;
     thread?: Thread;
+    threadNotification?: NotificationCountType;
 }
 
 @replaceableComponent("views.rooms.EventTile")
@@ -355,6 +361,7 @@ export default class EventTile extends React.Component<IProps, IState> {
     // TODO: Types
     private tile = React.createRef<unknown>();
     private replyChain = React.createRef<ReplyChain>();
+    private threadState: ThreadNotificationState;
 
     public readonly ref = createRef<HTMLElement>();
 
@@ -492,17 +499,55 @@ export default class EventTile extends React.Component<IProps, IState> {
         if (SettingsStore.getValue("feature_thread")) {
             this.props.mxEvent.once(ThreadEvent.Ready, this.updateThread);
             this.props.mxEvent.on(ThreadEvent.Update, this.updateThread);
+
+            if (this.thread) {
+                this.setupNotificationListener(this.thread);
+            }
         }
 
         const room = this.context.getRoom(this.props.mxEvent.getRoomId());
         room?.on(ThreadEvent.New, this.onNewThread);
     }
 
-    private updateThread = (thread) => {
+    private setupNotificationListener = (thread): void => {
+        const room = this.context.getRoom(this.props.mxEvent.getRoomId());
+        const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(room);
+
+        this.threadState = notifications.threadsState.get(thread);
+
+        this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate);
+        this.onThreadStateUpdate();
+    };
+
+    private onThreadStateUpdate = (): void => {
+        let threadNotification = null;
+        switch (this.threadState?.color) {
+            case NotificationColor.Grey:
+                threadNotification = NotificationCountType.Total;
+                break;
+            case NotificationColor.Red:
+                threadNotification = NotificationCountType.Highlight;
+                break;
+        }
+
         this.setState({
-            thread,
+            threadNotification,
         });
-        this.forceUpdate();
+    };
+
+    private updateThread = (thread) => {
+        if (thread !== this.state.thread) {
+            if (this.threadState) {
+                this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
+            }
+
+            this.setupNotificationListener(thread);
+            this.setState({
+                thread,
+            });
+
+            this.forceUpdate();
+        }
     };
 
     // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@@ -540,6 +585,9 @@ export default class EventTile extends React.Component<IProps, IState> {
 
         const room = this.context.getRoom(this.props.mxEvent.getRoomId());
         room?.off(ThreadEvent.New, this.onNewThread);
+        if (this.threadState) {
+            this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
+        }
     }
 
     componentDidUpdate(prevProps, prevState, snapshot) {
@@ -1358,6 +1406,7 @@ export default class EventTile extends React.Component<IProps, IState> {
                         "data-shape": this.props.tileShape,
                         "data-self": isOwnEvent,
                         "data-has-reply": !!replyChain,
+                        "data-notification": this.state.threadNotification,
                         "onMouseEnter": () => this.setState({ hover: true }),
                         "onMouseLeave": () => this.setState({ hover: false }),
 
diff --git a/src/stores/notifications/ThreadNotificationState.ts b/src/stores/notifications/ThreadNotificationState.ts
index c8c74a07e7..aac3153c73 100644
--- a/src/stores/notifications/ThreadNotificationState.ts
+++ b/src/stores/notifications/ThreadNotificationState.ts
@@ -40,20 +40,26 @@ export class ThreadNotificationState extends NotificationState implements IDestr
         this.thread.off(ThreadEvent.ViewThread, this.resetThreadNotification);
     }
 
-    private handleNewThreadReply(thread: Thread, event: MatrixEvent) {
+    private handleNewThreadReply = (thread: Thread, event: MatrixEvent) => {
         const client = MatrixClientPeg.get();
 
-        const isOwn = client.getUserId() === event.getSender();
-        if (!isOwn) {
+        const myUserId = client.getUserId();
+
+        const isOwn = myUserId === event.getSender();
+        const readReceipt = this.room.getReadReceiptForUserId(myUserId);
+
+        if (!isOwn && !readReceipt || event.getTs() >= readReceipt.data.ts) {
             const actions = client.getPushActionsForEvent(event, true);
 
-            const color = !!actions.tweaks.highlight
-                ? NotificationColor.Red
-                : NotificationColor.Grey;
+            if (actions?.tweaks) {
+                const color = !!actions.tweaks.highlight
+                    ? NotificationColor.Red
+                    : NotificationColor.Grey;
 
-            this.updateNotificationState(color);
+                this.updateNotificationState(color);
+            }
         }
-    }
+    };
 
     private resetThreadNotification = (): void => {
         this.updateNotificationState(NotificationColor.None);
diff --git a/src/stores/notifications/ThreadsRoomNotificationState.ts b/src/stores/notifications/ThreadsRoomNotificationState.ts
index eef8ff3991..bb3bbad716 100644
--- a/src/stores/notifications/ThreadsRoomNotificationState.ts
+++ b/src/stores/notifications/ThreadsRoomNotificationState.ts
@@ -23,7 +23,7 @@ import { ThreadNotificationState } from "./ThreadNotificationState";
 import { NotificationColor } from "./NotificationColor";
 
 export class ThreadsRoomNotificationState extends NotificationState implements IDestroyable {
-    private threadsState = new Map<Thread, ThreadNotificationState>();
+    public readonly threadsState = new Map<Thread, ThreadNotificationState>();
 
     protected _symbol = null;
     protected _count = 0;
@@ -31,6 +31,11 @@ export class ThreadsRoomNotificationState extends NotificationState implements I
 
     constructor(public readonly room: Room) {
         super();
+        if (this.room?.threads) {
+            for (const [, thread] of this.room.threads) {
+                this.onNewThread(thread);
+            }
+        }
         this.room.on(ThreadEvent.New, this.onNewThread);
     }