From 73a8e77d328028a348edd58c472ee605c8466193 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 29 May 2020 07:59:06 -0600 Subject: [PATCH 1/2] Add initial filtering support to new room list For https://github.com/vector-im/riot-web/issues/13635 This is an incomplete implementation and is mostly dumped in this state for review purposes. The remainder of the features/bugs are expected to be in more bite-sized chunks. This exposes the RoomListStore on the window for easy access to things like the new filter functions (used in debugging). This also adds initial handling of "new rooms" to the client, though the support is poor. Known bugs: * [ ] Regenerates the entire room list when a new room is seen. * [ ] Doesn't handle 2+ filters at the same time very well (see gif. will need a priority/ordering of some sort). * [ ] Doesn't handle room order changes within a tag yet, despite the docs implying it does. --- src/@types/global.d.ts | 2 + src/components/views/rooms/RoomList2.tsx | 18 +++ src/stores/room-list/README.md | 15 ++ src/stores/room-list/RoomListStore2.ts | 87 +++++++++-- src/stores/room-list/TagWatcher.ts | 80 +++++++++++ .../algorithms/list-ordering/Algorithm.ts | 136 ++++++++++++++++-- .../list-ordering/ImportanceAlgorithm.ts | 16 ++- .../list-ordering/NaturalAlgorithm.ts | 2 +- .../filters/CommunityFilterCondition.ts | 58 ++++++++ .../room-list/filters/IFilterCondition.ts | 42 ++++++ .../room-list/filters/NameFilterCondition.ts | 46 ++++++ src/stores/room-list/models.ts | 1 + src/utils/IDisposable.ts | 19 +++ src/utils/arrays.ts | 61 ++++++++ 14 files changed, 556 insertions(+), 27 deletions(-) create mode 100644 src/stores/room-list/TagWatcher.ts create mode 100644 src/stores/room-list/filters/CommunityFilterCondition.ts create mode 100644 src/stores/room-list/filters/IFilterCondition.ts create mode 100644 src/stores/room-list/filters/NameFilterCondition.ts create mode 100644 src/utils/IDisposable.ts create mode 100644 src/utils/arrays.ts diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index b244993955..ffd3277892 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -19,6 +19,7 @@ import ContentMessages from "../ContentMessages"; import { IMatrixClientPeg } from "../MatrixClientPeg"; import ToastStore from "../stores/ToastStore"; import DeviceListener from "../DeviceListener"; +import { RoomListStore2 } from "../stores/room-list/RoomListStore2"; declare global { interface Window { @@ -31,6 +32,7 @@ declare global { mx_ContentMessages: ContentMessages; mx_ToastStore: ToastStore; mx_DeviceListener: DeviceListener; + mx_RoomListStore2: RoomListStore2; } // workaround for https://github.com/microsoft/TypeScript/issues/30933 diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx index d0c147c953..e732e70edf 100644 --- a/src/components/views/rooms/RoomList2.tsx +++ b/src/components/views/rooms/RoomList2.tsx @@ -28,6 +28,8 @@ import { Dispatcher } from "flux"; import dis from "../../../dispatcher/dispatcher"; import RoomSublist2 from "./RoomSublist2"; import { ActionPayload } from "../../../dispatcher/payloads"; +import { IFilterCondition } from "../../../stores/room-list/filters/IFilterCondition"; +import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition"; /******************************************************************* * CAUTION * @@ -130,6 +132,7 @@ export default class RoomList2 extends React.Component { private sublistCollapseStates: { [tagId: string]: boolean } = {}; private unfilteredLayout: Layout; private filteredLayout: Layout; + private searchFilter: NameFilterCondition = new NameFilterCondition(); constructor(props: IProps) { super(props); @@ -139,6 +142,21 @@ export default class RoomList2 extends React.Component { this.prepareLayouts(); } + public componentDidUpdate(prevProps: Readonly): void { + if (prevProps.searchFilter !== this.props.searchFilter) { + const hadSearch = !!this.searchFilter.search.trim(); + const haveSearch = !!this.props.searchFilter.trim(); + this.searchFilter.search = this.props.searchFilter; + if (!hadSearch && haveSearch) { + // started a new filter - add the condition + RoomListStore.instance.addFilter(this.searchFilter); + } else if (hadSearch && !haveSearch) { + // cleared a filter - remove the condition + RoomListStore.instance.removeFilter(this.searchFilter); + } // else the filter hasn't changed enough for us to care here + } + } + public componentDidMount(): void { RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store) => { console.log("new lists", store.orderedLists); diff --git a/src/stores/room-list/README.md b/src/stores/room-list/README.md index 82a6e841db..f4a56130ca 100644 --- a/src/stores/room-list/README.md +++ b/src/stores/room-list/README.md @@ -111,6 +111,21 @@ an object containing the tags it needs to worry about and the rooms within. The decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with all kinds of filtering. +## Filtering + +Filters are provided to the store as condition classes, which are then passed along to the algorithm +implementations. The implementations then get to decide how to actually filter the rooms, however in +practice the base `Algorithm` class deals with the filtering in a more optimized/generic way. + +The results of filters get cached to avoid needlessly iterating over potentially thousands of rooms, +as the old room list store does. When a filter condition changes, it emits an update which (in this +case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a +minor subset where possible to avoid over-iterating rooms. + +All filter conditions are considered "stable" by the consumers, meaning that the consumer does not +expect a change in the condition unless the condition says it has changed. This is intentional to +maintain the caching behaviour described above. + ## Class breakdowns The `RoomListStore` is the major coordinator of various `Algorithm` implementations, which take care diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts index 108922a598..af9970d3cc 100644 --- a/src/stores/room-list/RoomListStore2.ts +++ b/src/stores/room-list/RoomListStore2.ts @@ -18,7 +18,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 { Algorithm } from "./algorithms/list-ordering/Algorithm"; +import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/list-ordering/Algorithm"; import TagOrderStore from "../TagOrderStore"; import { AsyncStore } from "../AsyncStore"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -27,6 +27,8 @@ import { getListAlgorithmInstance } from "./algorithms/list-ordering"; import { ActionPayload } from "../../dispatcher/payloads"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; +import { IFilterCondition } from "./filters/IFilterCondition"; +import { TagWatcher } from "./TagWatcher"; interface IState { tagsEnabled?: boolean; @@ -41,11 +43,13 @@ interface IState { */ export const LISTS_UPDATE_EVENT = "lists_update"; -class _RoomListStore extends AsyncStore { - private matrixClient: MatrixClient; +export class RoomListStore2 extends AsyncStore { + private _matrixClient: MatrixClient; private initialListsGenerated = false; private enabled = false; private algorithm: Algorithm; + private filterConditions: IFilterCondition[] = []; + private tagWatcher = new TagWatcher(this); private readonly watchedSettings = [ 'RoomList.orderAlphabetically', @@ -65,6 +69,10 @@ class _RoomListStore extends AsyncStore { return this.algorithm.getOrderedRooms(); } + public get matrixClient(): MatrixClient { + return this._matrixClient; + } + // TODO: Remove enabled flag when the old RoomListStore goes away private checkEnabled() { this.enabled = SettingsStore.isFeatureEnabled("feature_new_room_list"); @@ -96,7 +104,7 @@ class _RoomListStore extends AsyncStore { this.checkEnabled(); if (!this.enabled) return; - this.matrixClient = payload.matrixClient; + this._matrixClient = payload.matrixClient; // Update any settings here, as some may have happened before we were logically ready. console.log("Regenerating room lists: Startup"); @@ -111,7 +119,7 @@ class _RoomListStore extends AsyncStore { // Reset state without causing updates as the client will have been destroyed // and downstream code will throw NPE errors. this.reset(null, true); - this.matrixClient = null; + this._matrixClient = null; this.initialListsGenerated = false; // we'll want to regenerate them } @@ -152,8 +160,21 @@ class _RoomListStore extends AsyncStore { const roomId = eventPayload.event.getRoomId(); const room = this.matrixClient.getRoom(roomId); - console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()} in ${roomId}`); - await this.handleRoomUpdate(room, RoomUpdateCause.Timeline); + const tryUpdate = async (updatedRoom: Room) => { + console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()} in ${updatedRoom.roomId}`); + await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.Timeline); + }; + if (!room) { + console.warn(`Live timeline event ${eventPayload.event.getId()} received without associated room`); + console.warn(`Queuing failed room update for retry as a result.`); + setTimeout(async () => { + const updatedRoom = this.matrixClient.getRoom(roomId); + await tryUpdate(updatedRoom); + }, 100); // 100ms should be enough for the room to show up + return; + } else { + await tryUpdate(room); + } } else if (payload.action === 'MatrixActions.Event.decrypted') { const eventPayload = (payload); // TODO: Type out the dispatcher types const roomId = eventPayload.event.getRoomId(); @@ -171,11 +192,20 @@ class _RoomListStore extends AsyncStore { // TODO: Update DMs console.log(payload); } else if (payload.action === 'MatrixActions.Room.myMembership') { + // TODO: Improve new room check + const membershipPayload = (payload); // TODO: Type out the dispatcher types + if (!membershipPayload.oldMembership && membershipPayload.membership === "join") { + console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`); + await this.algorithm.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom); + } + // TODO: Update room from membership change console.log(payload); } else if (payload.action === 'MatrixActions.Room') { - // TODO: Update room from creation/join - console.log(payload); + // TODO: Improve new room check + // const roomPayload = (payload); // TODO: Type out the dispatcher types + // console.log(`[RoomListDebug] Handling new room ${roomPayload.room.roomId}`); + // await this.algorithm.handleRoomUpdate(roomPayload.room, RoomUpdateCause.NewRoom); } else if (payload.action === 'view_room') { // TODO: Update sticky room console.log(payload); @@ -211,11 +241,22 @@ class _RoomListStore extends AsyncStore { } private setAlgorithmClass() { + if (this.algorithm) { + this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); + } this.algorithm = getListAlgorithmInstance(this.state.preferredAlgorithm); + this.algorithm.setFilterConditions(this.filterConditions); + this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); } + private onAlgorithmListUpdated = () => { + console.log("Underlying algorithm has triggered a list update - refiring"); + this.emit(LISTS_UPDATE_EVENT, this); + }; + private async regenerateAllLists() { console.warn("Regenerating all room lists"); + const tags: ITagSortingMap = {}; for (const tagId of OrderedDefaultTagIDs) { tags[tagId] = this.getSortAlgorithmFor(tagId); @@ -234,16 +275,38 @@ class _RoomListStore extends AsyncStore { this.emit(LISTS_UPDATE_EVENT, this); } + + public addFilter(filter: IFilterCondition): void { + console.log("Adding filter condition:", filter); + this.filterConditions.push(filter); + if (this.algorithm) { + this.algorithm.addFilterCondition(filter); + } + } + + public removeFilter(filter: IFilterCondition): void { + console.log("Removing filter condition:", filter); + const idx = this.filterConditions.indexOf(filter); + if (idx >= 0) { + this.filterConditions.splice(idx, 1); + + if (this.algorithm) { + this.algorithm.removeFilterCondition(filter); + } + } + } } export default class RoomListStore { - private static internalInstance: _RoomListStore; + private static internalInstance: RoomListStore2; - public static get instance(): _RoomListStore { + public static get instance(): RoomListStore2 { if (!RoomListStore.internalInstance) { - RoomListStore.internalInstance = new _RoomListStore(); + RoomListStore.internalInstance = new RoomListStore2(); } return RoomListStore.internalInstance; } } + +window.mx_RoomListStore2 = RoomListStore.instance; diff --git a/src/stores/room-list/TagWatcher.ts b/src/stores/room-list/TagWatcher.ts new file mode 100644 index 0000000000..6c5fdf80a4 --- /dev/null +++ b/src/stores/room-list/TagWatcher.ts @@ -0,0 +1,80 @@ +/* +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 { RoomListStore2 } from "./RoomListStore2"; +import TagOrderStore from "../TagOrderStore"; +import { CommunityFilterCondition } from "./filters/CommunityFilterCondition"; +import { arrayDiff, arrayHasDiff, iteratorToArray } from "../../utils/arrays"; + +/** + * Watches for changes in tags/groups to manage filters on the provided RoomListStore + */ +export class TagWatcher { + // TODO: Support custom tags, somehow (deferred to later work - need support elsewhere) + private filters = new Map(); + + constructor(private store: RoomListStore2) { + TagOrderStore.addListener(this.onTagsUpdated); + } + + private onTagsUpdated = () => { + const lastTags = iteratorToArray(this.filters.keys()); + const newTags = TagOrderStore.getSelectedTags(); + + if (arrayHasDiff(lastTags, newTags)) { + // Selected tags changed, do some filtering + + if (!this.store.matrixClient) { + console.warn("Tag update without an associated matrix client - ignoring"); + return; + } + + const newFilters = new Map(); + + // TODO: Support custom tags properly + const filterableTags = newTags.filter(t => t.startsWith("+")); + + for (const tag of filterableTags) { + const group = this.store.matrixClient.getGroup(tag); + if (!group) { + console.warn(`Group selected with no group object available: ${tag}`); + continue; + } + + newFilters.set(tag, new CommunityFilterCondition(group)); + } + + // 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) + const filter = newFilters.get(tag); + if (!filter) continue; + + this.store.addFilter(filter); + } + for (const tag of diff.removed) { + // TODO: Remove this check when custom tags are supported (as we shouldn't be losing filters) + const filter = this.filters.get(tag); + if (!filter) continue; + + this.store.removeFilter(filter); + } + + this.filters = newFilters; + } + }; +} diff --git a/src/stores/room-list/algorithms/list-ordering/Algorithm.ts b/src/stores/room-list/algorithms/list-ordering/Algorithm.ts index e154847847..5cabc1e08c 100644 --- a/src/stores/room-list/algorithms/list-ordering/Algorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/Algorithm.ts @@ -20,24 +20,139 @@ import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { EffectiveMembership, splitRoomsByMembership } from "../../membership"; import { ITagMap, ITagSortingMap } from "../models"; import DMRoomMap from "../../../../utils/DMRoomMap"; +import { FILTER_CHANGED, IFilterCondition } from "../../filters/IFilterCondition"; +import { EventEmitter } from "events"; +import { iteratorToArray } from "../../../../utils/arrays"; // TODO: Add locking support to avoid concurrent writes? -// TODO: EventEmitter support? Might not be needed. + +/** + * Fired when the Algorithm has determined a list has been updated. + */ +export const LIST_UPDATED_EVENT = "list_updated_event"; /** * Represents a list ordering algorithm. This class will take care of tag * management (which rooms go in which tags) and ask the implementation to * deal with ordering mechanics. */ -export abstract class Algorithm { - protected cached: ITagMap = {}; +export abstract class Algorithm extends EventEmitter { + private _cachedRooms: ITagMap = {}; + private filteredRooms: ITagMap = {}; + protected sortAlgorithms: ITagSortingMap; protected rooms: Room[] = []; protected roomIdsToTags: { [roomId: string]: TagID[]; } = {}; + protected allowedByFilter: Map = new Map(); + protected allowedRoomsByFilters: Set = new Set(); protected constructor() { + super(); + } + + protected get hasFilters(): boolean { + return this.allowedByFilter.size > 0; + } + + protected set cachedRooms(val: ITagMap) { + this._cachedRooms = val; + this.recalculateFilteredRooms(); + } + + protected get cachedRooms(): ITagMap { + return this._cachedRooms; + } + + /** + * Sets the filter conditions the Algorithm should use. + * @param filterConditions The filter conditions to use. + */ + public setFilterConditions(filterConditions: IFilterCondition[]): void { + for (const filter of filterConditions) { + this.addFilterCondition(filter); + } + } + + public addFilterCondition(filterCondition: IFilterCondition): void { + // Populate the cache of the new filter + this.allowedByFilter.set(filterCondition, this.rooms.filter(r => filterCondition.isVisible(r))); + this.recalculateFilteredRooms(); + filterCondition.on(FILTER_CHANGED, this.recalculateFilteredRooms.bind(this)); + } + + public removeFilterCondition(filterCondition: IFilterCondition): void { + filterCondition.off(FILTER_CHANGED, this.recalculateFilteredRooms.bind(this)); + if (this.allowedByFilter.has(filterCondition)) { + this.allowedByFilter.delete(filterCondition); + + // If we removed the last filter, tell consumers that we've "updated" our filtered + // view. This will trick them into getting the complete room list. + if (!this.hasFilters) { + this.emit(LIST_UPDATED_EVENT); + } + } + } + + protected recalculateFilteredRooms() { + if (!this.hasFilters) { + return; + } + + console.warn("Recalculating filtered room list"); + const allowedByFilters = new Set(); + const filters = iteratorToArray(this.allowedByFilter.keys()); + const newMap: ITagMap = {}; + for (const tagId of Object.keys(this.cachedRooms)) { + // Cheaply clone the rooms so we can more easily do operations on the list. + // We optimize our lookups by trying to reduce sample size as much as possible + // to the rooms we know will be deduped by the Set. + const rooms = this.cachedRooms[tagId]; + const remainingRooms = rooms.map(r => r).filter(r => !allowedByFilters.has(r)); + const allowedRoomsInThisTag = []; + for (const filter of filters) { + const filteredRooms = remainingRooms.filter(r => filter.isVisible(r)); + for (const room of filteredRooms) { + const idx = remainingRooms.indexOf(room); + if (idx >= 0) remainingRooms.splice(idx, 1); + allowedByFilters.add(room); + allowedRoomsInThisTag.push(room); + } + } + newMap[tagId] = allowedRoomsInThisTag; + console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`); + } + + this.allowedRoomsByFilters = allowedByFilters; + this.filteredRooms = newMap; + this.emit(LIST_UPDATED_EVENT); + } + + protected addPossiblyFilteredRoomsToTag(tagId: TagID, added: Room[]): void { + const filters = this.allowedByFilter.keys(); + for (const room of added) { + for (const filter of filters) { + if (filter.isVisible(room)) { + this.allowedRoomsByFilters.add(room); + break; + } + } + } + + // Now that we've updated the allowed rooms, recalculate the tag + this.recalculateFilteredRoomsForTag(tagId); + } + + protected recalculateFilteredRoomsForTag(tagId: TagID): void { + console.log(`Recalculating filtered rooms for ${tagId}`); + delete this.filteredRooms[tagId]; + const rooms = this.cachedRooms[tagId]; + const filteredRooms = rooms.filter(r => this.allowedRoomsByFilters.has(r)); + if (filteredRooms.length > 0) { + this.filteredRooms[tagId] = filteredRooms; + } + console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`); } /** @@ -54,12 +169,15 @@ export abstract class Algorithm { } /** - * Gets an ordered set of rooms for the all known tags. + * Gets an ordered set of rooms for the all known tags, filtered. * @returns {ITagMap} The cached list of rooms, ordered, * for each tag. May be empty, but never null/undefined. */ public getOrderedRooms(): ITagMap { - return this.cached; + if (!this.hasFilters) { + return this.cachedRooms; + } + return this.filteredRooms; } /** @@ -83,7 +201,7 @@ export abstract class Algorithm { // If we can avoid doing work, do so. if (!rooms.length) { await this.generateFreshTags(newTags); // just in case it wants to do something - this.cached = newTags; + this.cachedRooms = newTags; return; } @@ -130,7 +248,7 @@ export abstract class Algorithm { await this.generateFreshTags(newTags); - this.cached = newTags; + this.cachedRooms = newTags; this.updateTagsFromCache(); } @@ -140,9 +258,9 @@ export abstract class Algorithm { protected updateTagsFromCache() { const newMap = {}; - const tags = Object.keys(this.cached); + const tags = Object.keys(this.cachedRooms); for (const tagId of tags) { - const rooms = this.cached[tagId]; + const rooms = this.cachedRooms[tagId]; for (const room of rooms) { if (!newMap[room.roomId]) newMap[room.roomId] = []; newMap[room.roomId].push(tagId); diff --git a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts index c72cdc2e1c..6c4498dad3 100644 --- a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts @@ -17,7 +17,7 @@ limitations under the License. import { Algorithm } from "./Algorithm"; import { Room } from "matrix-js-sdk/src/models/room"; -import { RoomUpdateCause, TagID } from "../../models"; +import { DefaultTagID, RoomUpdateCause, TagID } from "../../models"; import { ITagMap, SortAlgorithm } from "../models"; import { sortRoomsWithAlgorithm } from "../tag-sorting"; import * as Unread from '../../../../Unread'; @@ -92,9 +92,9 @@ export class ImportanceAlgorithm extends Algorithm { // can be found from `this.indices[tag][category]` and the sticky room information // from `this.stickyRoom`. // - // The room list store is always provided with the `this.cached` results, which are + // The room list store is always provided with the `this.cachedRooms` results, which are // updated as needed and not recalculated often. For example, when a room needs to - // move within a tag, the array in `this.cached` will be spliced instead of iterated. + // move within a tag, the array in `this.cachedRooms` will be spliced instead of iterated. // The `indices` help track the positions of each category to make splicing easier. private indices: { @@ -189,7 +189,13 @@ export class ImportanceAlgorithm extends Algorithm { } public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { - const tags = this.roomIdsToTags[room.roomId]; + if (cause === RoomUpdateCause.NewRoom) { + // TODO: Be smarter and insert rather than regen the planet. + await this.setKnownRooms([room, ...this.rooms]); + return; + } + + let tags = this.roomIdsToTags[room.roomId]; if (!tags) { console.warn(`No tags known for "${room.name}" (${room.roomId})`); return false; @@ -201,7 +207,7 @@ export class ImportanceAlgorithm extends Algorithm { continue; // Nothing to do here. } - const taggedRooms = this.cached[tag]; + const taggedRooms = this.cachedRooms[tag]; const indices = this.indices[tag]; let roomIdx = taggedRooms.indexOf(room); if (roomIdx === -1) { diff --git a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts index 44a501e592..e129e98e6f 100644 --- a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts @@ -49,7 +49,7 @@ export class NaturalAlgorithm extends Algorithm { for (const tag of tags) { // TODO: Optimize this loop to avoid useless operations // For example, we can skip updates to alphabetic (sometimes) and manually ordered tags - this.cached[tag] = await sortRoomsWithAlgorithm(this.cached[tag], tag, this.sortAlgorithms[tag]); + this.cachedRooms[tag] = await sortRoomsWithAlgorithm(this.cachedRooms[tag], tag, this.sortAlgorithms[tag]); } return true; // assume we changed something } diff --git a/src/stores/room-list/filters/CommunityFilterCondition.ts b/src/stores/room-list/filters/CommunityFilterCondition.ts new file mode 100644 index 0000000000..13d0084ae1 --- /dev/null +++ b/src/stores/room-list/filters/CommunityFilterCondition.ts @@ -0,0 +1,58 @@ +/* +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 { Room } from "matrix-js-sdk/src/models/room"; +import { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition"; +import { Group } from "matrix-js-sdk/src/models/group"; +import { EventEmitter } from "events"; +import GroupStore from "../../GroupStore"; +import { arrayHasDiff } from "../../../utils/arrays"; +import { IDisposable } from "../../../utils/IDisposable"; + +/** + * A filter condition for the room list which reveals rooms which + * are a member of a given community. + */ +export class CommunityFilterCondition extends EventEmitter implements IFilterCondition, IDisposable { + private roomIds: string[] = []; + + constructor(private community: Group) { + super(); + GroupStore.on("update", this.onStoreUpdate); + + // noinspection JSIgnoredPromiseFromCall + this.onStoreUpdate(); // trigger a false update to seed the store + } + + public isVisible(room: Room): boolean { + return this.roomIds.includes(room.roomId); + } + + private onStoreUpdate = async (): Promise => { + // We don't actually know if the room list changed for the community, so just + // check it again. + const beforeRoomIds = this.roomIds; + this.roomIds = (await GroupStore.getGroupRooms(this.community.groupId)).map(r => r.roomId); + if (arrayHasDiff(beforeRoomIds, this.roomIds)) { + console.log("Updating filter for group: ", this.community.groupId); + this.emit(FILTER_CHANGED); + } + }; + + public dispose(): void { + GroupStore.off("update", this.onStoreUpdate); + } +} diff --git a/src/stores/room-list/filters/IFilterCondition.ts b/src/stores/room-list/filters/IFilterCondition.ts new file mode 100644 index 0000000000..f7f0f61194 --- /dev/null +++ b/src/stores/room-list/filters/IFilterCondition.ts @@ -0,0 +1,42 @@ +/* +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 { Room } from "matrix-js-sdk/src/models/room"; +import { EventEmitter } from "events"; + +export const FILTER_CHANGED = "filter_changed"; + +/** + * A filter condition for the room list, determining if a room + * should be shown or not. + * + * All filter conditions are expected to be stable executions, + * meaning that given the same input the same answer will be + * returned (thus allowing caching). As such, filter conditions + * can, but shouldn't, do heavier logic and not worry about being + * called constantly by the room list. When the condition changes + * such that different inputs lead to different answers (such + * as a change in the user's input), this emits FILTER_CHANGED. + */ +export interface IFilterCondition extends EventEmitter { + /** + * Determines if a given room should be visible under this + * condition. + * @param room The room to check. + * @returns True if the room should be visible. + */ + isVisible(room: Room): boolean; +} diff --git a/src/stores/room-list/filters/NameFilterCondition.ts b/src/stores/room-list/filters/NameFilterCondition.ts new file mode 100644 index 0000000000..6a76569df3 --- /dev/null +++ b/src/stores/room-list/filters/NameFilterCondition.ts @@ -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 { Room } from "matrix-js-sdk/src/models/room"; +import { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition"; +import { EventEmitter } from "events"; + +/** + * A filter condition for the room list which reveals rooms of a particular + * name, or associated name (like a room alias). + */ +export class NameFilterCondition extends EventEmitter implements IFilterCondition { + private _search = ""; + + constructor() { + super(); + } + + public get search(): string { + return this._search; + } + + public set search(val: string) { + this._search = val; + console.log("Updating filter for room name search:", this._search); + this.emit(FILTER_CHANGED); + } + + public isVisible(room: Room): boolean { + // TODO: Improve this filter to include aliases and such + return room.name.toLowerCase().indexOf(this.search.toLowerCase()) >= 0; + } +} diff --git a/src/stores/room-list/models.ts b/src/stores/room-list/models.ts index a0c2621077..9a27569db4 100644 --- a/src/stores/room-list/models.ts +++ b/src/stores/room-list/models.ts @@ -39,4 +39,5 @@ export type TagID = string | DefaultTagID; export enum RoomUpdateCause { Timeline = "TIMELINE", RoomRead = "ROOM_READ", // TODO: Use this. + NewRoom = "NEW_ROOM", } diff --git a/src/utils/IDisposable.ts b/src/utils/IDisposable.ts new file mode 100644 index 0000000000..bf03e3bc85 --- /dev/null +++ b/src/utils/IDisposable.ts @@ -0,0 +1,19 @@ +/* +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 interface IDisposable { + dispose(): void; +} diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts new file mode 100644 index 0000000000..7ea11397e6 --- /dev/null +++ b/src/utils/arrays.ts @@ -0,0 +1,61 @@ +/* +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. +*/ + +/** + * Determines if two arrays are different through a shallow comparison. + * @param a The first array. Must be defined. + * @param b The second array. Must be defined. + * @returns True if they are the same, false otherwise. + */ +export function arrayHasDiff(a: any[], b: any[]): boolean { + if (a.length === b.length) { + // When the lengths are equal, check to see if either array is missing + // an element from the other. + if (b.some(i => !a.includes(i))) return true; + if (a.some(i => !b.includes(i))) return true; + } else { + return true; // different lengths means they are naturally diverged + } +} + +/** + * Performs a diff on two arrays. The result is what is different with the + * first array (`added` in the returned object means objects in B that aren't + * in A). Shallow comparisons are used to perform the diff. + * @param a The first array. Must be defined. + * @param b The second array. Must be defined. + * @returns The diff between the arrays. + */ +export function arrayDiff(a: T[], b: T[]): { added: T[], removed: T[] } { + return { + added: b.filter(i => !a.includes(i)), + removed: a.filter(i => !b.includes(i)), + }; +} + +/** + * Converts an iterator to an array. Not recommended to be called with infinite + * generator types. + * @param i The iterator to convert. + * @returns The array from the iterator. + */ +export function iteratorToArray(i: Iterable): T[] { + const a: T[] = []; + for (const e of i) { + a.push(e); + } + return a; +} From bc2fbefb5c93c84ed2671d4298162ec3ac3948c0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 3 Jun 2020 11:29:06 -0600 Subject: [PATCH 2/2] Remove iteratorToArray --- src/stores/room-list/TagWatcher.ts | 4 ++-- .../algorithms/list-ordering/Algorithm.ts | 3 +-- src/utils/arrays.ts | 14 -------------- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/stores/room-list/TagWatcher.ts b/src/stores/room-list/TagWatcher.ts index 6c5fdf80a4..1fb5223e00 100644 --- a/src/stores/room-list/TagWatcher.ts +++ b/src/stores/room-list/TagWatcher.ts @@ -17,7 +17,7 @@ limitations under the License. import { RoomListStore2 } from "./RoomListStore2"; import TagOrderStore from "../TagOrderStore"; import { CommunityFilterCondition } from "./filters/CommunityFilterCondition"; -import { arrayDiff, arrayHasDiff, iteratorToArray } from "../../utils/arrays"; +import { arrayDiff, arrayHasDiff } from "../../utils/arrays"; /** * Watches for changes in tags/groups to manage filters on the provided RoomListStore @@ -31,7 +31,7 @@ export class TagWatcher { } private onTagsUpdated = () => { - const lastTags = iteratorToArray(this.filters.keys()); + const lastTags = Array.from(this.filters.keys()); const newTags = TagOrderStore.getSelectedTags(); if (arrayHasDiff(lastTags, newTags)) { diff --git a/src/stores/room-list/algorithms/list-ordering/Algorithm.ts b/src/stores/room-list/algorithms/list-ordering/Algorithm.ts index 5cabc1e08c..3921bb6221 100644 --- a/src/stores/room-list/algorithms/list-ordering/Algorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/Algorithm.ts @@ -22,7 +22,6 @@ import { ITagMap, ITagSortingMap } from "../models"; import DMRoomMap from "../../../../utils/DMRoomMap"; import { FILTER_CHANGED, IFilterCondition } from "../../filters/IFilterCondition"; import { EventEmitter } from "events"; -import { iteratorToArray } from "../../../../utils/arrays"; // TODO: Add locking support to avoid concurrent writes? @@ -102,7 +101,7 @@ export abstract class Algorithm extends EventEmitter { console.warn("Recalculating filtered room list"); const allowedByFilters = new Set(); - const filters = iteratorToArray(this.allowedByFilter.keys()); + const filters = Array.from(this.allowedByFilter.keys()); const newMap: ITagMap = {}; for (const tagId of Object.keys(this.cachedRooms)) { // Cheaply clone the rooms so we can more easily do operations on the list. diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 7ea11397e6..3a84990b56 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -45,17 +45,3 @@ export function arrayDiff(a: T[], b: T[]): { added: T[], removed: T[] } { removed: a.filter(i => !b.includes(i)), }; } - -/** - * Converts an iterator to an array. Not recommended to be called with infinite - * generator types. - * @param i The iterator to convert. - * @returns The array from the iterator. - */ -export function iteratorToArray(i: Iterable): T[] { - const a: T[] = []; - for (const e of i) { - a.push(e); - } - return a; -}