Merge pull request #4861 from matrix-org/travis/room-list/notification-state
Reorganize and match new room list badges to old list behaviourpull/21833/head
						commit
						9fec5c98af
					
				|  | @ -20,7 +20,9 @@ import { Room } from "matrix-js-sdk/src/models/room"; | |||
| import { TagID } from '../../../stores/room-list/models'; | ||||
| import RoomAvatar from "./RoomAvatar"; | ||||
| import RoomTileIcon from "../rooms/RoomTileIcon"; | ||||
| import NotificationBadge, { INotificationState, TagSpecificNotificationState } from '../rooms/NotificationBadge'; | ||||
| import NotificationBadge from '../rooms/NotificationBadge'; | ||||
| import { INotificationState } from "../../../stores/notifications/INotificationState"; | ||||
| import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; | ||||
| 
 | ||||
| interface IProps { | ||||
|     room: Room; | ||||
|  | @ -60,4 +62,4 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt | |||
|             {badge} | ||||
|         </div>; | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -17,37 +17,13 @@ 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"; | ||||
| import AccessibleButton from "../elements/AccessibleButton"; | ||||
| import { XOR } from "../../../@types/common"; | ||||
| 
 | ||||
| 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; | ||||
|  | @ -123,11 +99,14 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I | |||
|         // Don't show a badge if we don't need to
 | ||||
|         if (notification.color <= NotificationColor.None) return null; | ||||
| 
 | ||||
|         // TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261
 | ||||
|         // As of writing, that is "if red, show count always" and "optionally show counts instead of dots".
 | ||||
|         // See git diff for what that boolean state looks like.
 | ||||
|         // XXX: We ignore this.state.showCounts (the setting which controls counts vs dots).
 | ||||
|         const hasNotif = notification.color >= NotificationColor.Red; | ||||
|         const hasCount = notification.color >= NotificationColor.Grey; | ||||
|         const hasUnread = notification.color >= NotificationColor.Bold; | ||||
|         const couldBeEmpty = (!this.state.showCounts || hasUnread) && !hasNotif; | ||||
|         let isEmptyBadge = couldBeEmpty && (!this.state.showCounts || !hasCount); | ||||
|         const hasAnySymbol = notification.symbol || notification.count > 0; | ||||
|         let isEmptyBadge = !hasAnySymbol || !hasCount; | ||||
|         if (forceCount) { | ||||
|             isEmptyBadge = false; | ||||
|             if (!hasCount) return null; // Can't render a badge
 | ||||
|  | @ -160,242 +139,3 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I | |||
|         ); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| // TODO: Clean up these state classes: https://github.com/vector-im/riot-web/issues/14153
 | ||||
| 
 | ||||
| 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); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| 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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| 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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -33,7 +33,8 @@ import { ListLayout } from "../../../stores/room-list/ListLayout"; | |||
| import { MatrixClientPeg } from "../../../MatrixClientPeg"; | ||||
| import GroupAvatar from "../avatars/GroupAvatar"; | ||||
| import TemporaryTile from "./TemporaryTile"; | ||||
| import { NotificationColor, StaticNotificationState } from "./NotificationBadge"; | ||||
| import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; | ||||
| 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
 | ||||
|  |  | |||
|  | @ -26,7 +26,6 @@ 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"; | ||||
|  | @ -34,6 +33,8 @@ import RoomListStore from "../../../stores/room-list/RoomListStore2"; | |||
| import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models"; | ||||
| import { DefaultTagID, TagID } from "../../../stores/room-list/models"; | ||||
| import dis from "../../../dispatcher/dispatcher"; | ||||
| import NotificationBadge from "./NotificationBadge"; | ||||
| import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; | ||||
| 
 | ||||
| // 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
 | ||||
|  |  | |||
|  | @ -25,11 +25,6 @@ import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleBu | |||
| 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, MenuItemRadio } from "../../structures/ContextMenu"; | ||||
| import { DefaultTagID, TagID } from "../../../stores/room-list/models"; | ||||
|  | @ -39,6 +34,10 @@ import RoomTileIcon from "./RoomTileIcon"; | |||
| import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs"; | ||||
| import { MatrixClientPeg } from "../../../MatrixClientPeg"; | ||||
| import { setRoomNotifsState } from "../../../RoomNotifs"; | ||||
| 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
 | ||||
|  |  | |||
|  | @ -18,7 +18,9 @@ import React from "react"; | |||
| import classNames from "classnames"; | ||||
| import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; | ||||
| import AccessibleButton from "../../views/elements/AccessibleButton"; | ||||
| import NotificationBadge, { INotificationState, NotificationColor } from "./NotificationBadge"; | ||||
| import { INotificationState } from "../../../stores/notifications/INotificationState"; | ||||
| import NotificationBadge from "./NotificationBadge"; | ||||
| import { NotificationColor } from "../../../stores/notifications/NotificationColor"; | ||||
| 
 | ||||
| interface IProps { | ||||
|     isMinimized: boolean; | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  | @ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | @ -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
 | ||||
| } | ||||
|  | @ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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); | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,46 @@ | |||
| /* | ||||
| 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 { 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, | ||||
|     } = { | ||||
|         // TODO: Update for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261
 | ||||
|         //[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; | ||||
|     } | ||||
| } | ||||
		Loading…
	
		Reference in New Issue
	
	 Travis Ralston
						Travis Ralston