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); }