diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index 2111310555..1bb72d02c3 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -17,35 +17,9 @@ limitations under the License. import React from "react"; import classNames from "classnames"; import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils"; -import { Room } from "matrix-js-sdk/src/models/room"; -import * as RoomNotifs from '../../../RoomNotifs'; -import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership"; -import * as Unread from '../../../Unread'; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { EventEmitter } from "events"; -import { arrayDiff } from "../../../utils/arrays"; -import { IDestroyable } from "../../../utils/IDestroyable"; import SettingsStore from "../../../settings/SettingsStore"; -import { DefaultTagID, TagID } from "../../../stores/room-list/models"; -import { readReceiptChangeIsFor } from "../../../utils/read-receipts"; - -export const NOTIFICATION_STATE_UPDATE = "update"; - -export enum NotificationColor { - // Inverted (None -> Red) because we do integer comparisons on this - None, // nothing special - // TODO: Remove bold with notifications: https://github.com/vector-im/riot-web/issues/14227 - Bold, // no badge, show as unread - Grey, // unread notified messages - Red, // unread pings -} - -export interface INotificationState extends EventEmitter { - symbol?: string; - count: number; - color: NotificationColor; -} +import { INotificationState, NOTIFICATION_STATE_UPDATE } from "../../../stores/notifications/INotificationState"; +import { NotificationColor } from "../../../stores/notifications/NotificationColor"; interface IProps { notification: INotificationState; @@ -141,242 +115,3 @@ export default class NotificationBadge extends React.PureComponent { - if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore - if (room.roomId !== this.room.roomId) return; // not for us - ignore - this.updateNotificationState(); - }; - - private handleRoomEventUpdate = (event: MatrixEvent) => { - const roomId = event.getRoomId(); - - if (roomId !== this.room.roomId) return; // ignore - not for us - this.updateNotificationState(); - }; - - private updateNotificationState() { - const before = {count: this.count, symbol: this.symbol, color: this.color}; - - if (this.roomIsInvite) { - this._color = NotificationColor.Red; - this._symbol = "!"; - this._count = 1; // not used, technically - } else { - const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'highlight'); - const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'total'); - - // For a 'true count' we pick the grey notifications first because they include the - // red notifications. If we don't have a grey count for some reason we use the red - // count. If that count is broken for some reason, assume zero. This avoids us showing - // a badge for 'NaN' (which formats as 'NaNB' for NaN Billion). - const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0); - - // Note: we only set the symbol if we have an actual count. We don't want to show - // zero on badges. - - if (redNotifs > 0) { - this._color = NotificationColor.Red; - this._count = trueCount; - this._symbol = null; // symbol calculated by component - } else if (greyNotifs > 0) { - this._color = NotificationColor.Grey; - this._count = trueCount; - this._symbol = null; // symbol calculated by component - } else { - // We don't have any notified messages, but we might have unread messages. Let's - // find out. - const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room); - if (hasUnread) { - this._color = NotificationColor.Bold; - } else { - this._color = NotificationColor.None; - } - - // no symbol or count for this state - this._count = 0; - this._symbol = null; - } - } - - // finally, publish an update if needed - const after = {count: this.count, symbol: this.symbol, color: this.color}; - if (JSON.stringify(before) !== JSON.stringify(after)) { - this.emit(NOTIFICATION_STATE_UPDATE); - } - } -} - -export class TagSpecificNotificationState extends RoomNotificationState { - private static TAG_TO_COLOR: { - // @ts-ignore - TS wants this to be a string key, but we know better - [tagId: TagID]: NotificationColor, - } = { - [DefaultTagID.DM]: NotificationColor.Red, - }; - - private readonly colorWhenNotIdle?: NotificationColor; - - constructor(room: Room, tagId: TagID) { - super(room); - - const specificColor = TagSpecificNotificationState.TAG_TO_COLOR[tagId]; - if (specificColor) this.colorWhenNotIdle = specificColor; - } - - public get color(): NotificationColor { - if (!this.colorWhenNotIdle) return super.color; - - if (super.color !== NotificationColor.None) return this.colorWhenNotIdle; - return super.color; - } -} - -export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState { - private _count: number; - private _color: NotificationColor; - private rooms: Room[] = []; - private states: { [roomId: string]: RoomNotificationState } = {}; - - constructor(private byTileCount = false, private tagId: TagID) { - super(); - } - - public get symbol(): string { - return null; // This notification state doesn't support symbols - } - - public get count(): number { - return this._count; - } - - public get color(): NotificationColor { - return this._color; - } - - public setRooms(rooms: Room[]) { - // If we're only concerned about the tile count, don't bother setting up listeners. - if (this.byTileCount) { - this.rooms = rooms; - this.calculateTotalState(); - return; - } - - const oldRooms = this.rooms; - const diff = arrayDiff(oldRooms, rooms); - this.rooms = rooms; - for (const oldRoom of diff.removed) { - 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.destroy(); - } - for (const newRoom of diff.added) { - const state = new TagSpecificNotificationState(newRoom, this.tagId); - state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); - if (this.states[newRoom.roomId]) { - // "Should never happen" disclaimer. - console.warn("Overwriting notification state for room:", newRoom.roomId); - this.states[newRoom.roomId].destroy(); - } - this.states[newRoom.roomId] = state; - } - - this.calculateTotalState(); - } - - public getForRoom(room: Room) { - const state = this.states[room.roomId]; - if (!state) throw new Error("Unknown room for notification state"); - return state; - } - - public destroy() { - for (const state of Object.values(this.states)) { - state.destroy(); - } - this.states = {}; - } - - private onRoomNotificationStateUpdate = () => { - this.calculateTotalState(); - }; - - private calculateTotalState() { - const before = {count: this.count, symbol: this.symbol, color: this.color}; - - if (this.byTileCount) { - this._color = NotificationColor.Red; - this._count = this.rooms.length; - } else { - this._count = 0; - this._color = NotificationColor.None; - for (const state of Object.values(this.states)) { - this._count += state.count; - this._color = Math.max(this.color, state.color); - } - } - - // finally, publish an update if needed - const after = {count: this.count, symbol: this.symbol, color: this.color}; - if (JSON.stringify(before) !== JSON.stringify(after)) { - this.emit(NOTIFICATION_STATE_UPDATE); - } - } -} diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 58ebf54bf7..fadecbd0d6 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -26,13 +26,14 @@ import AccessibleButton from "../../views/elements/AccessibleButton"; import RoomTile2 from "./RoomTile2"; import { ResizableBox, ResizeCallbackData } from "react-resizable"; import { ListLayout } from "../../../stores/room-list/ListLayout"; -import NotificationBadge, { ListNotificationState } from "./NotificationBadge"; import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import StyledCheckbox from "../elements/StyledCheckbox"; import StyledRadioButton from "../elements/StyledRadioButton"; import RoomListStore from "../../../stores/room-list/RoomListStore2"; import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models"; import { TagID } from "../../../stores/room-list/models"; +import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; +import NotificationBadge from "./NotificationBadge"; // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 1284728855..4c9147e005 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -26,16 +26,15 @@ import RoomAvatar from "../../views/avatars/RoomAvatar"; import dis from '../../../dispatcher/dispatcher'; import { Key } from "../../../Keyboard"; import ActiveRoomObserver from "../../../ActiveRoomObserver"; -import NotificationBadge, { - INotificationState, - NotificationColor, - TagSpecificNotificationState -} from "./NotificationBadge"; import { _t } from "../../../languageHandler"; import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import RoomTileIcon from "./RoomTileIcon"; +import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; +import { INotificationState } from "../../../stores/notifications/INotificationState"; +import NotificationBadge from "./NotificationBadge"; +import { NotificationColor } from "../../../stores/notifications/NotificationColor"; // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 diff --git a/src/stores/notifications/INotificationState.ts b/src/stores/notifications/INotificationState.ts new file mode 100644 index 0000000000..65bd7b7957 --- /dev/null +++ b/src/stores/notifications/INotificationState.ts @@ -0,0 +1,26 @@ +/* +Copyright 2020 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 { EventEmitter } from "events"; +import { NotificationColor } from "./NotificationColor"; + +export const NOTIFICATION_STATE_UPDATE = "update"; + +export interface INotificationState extends EventEmitter { + symbol?: string; + count: number; + color: NotificationColor; +} diff --git a/src/stores/notifications/ListNotificationState.ts b/src/stores/notifications/ListNotificationState.ts new file mode 100644 index 0000000000..5773693b47 --- /dev/null +++ b/src/stores/notifications/ListNotificationState.ts @@ -0,0 +1,120 @@ +/* +Copyright 2020 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 { EventEmitter } from "events"; +import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState"; +import { NotificationColor } from "./NotificationColor"; +import { IDestroyable } from "../../utils/IDestroyable"; +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 { TagSpecificNotificationState } from "./TagSpecificNotificationState"; + +export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState { + private _count: number; + private _color: NotificationColor; + private rooms: Room[] = []; + private states: { [roomId: string]: RoomNotificationState } = {}; + + constructor(private byTileCount = false, private tagId: TagID) { + super(); + } + + public get symbol(): string { + return null; // This notification state doesn't support symbols + } + + public get count(): number { + return this._count; + } + + public get color(): NotificationColor { + return this._color; + } + + public setRooms(rooms: Room[]) { + // If we're only concerned about the tile count, don't bother setting up listeners. + if (this.byTileCount) { + this.rooms = rooms; + this.calculateTotalState(); + return; + } + + const oldRooms = this.rooms; + const diff = arrayDiff(oldRooms, rooms); + this.rooms = rooms; + for (const oldRoom of diff.removed) { + 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.destroy(); + } + for (const newRoom of diff.added) { + const state = new TagSpecificNotificationState(newRoom, this.tagId); + state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); + if (this.states[newRoom.roomId]) { + // "Should never happen" disclaimer. + console.warn("Overwriting notification state for room:", newRoom.roomId); + this.states[newRoom.roomId].destroy(); + } + this.states[newRoom.roomId] = state; + } + + this.calculateTotalState(); + } + + public getForRoom(room: Room) { + const state = this.states[room.roomId]; + if (!state) throw new Error("Unknown room for notification state"); + return state; + } + + public destroy() { + for (const state of Object.values(this.states)) { + state.destroy(); + } + this.states = {}; + } + + private onRoomNotificationStateUpdate = () => { + this.calculateTotalState(); + }; + + private calculateTotalState() { + const before = {count: this.count, symbol: this.symbol, color: this.color}; + + if (this.byTileCount) { + this._color = NotificationColor.Red; + this._count = this.rooms.length; + } else { + this._count = 0; + this._color = NotificationColor.None; + for (const state of Object.values(this.states)) { + this._count += state.count; + this._color = Math.max(this.color, state.color); + } + } + + // finally, publish an update if needed + const after = {count: this.count, symbol: this.symbol, color: this.color}; + if (JSON.stringify(before) !== JSON.stringify(after)) { + this.emit(NOTIFICATION_STATE_UPDATE); + } + } +} + diff --git a/src/stores/notifications/NotificationColor.ts b/src/stores/notifications/NotificationColor.ts new file mode 100644 index 0000000000..aa2384b3df --- /dev/null +++ b/src/stores/notifications/NotificationColor.ts @@ -0,0 +1,24 @@ +/* +Copyright 2020 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. +*/ + +export enum NotificationColor { + // Inverted (None -> Red) because we do integer comparisons on this + None, // nothing special + // TODO: Remove bold with notifications: https://github.com/vector-im/riot-web/issues/14227 + Bold, // no badge, show as unread + Grey, // unread notified messages + Red, // unread pings +} diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts new file mode 100644 index 0000000000..b9bc3f3492 --- /dev/null +++ b/src/stores/notifications/RoomNotificationState.ts @@ -0,0 +1,131 @@ +/* +Copyright 2020 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 { EventEmitter } from "events"; +import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState"; +import { NotificationColor } from "./NotificationColor"; +import { IDestroyable } from "../../utils/IDestroyable"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { EffectiveMembership, getEffectiveMembership } from "../room-list/membership"; +import { readReceiptChangeIsFor } from "../../utils/read-receipts"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import * as RoomNotifs from '../../RoomNotifs'; +import * as Unread from '../../Unread'; + +export class RoomNotificationState extends EventEmitter implements IDestroyable, INotificationState { + private _symbol: string; + private _count: number; + private _color: NotificationColor; + + constructor(private room: Room) { + super(); + this.room.on("Room.receipt", this.handleReadReceipt); + this.room.on("Room.timeline", this.handleRoomEventUpdate); + this.room.on("Room.redaction", this.handleRoomEventUpdate); + MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate); + this.updateNotificationState(); + } + + public get symbol(): string { + return this._symbol; + } + + public get count(): number { + return this._count; + } + + public get color(): NotificationColor { + return this._color; + } + + private get roomIsInvite(): boolean { + return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite; + } + + public destroy(): void { + this.room.removeListener("Room.receipt", this.handleReadReceipt); + this.room.removeListener("Room.timeline", this.handleRoomEventUpdate); + this.room.removeListener("Room.redaction", this.handleRoomEventUpdate); + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate); + } + } + + private handleReadReceipt = (event: MatrixEvent, room: Room) => { + if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore + if (room.roomId !== this.room.roomId) return; // not for us - ignore + this.updateNotificationState(); + }; + + private handleRoomEventUpdate = (event: MatrixEvent) => { + const roomId = event.getRoomId(); + + if (roomId !== this.room.roomId) return; // ignore - not for us + this.updateNotificationState(); + }; + + private updateNotificationState() { + const before = {count: this.count, symbol: this.symbol, color: this.color}; + + if (this.roomIsInvite) { + this._color = NotificationColor.Red; + this._symbol = "!"; + this._count = 1; // not used, technically + } else { + const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'highlight'); + const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'total'); + + // For a 'true count' we pick the grey notifications first because they include the + // red notifications. If we don't have a grey count for some reason we use the red + // count. If that count is broken for some reason, assume zero. This avoids us showing + // a badge for 'NaN' (which formats as 'NaNB' for NaN Billion). + const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0); + + // Note: we only set the symbol if we have an actual count. We don't want to show + // zero on badges. + + if (redNotifs > 0) { + this._color = NotificationColor.Red; + this._count = trueCount; + this._symbol = null; // symbol calculated by component + } else if (greyNotifs > 0) { + this._color = NotificationColor.Grey; + this._count = trueCount; + this._symbol = null; // symbol calculated by component + } else { + // We don't have any notified messages, but we might have unread messages. Let's + // find out. + const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room); + if (hasUnread) { + this._color = NotificationColor.Bold; + } else { + this._color = NotificationColor.None; + } + + // no symbol or count for this state + this._count = 0; + this._symbol = null; + } + } + + // finally, publish an update if needed + const after = {count: this.count, symbol: this.symbol, color: this.color}; + if (JSON.stringify(before) !== JSON.stringify(after)) { + this.emit(NOTIFICATION_STATE_UPDATE); + } + } +} diff --git a/src/stores/notifications/StaticNotificationState.ts b/src/stores/notifications/StaticNotificationState.ts new file mode 100644 index 0000000000..51902688fe --- /dev/null +++ b/src/stores/notifications/StaticNotificationState.ts @@ -0,0 +1,33 @@ +/* +Copyright 2020 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 { EventEmitter } from "events"; +import { INotificationState } from "./INotificationState"; +import { NotificationColor } from "./NotificationColor"; + +export class StaticNotificationState extends EventEmitter implements INotificationState { + constructor(public symbol: string, public count: number, public color: NotificationColor) { + super(); + } + + public static forCount(count: number, color: NotificationColor): StaticNotificationState { + return new StaticNotificationState(null, count, color); + } + + public static forSymbol(symbol: string, color: NotificationColor): StaticNotificationState { + return new StaticNotificationState(symbol, 0, color); + } +} diff --git a/src/stores/notifications/TagSpecificNotificationState.ts b/src/stores/notifications/TagSpecificNotificationState.ts new file mode 100644 index 0000000000..02d8717fee --- /dev/null +++ b/src/stores/notifications/TagSpecificNotificationState.ts @@ -0,0 +1,45 @@ +/* +Copyright 2020 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 { Room } from "matrix-js-sdk/src/models/room"; +import { DefaultTagID, TagID } from "../room-list/models"; +import { RoomNotificationState } from "./RoomNotificationState"; + +export class TagSpecificNotificationState extends RoomNotificationState { + private static TAG_TO_COLOR: { + // @ts-ignore - TS wants this to be a string key, but we know better + [tagId: TagID]: NotificationColor, + } = { + [DefaultTagID.DM]: NotificationColor.Red, + }; + + private readonly colorWhenNotIdle?: NotificationColor; + + constructor(room: Room, tagId: TagID) { + super(room); + + const specificColor = TagSpecificNotificationState.TAG_TO_COLOR[tagId]; + if (specificColor) this.colorWhenNotIdle = specificColor; + } + + public get color(): NotificationColor { + if (!this.colorWhenNotIdle) return super.color; + + if (super.color !== NotificationColor.None) return this.colorWhenNotIdle; + return super.color; + } +}