From 29defa3e573b91202db7c235c9c56139ceecf1f5 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Mon, 20 Jul 2020 14:36:12 +0100
Subject: [PATCH 1/4] Tag Watcher don't create new filter if not needed,
 confuses references

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 src/stores/room-list/TagWatcher.ts | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/stores/room-list/TagWatcher.ts b/src/stores/room-list/TagWatcher.ts
index 1c16571e5b..81cbc84be1 100644
--- a/src/stores/room-list/TagWatcher.ts
+++ b/src/stores/room-list/TagWatcher.ts
@@ -54,7 +54,11 @@ export class TagWatcher {
                     continue;
                 }
 
-                newFilters.set(tag, new CommunityFilterCondition(group));
+                let filter = this.filters.get(tag);
+                if (!filter) {
+                    filter = new CommunityFilterCondition(group);
+                }
+                newFilters.set(tag, filter);
             }
 
             // Update the room list store's filters
@@ -73,10 +77,6 @@ export class TagWatcher {
                 if (!filter) continue;
 
                 this.store.removeFilter(filter);
-            }
-
-            // Destroy any and all old filter conditions to prevent resource leaks
-            for (const filter of this.filters.values()) {
                 filter.destroy();
             }
 

From a0b2859436d4b735678930e117d01d3e07f5b006 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Mon, 20 Jul 2020 16:51:16 -0600
Subject: [PATCH 2/4] Support custom tags in the room list again

Fixes https://github.com/vector-im/riot-web/issues/14091

Design needs work, however this is behind labs anyways. This re-implements the behaviour of the old room list.

The implementation ended up being a lot easier due to early confusion with what the TagOrderStore and TagPanel take care of. Turns out they don't deal with tags, but groups. As such, we don't need to do anything with filtering (though we keep some sanity checks in place for safety), and just have to wire up the CustomRoomTagPanel and CustomRoomTagStore.
---
 res/css/structures/_CustomRoomTagPanel.scss   |  6 ++-
 .../structures/CustomRoomTagPanel.js          | 10 ++---
 src/components/structures/LeftPanel.tsx       |  2 +
 src/components/views/rooms/RoomList.tsx       | 39 +++++++++++++++----
 src/stores/CustomRoomTagStore.js              | 34 +++++++---------
 src/stores/room-list/RoomListStore.ts         | 26 +++++++------
 src/stores/room-list/TagWatcher.ts            |  7 +---
 src/stores/room-list/algorithms/Algorithm.ts  |  3 --
 src/stores/room-list/models.ts                |  6 +++
 src/utils/enums.ts                            | 10 +++++
 10 files changed, 89 insertions(+), 54 deletions(-)

diff --git a/res/css/structures/_CustomRoomTagPanel.scss b/res/css/structures/_CustomRoomTagPanel.scss
index 1fb18ec41e..135a51c7cd 100644
--- a/res/css/structures/_CustomRoomTagPanel.scss
+++ b/res/css/structures/_CustomRoomTagPanel.scss
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+// TODO: Update design for custom tags to match new designs
+
 .mx_LeftPanel_tagPanelContainer {
     display: flex;
     flex-direction: column;
@@ -50,7 +52,7 @@ limitations under the License.
     background-color: $accent-color-alt;
     width: 5px;
     position: absolute;
-    left: -15px;
+    left: -9px;
     border-radius: 0 3px 3px 0;
-    top: 2px; // 10 [padding-top] - (56 - 40)/2
+    top: 12px; // just feels right (see comment above about designs needing to be updated)
 }
diff --git a/src/components/structures/CustomRoomTagPanel.js b/src/components/structures/CustomRoomTagPanel.js
index 2753d5c4da..a79bdafeb5 100644
--- a/src/components/structures/CustomRoomTagPanel.js
+++ b/src/components/structures/CustomRoomTagPanel.js
@@ -72,17 +72,17 @@ class CustomRoomTagTile extends React.Component {
         const tag = this.props.tag;
         const avatarHeight = 40;
         const className = classNames({
-            CustomRoomTagPanel_tileSelected: tag.selected,
+            "CustomRoomTagPanel_tileSelected": tag.selected,
         });
         const name = tag.name;
-        const badge = tag.badge;
+        const badgeNotifState = tag.badgeNotifState;
         let badgeElement;
-        if (badge) {
+        if (badgeNotifState) {
             const badgeClasses = classNames({
                 "mx_TagTile_badge": true,
-                "mx_TagTile_badgeHighlight": badge.highlight,
+                "mx_TagTile_badgeHighlight": badgeNotifState.hasMentions,
             });
-            badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badge.count)}</div>);
+            badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badgeNotifState.count)}</div>);
         }
 
         return (
diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx
index dfefeacde5..43e75ffe88 100644
--- a/src/components/structures/LeftPanel.tsx
+++ b/src/components/structures/LeftPanel.tsx
@@ -17,6 +17,7 @@ limitations under the License.
 import * as React from "react";
 import { createRef } from "react";
 import TagPanel from "./TagPanel";
+import CustomRoomTagPanel from "./CustomRoomTagPanel";
 import classNames from "classnames";
 import dis from "../../dispatcher/dispatcher";
 import { _t } from "../../languageHandler";
@@ -361,6 +362,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
         const tagPanel = !this.state.showTagPanel ? null : (
             <div className="mx_LeftPanel_tagPanelContainer">
                 <TagPanel/>
+                {SettingsStore.isFeatureEnabled("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
             </div>
         );
 
diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx
index 3b5a7a0ce7..33c844e955 100644
--- a/src/components/views/rooms/RoomList.tsx
+++ b/src/components/views/rooms/RoomList.tsx
@@ -26,7 +26,7 @@ import { ResizeNotifier } from "../../../utils/ResizeNotifier";
 import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
 import RoomViewStore from "../../../stores/RoomViewStore";
 import { ITagMap } from "../../../stores/room-list/algorithms/models";
-import { DefaultTagID, TagID } from "../../../stores/room-list/models";
+import { DefaultTagID, isCustomTag, TagID } from "../../../stores/room-list/models";
 import dis from "../../../dispatcher/dispatcher";
 import defaultDispatcher from "../../../dispatcher/dispatcher";
 import RoomSublist from "./RoomSublist";
@@ -41,6 +41,7 @@ import { Action } from "../../../dispatcher/actions";
 import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
 import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
 import SettingsStore from "../../../settings/SettingsStore";
+import CustomRoomTagStore from "../../../stores/CustomRoomTagStore";
 
 interface IProps {
     onKeyDown: (ev: React.KeyboardEvent) => void;
@@ -77,6 +78,7 @@ const ALWAYS_VISIBLE_TAGS: TagID[] = [
 
 interface ITagAesthetics {
     sectionLabel: string;
+    sectionLabelRaw?: string;
     addRoomLabel?: string;
     onAddRoom?: (dispatcher: Dispatcher<ActionPayload>) => void;
     isInvite: boolean;
@@ -130,9 +132,22 @@ const TAG_AESTHETICS: {
     },
 };
 
+function customTagAesthetics(tagId: TagID): ITagAesthetics {
+    if (tagId.startsWith("u.")) {
+        tagId = tagId.substring(2);
+    }
+    return {
+        sectionLabel: _td("Custom Tag"),
+        sectionLabelRaw: tagId,
+        isInvite: false,
+        defaultHidden: false,
+    };
+}
+
 export default class RoomList extends React.Component<IProps, IState> {
     private searchFilter: NameFilterCondition = new NameFilterCondition();
     private dispatcherRef;
+    private customTagStoreRef;
 
     constructor(props: IProps) {
         super(props);
@@ -161,12 +176,14 @@ export default class RoomList extends React.Component<IProps, IState> {
 
     public componentDidMount(): void {
         RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
+        this.customTagStoreRef = CustomRoomTagStore.addListener(this.updateLists);
         this.updateLists(); // trigger the first update
     }
 
     public componentWillUnmount() {
         RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
         defaultDispatcher.unregister(this.dispatcherRef);
+        if (this.customTagStoreRef) this.customTagStoreRef.remove();
     }
 
     private onAction = (payload: ActionPayload) => {
@@ -257,12 +274,18 @@ export default class RoomList extends React.Component<IProps, IState> {
     private renderSublists(): React.ReactElement[] {
         const components: React.ReactElement[] = [];
 
-        for (const orderedTagId of TAG_ORDER) {
-            if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) {
-                // Populate custom tags if needed
-                // TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091
+        const tagOrder = TAG_ORDER.reduce((p, c) => {
+            if (c === CUSTOM_TAGS_BEFORE_TAG) {
+                const customTags = Object.keys(this.state.sublists)
+                    .filter(t => isCustomTag(t))
+                    .filter(t => CustomRoomTagStore.getTags()[t]); // isSelected
+                p.push(...customTags);
             }
+            p.push(c);
+            return p;
+        }, [] as TagID[]);
 
+        for (const orderedTagId of tagOrder) {
             const orderedRooms = this.state.sublists[orderedTagId] || [];
             const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
             const totalTiles = orderedRooms.length + (extraTiles ? extraTiles.length : 0);
@@ -270,7 +293,9 @@ export default class RoomList extends React.Component<IProps, IState> {
                 continue; // skip tag - not needed
             }
 
-            const aesthetics: ITagAesthetics = TAG_AESTHETICS[orderedTagId];
+            const aesthetics: ITagAesthetics = isCustomTag(orderedTagId)
+                ? customTagAesthetics(orderedTagId)
+                : TAG_AESTHETICS[orderedTagId];
             if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
 
             const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
@@ -281,7 +306,7 @@ export default class RoomList extends React.Component<IProps, IState> {
                     forRooms={true}
                     rooms={orderedRooms}
                     startAsHidden={aesthetics.defaultHidden}
-                    label={_t(aesthetics.sectionLabel)}
+                    label={aesthetics.sectionLabelRaw ? aesthetics.sectionLabelRaw : _t(aesthetics.sectionLabel)}
                     onAddRoom={onAddRoomFn}
                     addRoomLabel={aesthetics.addRoomLabel}
                     isMinimized={this.props.isMinimized}
diff --git a/src/stores/CustomRoomTagStore.js b/src/stores/CustomRoomTagStore.js
index b002971e2e..9967708c29 100644
--- a/src/stores/CustomRoomTagStore.js
+++ b/src/stores/CustomRoomTagStore.js
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 New Vector Ltd
+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.
@@ -13,15 +14,14 @@ 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 dis from '../dispatcher/dispatcher';
-import * as RoomNotifs from '../RoomNotifs';
 import EventEmitter from 'events';
-import { throttle } from "lodash";
+import {throttle} from "lodash";
 import SettingsStore from "../settings/SettingsStore";
 import RoomListStore, {LISTS_UPDATE_EVENT} from "./room-list/RoomListStore";
-
-// TODO: All of this needs updating for new custom tags: https://github.com/vector-im/riot-web/issues/14091
-const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
+import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateStore";
+import {isCustomTag} from "./room-list/models";
 
 function commonPrefix(a, b) {
     const len = Math.min(a.length, b.length);
@@ -84,8 +84,6 @@ class CustomRoomTagStore extends EventEmitter {
     }
 
     getSortedTags() {
-        const roomLists = RoomListStore.instance.orderedLists;
-
         const tagNames = Object.keys(this._state.tags).sort();
         const prefixes = tagNames.map((name, i) => {
             const isFirst = i === 0;
@@ -97,14 +95,14 @@ class CustomRoomTagStore extends EventEmitter {
             return longestPrefix;
         });
         return tagNames.map((name, i) => {
-            const notifs = RoomNotifs.aggregateNotificationCount(roomLists[name]);
-            let badge;
-            if (notifs.count !== 0) {
-                badge = notifs;
+            const notifs = RoomNotificationStateStore.instance.getListState(name);
+            let badgeNotifState;
+            if (notifs.hasUnreadCount) {
+                badgeNotifState = notifs;
             }
             const avatarLetter = name.substr(prefixes[i].length, 1);
             const selected = this._state.tags[name];
-            return {name, avatarLetter, badge, selected};
+            return {name, avatarLetter, badgeNotifState, selected};
         });
     }
 
@@ -139,16 +137,12 @@ class CustomRoomTagStore extends EventEmitter {
             return;
         }
 
-        const newTagNames = Object.keys(RoomListStore.instance.orderedLists)
-            .filter((tagName) => {
-                return !tagName.match(STANDARD_TAGS_REGEX);
-            }).sort();
+        const newTagNames = Object.keys(RoomListStore.instance.orderedLists).filter(t => isCustomTag(t)).sort();
         const prevTags = this._state && this._state.tags;
-        const newTags = newTagNames.reduce((newTags, tagName) => {
-            newTags[tagName] = (prevTags && prevTags[tagName]) || false;
-            return newTags;
+        return newTagNames.reduce((c, tagName) => {
+            c[tagName] = (prevTags && prevTags[tagName]) || false;
+            return c;
         }, {});
-        return newTags;
     }
 }
 
diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts
index 0cb1e094b1..46dd870541 100644
--- a/src/stores/room-list/RoomListStore.ts
+++ b/src/stores/room-list/RoomListStore.ts
@@ -17,7 +17,7 @@ limitations under the License.
 
 import { MatrixClient } from "matrix-js-sdk/src/client";
 import SettingsStore from "../../settings/SettingsStore";
-import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
+import { DefaultTagID, isCustomTag, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
 import TagOrderStore from "../TagOrderStore";
 import { Room } from "matrix-js-sdk/src/models/room";
 import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
@@ -33,6 +33,7 @@ import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
 import RoomListLayoutStore from "./RoomListLayoutStore";
 import { MarkedExecution } from "../../utils/MarkedExecution";
 import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
+import { isEnumValue } from "../../utils/enums";
 
 interface IState {
     tagsEnabled?: boolean;
@@ -527,25 +528,28 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
     public async regenerateAllLists({trigger = true}) {
         console.warn("Regenerating all room lists");
 
+        const rooms = this.matrixClient.getVisibleRooms();
+        const customTags = new Set<TagID>();
+        if (this.state.tagsEnabled) {
+            for (const room of rooms) {
+                if (!room.tags) continue;
+                const tags = Object.keys(room.tags).filter(t => isCustomTag(t));
+                tags.forEach(t => customTags.add(t));
+            }
+        }
+
         const sorts: ITagSortingMap = {};
         const orders: IListOrderingMap = {};
-        for (const tagId of OrderedDefaultTagIDs) {
+        const allTags = [...OrderedDefaultTagIDs, ...Array.from(customTags)];
+        for (const tagId of allTags) {
             sorts[tagId] = this.calculateTagSorting(tagId);
             orders[tagId] = this.calculateListOrder(tagId);
 
             RoomListLayoutStore.instance.ensureLayoutExists(tagId);
         }
 
-        if (this.state.tagsEnabled) {
-            // TODO: Fix custom tags: https://github.com/vector-im/riot-web/issues/14091
-            const roomTags = TagOrderStore.getOrderedTags() || [];
-
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
-            console.log("rtags", roomTags);
-        }
-
         await this.algorithm.populateTags(sorts, orders);
-        await this.algorithm.setKnownRooms(this.matrixClient.getVisibleRooms());
+        await this.algorithm.setKnownRooms(rooms);
 
         this.initialListsGenerated = true;
 
diff --git a/src/stores/room-list/TagWatcher.ts b/src/stores/room-list/TagWatcher.ts
index 81cbc84be1..dc445d10ba 100644
--- a/src/stores/room-list/TagWatcher.ts
+++ b/src/stores/room-list/TagWatcher.ts
@@ -20,10 +20,9 @@ import { CommunityFilterCondition } from "./filters/CommunityFilterCondition";
 import { arrayDiff, arrayHasDiff } from "../../utils/arrays";
 
 /**
- * Watches for changes in tags/groups to manage filters on the provided RoomListStore
+ * Watches for changes in groups to manage filters on the provided RoomListStore
  */
 export class TagWatcher {
-    // TODO: Support custom tags, somehow: https://github.com/vector-im/riot-web/issues/14091
     private filters = new Map<string, CommunityFilterCondition>();
 
     constructor(private store: RoomListStoreClass) {
@@ -43,8 +42,6 @@ export class TagWatcher {
             }
 
             const newFilters = new Map<string, CommunityFilterCondition>();
-
-            // TODO: Support custom tags, somehow: https://github.com/vector-im/riot-web/issues/14091
             const filterableTags = newTags.filter(t => t.startsWith("+"));
 
             for (const tag of filterableTags) {
@@ -64,8 +61,6 @@ export class TagWatcher {
             // Update the room list store's filters
             const diff = arrayDiff(lastTags, newTags);
             for (const tag of diff.added) {
-                // TODO: Remove this check when custom tags are supported (as we shouldn't be losing filters)
-                // Ref https://github.com/vector-im/riot-web/issues/14091
                 const filter = newFilters.get(tag);
                 if (!filter) continue;
 
diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts
index 9c75dab251..9e920064b0 100644
--- a/src/stores/room-list/algorithms/Algorithm.ts
+++ b/src/stores/room-list/algorithms/Algorithm.ts
@@ -563,9 +563,6 @@ export class Algorithm extends EventEmitter {
     }
 
     public getTagsForRoom(room: Room): TagID[] {
-        // XXX: This duplicates a lot of logic from setKnownRooms above, but has a slightly
-        // different use case and therefore different performance curve
-
         const tags: TagID[] = [];
 
         const membership = getEffectiveMembership(room.getMyMembership());
diff --git a/src/stores/room-list/models.ts b/src/stores/room-list/models.ts
index 0ccd623544..7d3902f552 100644
--- a/src/stores/room-list/models.ts
+++ b/src/stores/room-list/models.ts
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import { isEnumValue } from "../../utils/enums";
+
 export enum DefaultTagID {
     Invite = "im.vector.fake.invite",
     Untagged = "im.vector.fake.recent", // legacy: used to just be 'recent rooms' but now it's all untagged rooms
@@ -36,6 +38,10 @@ export const OrderedDefaultTagIDs = [
 
 export type TagID = string | DefaultTagID;
 
+export function isCustomTag(tagId: TagID): boolean {
+    return !isEnumValue(DefaultTagID, tagId);
+}
+
 export enum RoomUpdateCause {
     Timeline = "TIMELINE",
     PossibleTagChange = "POSSIBLE_TAG_CHANGE",
diff --git a/src/utils/enums.ts b/src/utils/enums.ts
index 79656d7675..f7f4787896 100644
--- a/src/utils/enums.ts
+++ b/src/utils/enums.ts
@@ -25,3 +25,13 @@ export function getEnumValues<T>(e: any): T[] {
         .filter(k => ['string', 'number'].includes(typeof(e[k])))
         .map(k => e[k]);
 }
+
+/**
+ * Determines if a given value is a valid value for the provided enum.
+ * @param e The enum to check against.
+ * @param val The value to search for.
+ * @returns True if the enum contains the value.
+ */
+export function isEnumValue<T>(e: T, val: string | number): boolean {
+    return getEnumValues(e).includes(val);
+}

From de35e0dfcd5cb10790776dda5333d526898595a7 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Mon, 20 Jul 2020 16:56:41 -0600
Subject: [PATCH 3/4] Update i18n

---
 src/i18n/strings/en_EN.json | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 998d582853..1b508feb19 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1156,6 +1156,7 @@
     "Low priority": "Low priority",
     "System Alerts": "System Alerts",
     "Historical": "Historical",
+    "Custom Tag": "Custom Tag",
     "This room": "This room",
     "Joining room …": "Joining room …",
     "Loading …": "Loading …",

From ae069576f62ca3ac0f4556114cf7691d8d926de9 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Tue, 21 Jul 2020 10:33:30 +0100
Subject: [PATCH 4/4] Fix the tag panel context menu

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 src/components/views/elements/TagTile.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/views/elements/TagTile.js b/src/components/views/elements/TagTile.js
index 2b9d9e5211..c59aaeefa4 100644
--- a/src/components/views/elements/TagTile.js
+++ b/src/components/views/elements/TagTile.js
@@ -156,7 +156,7 @@ export default createReactClass({
             <AccessibleButton
                 className="mx_TagTile_context_button"
                 onClick={this.openMenu}
-                ref={this.props.contextMenuButtonRef}
+                inputRef={this.props.contextMenuButtonRef}
             >
                 {"\u00B7\u00B7\u00B7"}
             </AccessibleButton> : <div ref={this.props.contextMenuButtonRef} />;