From 086b9101fa8720f698644b934e6886ad1563e567 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Mon, 8 Jun 2020 13:42:18 -0600
Subject: [PATCH 1/2] Add sublist badge counts to new room list

Also add IDLE state to rooms
---
 res/css/_components.scss                      |   1 +
 res/css/views/rooms/_NotificationBadge.scss   |  72 +++++
 res/css/views/rooms/_RoomSublist2.scss        |  35 ++-
 res/css/views/rooms/_RoomTile2.scss           |  34 +--
 .../views/rooms/NotificationBadge.tsx         | 279 ++++++++++++++++++
 src/components/views/rooms/RoomSublist2.tsx   |  64 ++--
 src/components/views/rooms/RoomTile2.tsx      | 109 +------
 7 files changed, 408 insertions(+), 186 deletions(-)
 create mode 100644 res/css/views/rooms/_NotificationBadge.scss
 create mode 100644 src/components/views/rooms/NotificationBadge.tsx

diff --git a/res/css/_components.scss b/res/css/_components.scss
index 62bec5ad62..61e3018725 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -170,6 +170,7 @@
 @import "./views/rooms/_MemberList.scss";
 @import "./views/rooms/_MessageComposer.scss";
 @import "./views/rooms/_MessageComposerFormatBar.scss";
+@import "./views/rooms/_NotificationBadge.scss";
 @import "./views/rooms/_PinnedEventTile.scss";
 @import "./views/rooms/_PinnedEventsPanel.scss";
 @import "./views/rooms/_PresenceLabel.scss";
diff --git a/res/css/views/rooms/_NotificationBadge.scss b/res/css/views/rooms/_NotificationBadge.scss
new file mode 100644
index 0000000000..609e41c583
--- /dev/null
+++ b/res/css/views/rooms/_NotificationBadge.scss
@@ -0,0 +1,72 @@
+/*
+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.
+*/
+
+.mx_NotificationBadge {
+    &:not(.mx_NotificationBadge_visible) {
+        display: none;
+    }
+
+    // Badges are structured a bit weirdly to work around issues with non-monospace
+    // font styles. The badge pill is actually a background div and the count floats
+    // within that. For example:
+    //
+    //  ( 99+ ) <-- Rounded pill is a _bg class.
+    //     ^- The count is an element floating within that.
+
+    &.mx_NotificationBadge_visible {
+        background-color: $roomtile2-badge-color;
+        margin-right: 14px;
+
+        // Create a flexbox to order the count a bit easier
+        display: flex;
+        align-items: center;
+        justify-content: center;
+
+        &.mx_NotificationBadge_highlighted {
+            // TODO: Use a more specific variable
+            background-color: $warning-color;
+        }
+
+        // These are the 3 background types
+
+        &.mx_NotificationBadge_dot {
+            width: 6px;
+            height: 6px;
+            border-radius: 6px;
+            margin-right: 18px;
+        }
+
+        &.mx_NotificationBadge_2char {
+            width: 16px;
+            height: 16px;
+            border-radius: 16px;
+        }
+
+        &.mx_NotificationBadge_3char {
+            width: 26px;
+            height: 16px;
+            border-radius: 16px;
+        }
+
+        // The following is the floating badge
+
+        .mx_NotificationBadge_count {
+            font-size: $font-10px;
+            line-height: $font-14px;
+            color: #fff; // TODO: Variable
+        }
+    }
+}
diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss
index e6e5af3b48..cfb9bc3b6d 100644
--- a/res/css/views/rooms/_RoomSublist2.scss
+++ b/res/css/views/rooms/_RoomSublist2.scss
@@ -30,11 +30,36 @@ limitations under the License.
     margin-bottom: 12px;
 
     .mx_RoomSublist2_headerContainer {
-        text-transform: uppercase;
-        opacity: 0.5;
-        line-height: $font-16px;
-        font-size: $font-12px;
-        padding-bottom: 8px;
+        // Create a flexbox to make ordering easy
+        display: flex;
+        align-items: center;
+
+        .mx_RoomSublist2_badgeContainer {
+            opacity: 0.8;
+            padding-right: 7px;
+
+            // Create another flexbox row because it's super easy to position the badge at
+            // the end this way.
+            display: flex;
+            align-items: center;
+            justify-content: flex-end;
+        }
+
+        .mx_RoomSublist2_headerText {
+            text-transform: uppercase;
+            opacity: 0.5;
+            line-height: $font-16px;
+            font-size: $font-12px;
+            padding-bottom: 8px;
+
+            width: 100%;
+            flex: 1;
+
+            // Ellipsize any text overflow
+            text-overflow: ellipsis;
+            overflow: hidden;
+            white-space: nowrap;
+        }
     }
 
     .mx_RoomSublist2_resizeBox {
diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss
index 3151bb8716..41c9469bc1 100644
--- a/res/css/views/rooms/_RoomTile2.scss
+++ b/res/css/views/rooms/_RoomTile2.scss
@@ -50,11 +50,14 @@ limitations under the License.
         // TODO: Ellipsis on the name and preview
 
         .mx_RoomTile2_name {
-            font-weight: 600;
             font-size: $font-14px;
             line-height: $font-19px;
         }
 
+        .mx_RoomTile2_name.mx_RoomTile2_nameHasUnreadEvents {
+            font-weight: 600;
+        }
+
         .mx_RoomTile2_messagePreview {
             font-size: $font-13px;
             line-height: $font-18px;
@@ -70,34 +73,5 @@ limitations under the License.
         display: flex;
         align-items: center;
         justify-content: flex-end;
-
-        .mx_RoomTile2_badge {
-            background-color: $roomtile2-badge-color;
-
-            &:not(.mx_RoomTile2_badgeEmpty) {
-                border-radius: 16px;
-                font-size: $font-10px;
-                line-height: $font-14px;
-                text-align: center;
-                font-weight: bold;
-                margin-right: 14px;
-                color: #fff; // TODO: Variable
-
-                // TODO: Confirm padding on counted badges
-                padding: 2px 5px;
-            }
-
-            &.mx_RoomTile2_badgeEmpty {
-                width: 6px;
-                height: 6px;
-                border-radius: 6px;
-                margin-right: 18px;
-            }
-
-            &.mx_RoomTile2_badgeHighlight {
-                // TODO: Use a more specific variable
-                background-color: $warning-color;
-            }
-        }
     }
 }
diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx
new file mode 100644
index 0000000000..a77d2fc8d0
--- /dev/null
+++ b/src/components/views/rooms/NotificationBadge.tsx
@@ -0,0 +1,279 @@
+/*
+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 React from "react";
+import classNames from "classnames";
+import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
+import { Room } from "matrix-js-sdk/src/models/room";
+import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
+import AccessibleButton from "../../views/elements/AccessibleButton";
+import RoomAvatar from "../../views/avatars/RoomAvatar";
+import dis from '../../../dispatcher/dispatcher';
+import { Key } from "../../../Keyboard";
+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 ActiveRoomObserver from "../../../ActiveRoomObserver";
+import { EventEmitter } from "events";
+import { arrayDiff } from "../../../utils/arrays";
+
+export const NOTIFICATION_STATE_UPDATE = "update";
+
+export enum NotificationColor {
+    // Inverted (None -> Red) because we do integer comparisons on this
+    None, // nothing special
+    Bold, // no badge, show as unread
+    Grey, // unread notified messages
+    Red,  // unread pings
+}
+
+export interface INotificationState extends EventEmitter {
+    symbol?: string;
+    count: number;
+    color: NotificationColor;
+}
+
+interface IProps {
+    notification: INotificationState;
+
+    /**
+     * If true, the badge will conditionally display a badge without count for the user.
+     */
+    allowNoCount: boolean;
+}
+
+interface IState {
+}
+
+export default class NotificationBadge extends React.PureComponent<IProps, IState> {
+    constructor(props: IProps) {
+        super(props);
+        this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
+    }
+
+    public componentDidUpdate(prevProps: Readonly<IProps>) {
+        if (prevProps.notification) {
+            prevProps.notification.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
+        }
+
+        this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
+    }
+
+    private onNotificationUpdate = () => {
+        this.forceUpdate(); // notification state changed - update
+    };
+
+    public render(): React.ReactElement {
+        // Don't show a badge if we don't need to
+        if (this.props.notification.color <= NotificationColor.Bold) return null;
+
+        const hasNotif = this.props.notification.color >= NotificationColor.Red;
+        const hasCount = this.props.notification.color >= NotificationColor.Grey;
+        const isEmptyBadge = this.props.allowNoCount && !localStorage.getItem("mx_rl_rt_badgeCount");
+
+        let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count);
+        if (isEmptyBadge) symbol = "";
+
+        const classes = classNames({
+            'mx_NotificationBadge': true,
+            'mx_NotificationBadge_visible': hasCount,
+            'mx_NotificationBadge_highlighted': hasNotif,
+            'mx_NotificationBadge_dot': isEmptyBadge,
+            'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
+            'mx_NotificationBadge_3char': symbol.length > 2,
+        });
+
+        return (
+            <div className={classes}>
+                <span className="mx_NotificationBadge_count">{symbol}</span>
+            </div>
+        );
+    }
+}
+
+export class RoomNotificationState extends EventEmitter {
+    private _symbol: string;
+    private _count: number;
+    private _color: NotificationColor;
+
+    constructor(private room: Room) {
+        super();
+        this.room.on("Room.receipt", this.handleRoomEventUpdate);
+        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 dispose(): void {
+        this.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
+        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 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 ListNotificationState extends EventEmitter {
+    private _count: number;
+    private _color: NotificationColor;
+    private rooms: Room[] = [];
+    private states: { [roomId: string]: RoomNotificationState } = {};
+
+    constructor(private byTileCount = false) {
+        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);
+        for (const oldRoom of diff.removed) {
+            const state = this.states[oldRoom.roomId];
+            delete this.states[oldRoom.roomId];
+            state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
+            state.dispose();
+        }
+        for (const newRoom of diff.added) {
+            const state = new RoomNotificationState(newRoom);
+            state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
+            this.states[newRoom.roomId] = state;
+        }
+
+        this.calculateTotalState();
+    }
+
+    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 650a3ae645..cd27156cbd 100644
--- a/src/components/views/rooms/RoomSublist2.tsx
+++ b/src/components/views/rooms/RoomSublist2.tsx
@@ -26,7 +26,7 @@ import AccessibleButton from "../../views/elements/AccessibleButton";
 import RoomTile2 from "./RoomTile2";
 import { ResizableBox, ResizeCallbackData } from "react-resizable";
 import { ListLayout } from "../../../stores/room-list/ListLayout";
-import { DefaultTagID, TagID } from "../../../stores/room-list/models";
+import NotificationBadge, { ListNotificationState } from "./NotificationBadge";
 
 /*******************************************************************
  *   CAUTION                                                       *
@@ -56,13 +56,19 @@ interface IProps {
 }
 
 interface IState {
+    notificationState: ListNotificationState;
 }
 
 export default class RoomSublist2 extends React.Component<IProps, IState> {
     private headerButton = createRef();
 
-    private hasTiles(): boolean {
-        return this.numTiles > 0;
+    constructor(props: IProps) {
+        super(props);
+
+        this.state = {
+            notificationState: new ListNotificationState(this.props.isInvite),
+        };
+        this.state.notificationState.setRooms(this.props.rooms);
     }
 
     private get numTiles(): number {
@@ -70,6 +76,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
         return (this.props.rooms || []).length;
     }
 
+    public componentDidUpdate() {
+        this.state.notificationState.setRooms(this.props.rooms);
+    }
+
     private onAddRoom = (e) => {
         e.stopPropagation();
         if (this.props.onAddRoom) this.props.onAddRoom();
@@ -106,13 +116,6 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
     }
 
     private renderHeader(): React.ReactElement {
-        // TODO: Handle badge count
-        // const notifications = !this.props.isInvite
-        //     ? RoomNotifs.aggregateNotificationCount(this.props.rooms)
-        //     : {count: 0, highlight: true};
-        // const notifCount = notifications.count;
-        // const notifHighlight = notifications.highlight;
-
         // TODO: Title on collapsed
         // TODO: Incoming call box
 
@@ -123,42 +126,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
                     const tabIndex = isActive ? 0 : -1;
 
                     // TODO: Collapsed state
-                    // TODO: Handle badge count
-                    // let badge;
-                    // if (true) { // !isCollapsed
-                    //     const showCount = localStorage.getItem("mx_rls_count") || notifHighlight;
-                    //     const badgeClasses = classNames({
-                    //         'mx_RoomSublist2_badge': true,
-                    //         'mx_RoomSublist2_badgeHighlight': notifHighlight,
-                    //         'mx_RoomSublist2_badgeEmpty': !showCount,
-                    //     });
-                    //     // Wrap the contents in a div and apply styles to the child div so that the browser default outline works
-                    //     if (notifCount > 0) {
-                    //         const count = <div>{FormattingUtils.formatCount(notifCount)}</div>;
-                    //         badge = (
-                    //             <AccessibleButton
-                    //                 tabIndex={tabIndex}
-                    //                 className={badgeClasses}
-                    //                 aria-label={_t("Jump to first unread room.")}
-                    //             >
-                    //                 {showCount ? count : null}
-                    //             </AccessibleButton>
-                    //         );
-                    //     } else if (this.props.isInvite && this.hasTiles()) {
-                    //         // Render the `!` badge for invites
-                    //         badge = (
-                    //             <AccessibleButton
-                    //                 tabIndex={tabIndex}
-                    //                 className={badgeClasses}
-                    //                 aria-label={_t("Jump to first invite.")}
-                    //             >
-                    //                 <div>
-                    //                     {FormattingUtils.formatCount(this.numTiles)}
-                    //                 </div>
-                    //             </AccessibleButton>
-                    //         );
-                    //     }
-                    // }
+
+                    const badge = <NotificationBadge allowNoCount={false} notification={this.state.notificationState}/>;
 
                     // TODO: Aux button
                     // let addRoomButton = null;
@@ -185,6 +154,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
                             >
                                 <span>{this.props.label}</span>
                             </AccessibleButton>
+                            <div className="mx_RoomSublist2_badgeContainer">
+                                {badge}
+                            </div>
                         </div>
                     );
                 }}
diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx
index 09d7b46ba5..d4f64e4571 100644
--- a/src/components/views/rooms/RoomTile2.tsx
+++ b/src/components/views/rooms/RoomTile2.tsx
@@ -25,13 +25,8 @@ import AccessibleButton from "../../views/elements/AccessibleButton";
 import RoomAvatar from "../../views/avatars/RoomAvatar";
 import dis from '../../../dispatcher/dispatcher';
 import { Key } from "../../../Keyboard";
-import * as RoomNotifs from '../../../RoomNotifs';
-import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
-import * as Unread from '../../../Unread';
-import * as FormattingUtils from "../../../utils/FormattingUtils";
-import { MatrixClientPeg } from "../../../MatrixClientPeg";
-import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import ActiveRoomObserver from "../../../ActiveRoomObserver";
+import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge";
 
 /*******************************************************************
  *   CAUTION                                                       *
@@ -41,14 +36,6 @@ import ActiveRoomObserver from "../../../ActiveRoomObserver";
  * warning disappears.                                             *
  *******************************************************************/
 
-enum NotificationColor {
-    // Inverted (None -> Red) because we do integer comparisons on this
-    None, // nothing special
-    Bold, // no badge, show as unread
-    Grey, // unread notified messages
-    Red,  // unread pings
-}
-
 interface IProps {
     room: Room;
     showMessagePreview: boolean;
@@ -58,11 +45,6 @@ interface IProps {
     // TODO: Incoming call boxes?
 }
 
-interface INotificationState {
-    symbol: string;
-    color: NotificationColor;
-}
-
 interface IState {
     hover: boolean;
     notificationState: INotificationState;
@@ -88,89 +70,17 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
 
         this.state = {
             hover: false,
-            notificationState: this.getNotificationState(),
+            notificationState: new RoomNotificationState(this.props.room),
             selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
         };
 
-        this.props.room.on("Room.receipt", this.handleRoomEventUpdate);
-        this.props.room.on("Room.timeline", this.handleRoomEventUpdate);
-        this.props.room.on("Room.redaction", this.handleRoomEventUpdate);
-        MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
         ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
     }
 
     public componentWillUnmount() {
         if (this.props.room) {
-            this.props.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
-            this.props.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
-            this.props.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
             ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
         }
-        if (MatrixClientPeg.get()) {
-            MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
-        }
-    }
-
-    // XXX: This is a bit of an awful-looking hack. We should probably be using state for
-    // this, but instead we're kinda forced to either duplicate the code or thread a variable
-    // through the code paths. This feels like the least evil option.
-    private get roomIsInvite(): boolean {
-        return getEffectiveMembership(this.props.room.getMyMembership()) === EffectiveMembership.Invite;
-    }
-
-    private handleRoomEventUpdate = (event: MatrixEvent) => {
-        const roomId = event.getRoomId();
-
-        // Sanity check: should never happen
-        if (roomId !== this.props.room.roomId) return;
-
-        this.updateNotificationState();
-    };
-
-    private updateNotificationState() {
-        this.setState({notificationState: this.getNotificationState()});
-    }
-
-    private getNotificationState(): INotificationState {
-        const state: INotificationState = {
-            color: NotificationColor.None,
-            symbol: null,
-        };
-
-        if (this.roomIsInvite) {
-            state.color = NotificationColor.Red;
-            state.symbol = "!";
-        } else {
-            const redNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'highlight');
-            const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.props.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) {
-                state.color = NotificationColor.Red;
-                state.symbol = FormattingUtils.formatCount(trueCount);
-            } else if (greyNotifs > 0) {
-                state.color = NotificationColor.Grey;
-                state.symbol = FormattingUtils.formatCount(trueCount);
-            } else {
-                // We don't have any notified messages, but we might have unread messages. Let's
-                // find out.
-                const hasUnread = Unread.doesRoomHaveUnreadMessages(this.props.room);
-                if (hasUnread) {
-                    state.color = NotificationColor.Bold;
-                    // no symbol for this state
-                }
-            }
-        }
-
-        return state;
     }
 
     private onTileMouseEnter = () => {
@@ -206,19 +116,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
             'mx_RoomTile2_selected': this.state.selected,
         });
 
-        let badge;
-        const hasBadge = this.state.notificationState.color > NotificationColor.Bold;
-        if (hasBadge) {
-            const hasNotif = this.state.notificationState.color >= NotificationColor.Red;
-            const isEmptyBadge = !localStorage.getItem("mx_rl_rt_badgeCount");
-            const badgeClasses = classNames({
-                'mx_RoomTile2_badge': true,
-                'mx_RoomTile2_badgeHighlight': hasNotif,
-                'mx_RoomTile2_badgeEmpty': isEmptyBadge,
-            });
-            const symbol = this.state.notificationState.symbol;
-            badge = <div className={badgeClasses}>{isEmptyBadge ? null : symbol}</div>;
-        }
+        const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true} />;
 
         // TODO: the original RoomTile uses state for the room name. Do we need to?
         let name = this.props.room.name;
@@ -237,6 +135,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
         const nameClasses = classNames({
             "mx_RoomTile2_name": true,
             "mx_RoomTile2_nameWithPreview": !!messagePreview,
+            "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold,
         });
 
         const avatarSize = 32;

From 8632d56e97a9cb7d032ce97c3bf12f9273220ec2 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Tue, 9 Jun 2020 08:05:35 -0600
Subject: [PATCH 2/2] dispose -> destroy

---
 src/components/views/rooms/NotificationBadge.tsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx
index a77d2fc8d0..50af2ee1d0 100644
--- a/src/components/views/rooms/NotificationBadge.tsx
+++ b/src/components/views/rooms/NotificationBadge.tsx
@@ -136,7 +136,7 @@ export class RoomNotificationState extends EventEmitter {
         return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
     }
 
-    public dispose(): void {
+    public destroy(): void {
         this.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
         this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
         this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
@@ -240,7 +240,7 @@ export class ListNotificationState extends EventEmitter {
             const state = this.states[oldRoom.roomId];
             delete this.states[oldRoom.roomId];
             state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
-            state.dispose();
+            state.destroy();
         }
         for (const newRoom of diff.added) {
             const state = new RoomNotificationState(newRoom);