From 38e5e94ee4102a6d8b2d3f81a84f5b2576bdcdfd Mon Sep 17 00:00:00 2001 From: Germain Date: Tue, 7 Dec 2021 12:51:34 +0000 Subject: [PATCH] Threads notifications after app startup (#7253) --- src/components/structures/ThreadView.tsx | 4 +- .../views/rooms/NotificationBadge.tsx | 10 +-- src/components/views/rooms/RoomHeader.tsx | 6 +- src/components/views/rooms/RoomTile.tsx | 6 +- .../notifications/ListNotificationState.ts | 8 +-- src/stores/notifications/NotificationState.ts | 25 ++++--- .../RoomNotificationStateStore.ts | 14 ++++ .../notifications/SpaceNotificationState.ts | 8 +-- .../notifications/ThreadNotificationState.ts | 69 ++++++++++++++++++ .../ThreadsRoomNotificationState.ts | 72 +++++++++++++++++++ 10 files changed, 194 insertions(+), 28 deletions(-) create mode 100644 src/stores/notifications/ThreadNotificationState.ts create mode 100644 src/stores/notifications/ThreadsRoomNotificationState.ts diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 5cf8f4a9b6..46e10e8d95 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -72,6 +72,7 @@ export default class ThreadView extends React.Component { super(props); this.state = {}; } + public componentDidMount(): void { this.setupThread(this.props.mxEvent); this.dispatcherRef = dis.register(this.onAction); @@ -166,10 +167,11 @@ export default class ThreadView extends React.Component { }; private updateThread = (thread?: Thread) => { - if (thread) { + if (thread && this.state.thread !== thread) { this.setState({ thread, }); + thread.emit(ThreadEvent.ViewThread); } this.timelinePanelRef.current?.refreshTimeline(); diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index a97d51fc90..c80f70f644 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -20,7 +20,7 @@ import { formatCount } from "../../../utils/FormattingUtils"; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; import { XOR } from "../../../@types/common"; -import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState"; +import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import Tooltip from "../elements/Tooltip"; import { _t } from "../../../languageHandler"; @@ -60,7 +60,7 @@ export default class NotificationBadge extends React.PureComponent) { if (prevProps.notification) { - prevProps.notification.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); + prevProps.notification.off(NotificationStateEvents.Update, this.onNotificationUpdate); } - this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); + this.props.notification.on(NotificationStateEvents.Update, this.onNotificationUpdate); } private countPreferenceChanged = () => { diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 69b91f02b0..4d5dfcd930 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -38,8 +38,8 @@ import { ContextMenuTooltipButton } from '../../structures/ContextMenu'; import RoomContextMenu from "../context_menus/RoomContextMenu"; import { contextMenuBelow } from './RoomTile'; import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore'; -import { NOTIFICATION_STATE_UPDATE } from '../../../stores/notifications/NotificationState'; import { RightPanelPhases } from '../../../stores/RightPanelStorePhases'; +import { NotificationStateEvents } from '../../../stores/notifications/NotificationState'; export interface ISearchInfo { searchTerm: string; @@ -76,7 +76,7 @@ export default class RoomHeader extends React.Component { constructor(props, context) { super(props, context); const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room); - notiStore.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); + notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate); this.state = {}; } @@ -91,7 +91,7 @@ export default class RoomHeader extends React.Component { cli.removeListener("RoomState.events", this.onRoomStateEvents); } const notiStore = RoomNotificationStateStore.instance.getRoomState(this.props.room); - notiStore.removeListener(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); + notiStore.removeListener(NotificationStateEvents.Update, this.onNotificationUpdate); } private onRoomStateEvents = (event: MatrixEvent, state: RoomState) => { diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 235dfcc4da..e413a55276 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -37,7 +37,7 @@ import RoomListStore from "../../../stores/room-list/RoomListStore"; import RoomListActions from "../../../actions/RoomListActions"; import { ActionPayload } from "../../../dispatcher/payloads"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; -import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState"; +import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { EchoChamber } from "../../../stores/local-echo/EchoChamber"; import { CachedRoomKey, RoomEchoChamber } from "../../../stores/local-echo/RoomEchoChamber"; @@ -164,7 +164,7 @@ export default class RoomTile extends React.PureComponent { MessagePreviewStore.getPreviewChangedEventName(this.props.room), this.onRoomPreviewChanged, ); - this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); + this.notificationState.on(NotificationStateEvents.Update, this.onNotificationUpdate); this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate); this.props.room?.on("Room.name", this.onRoomNameUpdate); CommunityPrototypeStore.instance.on( @@ -188,7 +188,7 @@ export default class RoomTile extends React.PureComponent { } ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); defaultDispatcher.unregister(this.dispatcherRef); - this.notificationState.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); + this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate); this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate); CommunityPrototypeStore.instance.off( CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), diff --git a/src/stores/notifications/ListNotificationState.ts b/src/stores/notifications/ListNotificationState.ts index 97ba2bd80b..eab246399a 100644 --- a/src/stores/notifications/ListNotificationState.ts +++ b/src/stores/notifications/ListNotificationState.ts @@ -19,7 +19,7 @@ import { TagID } from "../room-list/models"; import { Room } from "matrix-js-sdk/src/models/room"; import { arrayDiff } from "../../utils/arrays"; import { RoomNotificationState } from "./RoomNotificationState"; -import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationState"; +import { NotificationState, NotificationStateEvents } from "./NotificationState"; export type FetchRoomFn = (room: Room) => RoomNotificationState; @@ -50,11 +50,11 @@ export class ListNotificationState extends NotificationState { const state = this.states[oldRoom.roomId]; if (!state) continue; // We likely just didn't have a badge (race condition) delete this.states[oldRoom.roomId]; - state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); + state.off(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate); } for (const newRoom of diff.added) { const state = this.getRoomFn(newRoom); - state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); + state.on(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate); this.states[newRoom.roomId] = state; } @@ -70,7 +70,7 @@ export class ListNotificationState extends NotificationState { public destroy() { super.destroy(); for (const state of Object.values(this.states)) { - state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); + state.off(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate); } this.states = {}; } diff --git a/src/stores/notifications/NotificationState.ts b/src/stores/notifications/NotificationState.ts index 00dbb7856f..744eb81151 100644 --- a/src/stores/notifications/NotificationState.ts +++ b/src/stores/notifications/NotificationState.ts @@ -14,14 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; import { NotificationColor } from "./NotificationColor"; import { IDestroyable } from "../../utils/IDestroyable"; +import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; -export const NOTIFICATION_STATE_UPDATE = "update"; +export interface INotificationStateSnapshotParams { + symbol: string | null; + count: number; + color: NotificationColor; +} -export abstract class NotificationState extends EventEmitter implements IDestroyable { - protected _symbol: string; +export enum NotificationStateEvents { + Update = "update", +} + +export abstract class NotificationState extends TypedEventEmitter + implements INotificationStateSnapshotParams, IDestroyable { + protected _symbol: string | null; protected _count: number; protected _color: NotificationColor; @@ -55,7 +64,7 @@ export abstract class NotificationState extends EventEmitter implements IDestroy protected emitIfUpdated(snapshot: NotificationStateSnapshot) { if (snapshot.isDifferentFrom(this)) { - this.emit(NOTIFICATION_STATE_UPDATE); + this.emit(NotificationStateEvents.Update); } } @@ -64,7 +73,7 @@ export abstract class NotificationState extends EventEmitter implements IDestroy } public destroy(): void { - this.removeAllListeners(NOTIFICATION_STATE_UPDATE); + this.removeAllListeners(NotificationStateEvents.Update); } } @@ -73,13 +82,13 @@ export class NotificationStateSnapshot { private readonly count: number; private readonly color: NotificationColor; - constructor(state: NotificationState) { + constructor(state: INotificationStateSnapshotParams) { this.symbol = state.symbol; this.count = state.count; this.color = state.color; } - public isDifferentFrom(other: NotificationState): boolean { + public isDifferentFrom(other: INotificationStateSnapshotParams): boolean { const before = { count: this.count, symbol: this.symbol, color: this.color }; const after = { count: other.count, symbol: other.symbol, color: other.color }; return JSON.stringify(before) !== JSON.stringify(after); diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index 7253b46ddd..c38675ec94 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -23,6 +23,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { RoomNotificationState } from "./RoomNotificationState"; import { SummarizedNotificationState } from "./SummarizedNotificationState"; import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; +import { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState"; interface IState {} @@ -30,6 +31,7 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { private static internalInstance = new RoomNotificationStateStore(); private roomMap = new Map(); + private roomThreadsMap = new Map(); private listMap = new Map(); private constructor() { @@ -85,10 +87,22 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { public getRoomState(room: Room): RoomNotificationState { if (!this.roomMap.has(room)) { this.roomMap.set(room, new RoomNotificationState(room)); + // Not very elegant, but that way we ensure that we start tracking + // threads notification at the same time at rooms. + // There are multiple entry points, and it's unclear which one gets + // called first + this.roomThreadsMap.set(room, new ThreadsRoomNotificationState(room)); } return this.roomMap.get(room); } + public getThreadsRoomState(room: Room): ThreadsRoomNotificationState { + if (!this.roomThreadsMap.has(room)) { + this.roomThreadsMap.set(room, new ThreadsRoomNotificationState(room)); + } + return this.roomThreadsMap.get(room); + } + public static get instance(): RoomNotificationStateStore { return RoomNotificationStateStore.internalInstance; } diff --git a/src/stores/notifications/SpaceNotificationState.ts b/src/stores/notifications/SpaceNotificationState.ts index 137b2ca0f2..e5acf7e949 100644 --- a/src/stores/notifications/SpaceNotificationState.ts +++ b/src/stores/notifications/SpaceNotificationState.ts @@ -19,7 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { NotificationColor } from "./NotificationColor"; import { arrayDiff } from "../../utils/arrays"; import { RoomNotificationState } from "./RoomNotificationState"; -import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationState"; +import { NotificationState, NotificationStateEvents } from "./NotificationState"; import { FetchRoomFn } from "./ListNotificationState"; export class SpaceNotificationState extends NotificationState { @@ -42,11 +42,11 @@ export class SpaceNotificationState extends NotificationState { const state = this.states[oldRoom.roomId]; if (!state) continue; // We likely just didn't have a badge (race condition) delete this.states[oldRoom.roomId]; - state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); + state.off(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate); } for (const newRoom of diff.added) { const state = this.getRoomFn(newRoom); - state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); + state.on(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate); this.states[newRoom.roomId] = state; } @@ -60,7 +60,7 @@ export class SpaceNotificationState extends NotificationState { public destroy() { super.destroy(); for (const state of Object.values(this.states)) { - state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); + state.off(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate); } this.states = {}; } diff --git a/src/stores/notifications/ThreadNotificationState.ts b/src/stores/notifications/ThreadNotificationState.ts new file mode 100644 index 0000000000..1ad988cedf --- /dev/null +++ b/src/stores/notifications/ThreadNotificationState.ts @@ -0,0 +1,69 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache 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 + + http://www.apache.org/licenses/LICENSE-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. +*/ + +import { NotificationColor } from "./NotificationColor"; +import { IDestroyable } from "../../utils/IDestroyable"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { NotificationState } from "./NotificationState"; +import { Thread, ThreadEvent } from "matrix-js-sdk/src/models/thread"; +import { Room } from "matrix-js-sdk/src/models/room"; + +export class ThreadNotificationState extends NotificationState implements IDestroyable { + protected _symbol = null; + protected _count = 0; + protected _color = NotificationColor.None; + + constructor(public readonly room: Room, public readonly thread: Thread) { + super(); + this.thread.on(ThreadEvent.NewReply, this.handleNewThreadReply); + this.thread.on(ThreadEvent.ViewThread, this.resetThreadNotification); + } + + public destroy(): void { + super.destroy(); + this.thread.off(ThreadEvent.NewReply, this.handleNewThreadReply); + this.thread.off(ThreadEvent.ViewThread, this.resetThreadNotification); + } + + private handleNewThreadReply(thread: Thread, event: MatrixEvent) { + const client = MatrixClientPeg.get(); + + const isOwn = client.getUserId() === event.getSender(); + if (!isOwn) { + const actions = client.getPushActionsForEvent(event, true); + + const color = !!actions.tweaks.highlight + ? NotificationColor.Red + : NotificationColor.Grey; + + this.updateNotificationState(color); + } + } + + private resetThreadNotification = (): void => { + this.updateNotificationState(NotificationColor.None); + }; + + private updateNotificationState(color: NotificationColor) { + const snapshot = this.snapshot(); + + this._color = color; + + // finally, publish an update if needed + this.emitIfUpdated(snapshot); + } +} diff --git a/src/stores/notifications/ThreadsRoomNotificationState.ts b/src/stores/notifications/ThreadsRoomNotificationState.ts new file mode 100644 index 0000000000..4caa405343 --- /dev/null +++ b/src/stores/notifications/ThreadsRoomNotificationState.ts @@ -0,0 +1,72 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache 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 + + http://www.apache.org/licenses/LICENSE-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. +*/ + +import { IDestroyable } from "../../utils/IDestroyable"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { NotificationState, NotificationStateEvents } from "./NotificationState"; +import { Thread, ThreadEvent } from "matrix-js-sdk/src/models/thread"; +import { ThreadNotificationState } from "./ThreadNotificationState"; +import { NotificationColor } from "./NotificationColor"; + +export class ThreadsRoomNotificationState extends NotificationState implements IDestroyable { + private threadsState = new Map(); + + protected _symbol = null; + protected _count = 0; + protected _color = NotificationColor.None; + + constructor(public readonly room: Room) { + super(); + this.room.on(ThreadEvent.New, this.onNewThread); + } + + public destroy(): void { + super.destroy(); + this.room.on(ThreadEvent.New, this.onNewThread); + for (const [, notificationState] of this.threadsState) { + notificationState.off(NotificationStateEvents.Update, this.onThreadUpdate); + } + } + + private onNewThread = (thread: Thread): void => { + const notificationState = new ThreadNotificationState(this.room, thread); + this.threadsState.set( + thread, + notificationState, + ); + notificationState.on(NotificationStateEvents.Update, this.onThreadUpdate); + }; + + private onThreadUpdate = (): void => { + let color = NotificationColor.None; + for (const [, notificationState] of this.threadsState) { + if (notificationState.color === NotificationColor.Red) { + color = NotificationColor.Red; + break; + } else if (notificationState.color === NotificationColor.Grey) { + color = NotificationColor.Grey; + } + } + this.updateNotificationState(color); + }; + + private updateNotificationState(color: NotificationColor): void { + const snapshot = this.snapshot(); + this._color = color; + // finally, publish an update if needed + this.emitIfUpdated(snapshot); + } +}