Merge pull request #5054 from matrix-org/travis/perf6

Minor improvements to filtering performance
This commit is contained in:
Travis Ralston 2020-07-28 06:37:27 -06:00 committed by GitHub
commit 3561de3f8a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 150 additions and 81 deletions

View file

@ -44,7 +44,6 @@ interface IProps {
} }
interface IState { interface IState {
searchFilter: string;
showBreadcrumbs: boolean; showBreadcrumbs: boolean;
showTagPanel: boolean; showTagPanel: boolean;
} }
@ -69,7 +68,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
super(props); super(props);
this.state = { this.state = {
searchFilter: "",
showBreadcrumbs: BreadcrumbsStore.instance.visible, showBreadcrumbs: BreadcrumbsStore.instance.visible,
showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
}; };
@ -97,10 +95,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize); this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
} }
private onSearch = (term: string): void => {
this.setState({searchFilter: term});
};
private onExplore = () => { private onExplore = () => {
dis.fire(Action.ViewRoomDirectory); dis.fire(Action.ViewRoomDirectory);
}; };
@ -366,7 +360,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
> >
<RoomSearch <RoomSearch
onQueryUpdate={this.onSearch}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onVerticalArrow={this.onKeyDown} onVerticalArrow={this.onKeyDown}
onEnter={this.onEnter} onEnter={this.onEnter}
@ -392,7 +385,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
resizeNotifier={null} resizeNotifier={null}
collapsed={false} collapsed={false}
searchFilter={this.state.searchFilter}
onFocus={this.onFocus} onFocus={this.onFocus}
onBlur={this.onBlur} onBlur={this.onBlur}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}

View file

@ -24,9 +24,10 @@ import { throttle } from 'lodash';
import { Key } from "../../Keyboard"; import { Key } from "../../Keyboard";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton from "../views/elements/AccessibleButton";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import RoomListStore from "../../stores/room-list/RoomListStore";
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
interface IProps { interface IProps {
onQueryUpdate: (newQuery: string) => void;
isMinimized: boolean; isMinimized: boolean;
onVerticalArrow(ev: React.KeyboardEvent): void; onVerticalArrow(ev: React.KeyboardEvent): void;
onEnter(ev: React.KeyboardEvent): boolean; onEnter(ev: React.KeyboardEvent): boolean;
@ -40,6 +41,7 @@ interface IState {
export default class RoomSearch extends React.PureComponent<IProps, IState> { export default class RoomSearch extends React.PureComponent<IProps, IState> {
private dispatcherRef: string; private dispatcherRef: string;
private inputRef: React.RefObject<HTMLInputElement> = createRef(); private inputRef: React.RefObject<HTMLInputElement> = createRef();
private searchFilter: NameFilterCondition = new NameFilterCondition();
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -52,6 +54,21 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
this.dispatcherRef = defaultDispatcher.register(this.onAction); this.dispatcherRef = defaultDispatcher.register(this.onAction);
} }
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
if (prevState.query !== this.state.query) {
const hadSearch = !!this.searchFilter.search.trim();
const haveSearch = !!this.state.query.trim();
this.searchFilter.search = this.state.query;
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 componentWillUnmount() { public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef); defaultDispatcher.unregister(this.dispatcherRef);
} }
@ -78,19 +95,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
private onChange = () => { private onChange = () => {
if (!this.inputRef.current) return; if (!this.inputRef.current) return;
this.setState({query: this.inputRef.current.value}); this.setState({query: this.inputRef.current.value});
this.onSearchUpdated();
}; };
// it wants this at the top of the file, but we know better
// tslint:disable-next-line
private onSearchUpdated = throttle(
() => {
// We can't use the state variable because it can lag behind the input.
// The lag is most obvious when deleting/clearing text with the keyboard.
this.props.onQueryUpdate(this.inputRef.current.value);
}, 200, {trailing: true, leading: true},
);
private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => { private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => {
this.setState({focused: true}); this.setState({focused: true});
ev.target.select(); ev.target.select();

View file

@ -31,7 +31,6 @@ import dis from "../../../dispatcher/dispatcher";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomSublist from "./RoomSublist"; import RoomSublist from "./RoomSublist";
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import GroupAvatar from "../avatars/GroupAvatar"; import GroupAvatar from "../avatars/GroupAvatar";
import TemporaryTile from "./TemporaryTile"; import TemporaryTile from "./TemporaryTile";
@ -52,7 +51,6 @@ interface IProps {
onResize: () => void; onResize: () => void;
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
collapsed: boolean; collapsed: boolean;
searchFilter: string;
isMinimized: boolean; isMinimized: boolean;
} }
@ -150,8 +148,7 @@ function customTagAesthetics(tagId: TagID): ITagAesthetics {
}; };
} }
export default class RoomList extends React.Component<IProps, IState> { export default class RoomList extends React.PureComponent<IProps, IState> {
private searchFilter: NameFilterCondition = new NameFilterCondition();
private dispatcherRef; private dispatcherRef;
private customTagStoreRef; private customTagStoreRef;
@ -165,21 +162,6 @@ export default class RoomList extends React.Component<IProps, IState> {
this.dispatcherRef = defaultDispatcher.register(this.onAction); this.dispatcherRef = defaultDispatcher.register(this.onAction);
} }
public componentDidUpdate(prevProps: Readonly<IProps>): 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 { public componentDidMount(): void {
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
this.customTagStoreRef = CustomRoomTagStore.addListener(this.updateLists); this.customTagStoreRef = CustomRoomTagStore.addListener(this.updateLists);
@ -266,12 +248,11 @@ export default class RoomList extends React.Component<IProps, IState> {
} }
}; };
private renderCommunityInvites(): React.ReactElement[] { private renderCommunityInvites(): TemporaryTile[] {
// TODO: Put community invites in a more sensible place (not in the room list) // TODO: Put community invites in a more sensible place (not in the room list)
// See https://github.com/vector-im/riot-web/issues/14456 // See https://github.com/vector-im/riot-web/issues/14456
return MatrixClientPeg.get().getGroups().filter(g => { return MatrixClientPeg.get().getGroups().filter(g => {
if (g.myMembership !== 'invite') return false; return g.myMembership === 'invite';
return !this.searchFilter || this.searchFilter.matches(g.name || "");
}).map(g => { }).map(g => {
const avatar = ( const avatar = (
<GroupAvatar <GroupAvatar
@ -340,7 +321,6 @@ export default class RoomList extends React.Component<IProps, IState> {
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onResize={this.props.onResize} onResize={this.props.onResize}
extraBadTilesThatShouldntExist={extraTiles} extraBadTilesThatShouldntExist={extraTiles}
isFiltered={!!this.searchFilter.search}
/> />
); );
} }

View file

@ -38,7 +38,6 @@ import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
@ -49,6 +48,8 @@ import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNo
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore"; import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
import { arrayHasOrderChange } from "../../../utils/arrays"; import { arrayHasOrderChange } from "../../../utils/arrays";
import { objectExcluding, objectHasValueChange } from "../../../utils/objects"; import { objectExcluding, objectHasValueChange } from "../../../utils/objects";
import TemporaryTile from "./TemporaryTile";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
@ -68,11 +69,10 @@ interface IProps {
isMinimized: boolean; isMinimized: boolean;
tagId: TagID; tagId: TagID;
onResize: () => void; onResize: () => void;
isFiltered: boolean;
// TODO: Don't use this. It's for community invites, and community invites shouldn't be here. // TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
// You should feel bad if you use this. // You should feel bad if you use this.
extraBadTilesThatShouldntExist?: React.ReactElement[]; extraBadTilesThatShouldntExist?: TemporaryTile[];
// TODO: Account for https://github.com/vector-im/riot-web/issues/14179 // TODO: Account for https://github.com/vector-im/riot-web/issues/14179
} }
@ -86,12 +86,12 @@ interface ResizeDelta {
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">; type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
interface IState { interface IState {
notificationState: ListNotificationState;
contextMenuPosition: PartialDOMRect; contextMenuPosition: PartialDOMRect;
isResizing: boolean; isResizing: boolean;
isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
height: number; height: number;
rooms: Room[]; rooms: Room[];
filteredExtraTiles?: TemporaryTile[];
} }
export default class RoomSublist extends React.Component<IProps, IState> { export default class RoomSublist extends React.Component<IProps, IState> {
@ -100,23 +100,25 @@ export default class RoomSublist extends React.Component<IProps, IState> {
private dispatcherRef: string; private dispatcherRef: string;
private layout: ListLayout; private layout: ListLayout;
private heightAtStart: number; private heightAtStart: number;
private isBeingFiltered: boolean;
private notificationState: ListNotificationState;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId); this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
this.heightAtStart = 0; this.heightAtStart = 0;
this.isBeingFiltered = !!RoomListStore.instance.getFirstNameFilterCondition();
this.notificationState = RoomNotificationStateStore.instance.getListState(this.props.tagId);
this.state = { this.state = {
notificationState: RoomNotificationStateStore.instance.getListState(this.props.tagId),
contextMenuPosition: null, contextMenuPosition: null,
isResizing: false, isResizing: false,
isExpanded: this.props.isFiltered ? this.props.isFiltered : !this.layout.isCollapsed, isExpanded: this.isBeingFiltered ? this.isBeingFiltered : !this.layout.isCollapsed,
height: 0, // to be fixed in a moment, we need `rooms` to calculate this. height: 0, // to be fixed in a moment, we need `rooms` to calculate this.
rooms: RoomListStore.instance.orderedLists[this.props.tagId] || [], rooms: RoomListStore.instance.orderedLists[this.props.tagId] || [],
}; };
// Why Object.assign() and not this.state.height? Because TypeScript says no. // Why Object.assign() and not this.state.height? Because TypeScript says no.
this.state = Object.assign(this.state, {height: this.calculateInitialHeight()}); this.state = Object.assign(this.state, {height: this.calculateInitialHeight()});
this.state.notificationState.setRooms(this.state.rooms);
this.dispatcherRef = defaultDispatcher.register(this.onAction); this.dispatcherRef = defaultDispatcher.register(this.onAction);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated);
} }
@ -146,8 +148,18 @@ export default class RoomSublist extends React.Component<IProps, IState> {
return padding; return padding;
} }
private get extraTiles(): TemporaryTile[] | null {
if (this.state.filteredExtraTiles) {
return this.state.filteredExtraTiles;
}
if (this.props.extraBadTilesThatShouldntExist) {
return this.props.extraBadTilesThatShouldntExist;
}
return null;
}
private get numTiles(): number { private get numTiles(): number {
return RoomSublist.calcNumTiles(this.state.rooms, this.props.extraBadTilesThatShouldntExist); return RoomSublist.calcNumTiles(this.state.rooms, this.extraTiles);
} }
private static calcNumTiles(rooms: Room[], extraTiles: any[]) { private static calcNumTiles(rooms: Room[], extraTiles: any[]) {
@ -160,17 +172,10 @@ export default class RoomSublist extends React.Component<IProps, IState> {
} }
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>) { public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>) {
this.state.notificationState.setRooms(this.state.rooms); const prevExtraTiles = prevState.filteredExtraTiles || prevProps.extraBadTilesThatShouldntExist;
if (prevProps.isFiltered !== this.props.isFiltered) {
if (this.props.isFiltered) {
this.setState({isExpanded: true});
} else {
this.setState({isExpanded: !this.layout.isCollapsed});
}
}
// as the rooms can come in one by one we need to reevaluate // as the rooms can come in one by one we need to reevaluate
// the amount of available rooms to cap the amount of requested visible rooms by the layout // the amount of available rooms to cap the amount of requested visible rooms by the layout
if (RoomSublist.calcNumTiles(prevState.rooms, prevProps.extraBadTilesThatShouldntExist) !== this.numTiles) { if (RoomSublist.calcNumTiles(prevState.rooms, prevExtraTiles) !== this.numTiles) {
this.setState({height: this.calculateInitialHeight()}); this.setState({height: this.calculateInitialHeight()});
} }
} }
@ -191,14 +196,14 @@ export default class RoomSublist extends React.Component<IProps, IState> {
// If we're supposed to handle extra tiles, take the performance hit and re-render all the // If we're supposed to handle extra tiles, take the performance hit and re-render all the
// time so we don't have to consider them as part of the visible room optimization. // time so we don't have to consider them as part of the visible room optimization.
const prevExtraTiles = this.props.extraBadTilesThatShouldntExist || []; const prevExtraTiles = this.props.extraBadTilesThatShouldntExist || [];
const nextExtraTiles = nextProps.extraBadTilesThatShouldntExist || []; const nextExtraTiles = (nextState.filteredExtraTiles || nextProps.extraBadTilesThatShouldntExist) || [];
if (prevExtraTiles.length > 0 || nextExtraTiles.length > 0) { if (prevExtraTiles.length > 0 || nextExtraTiles.length > 0) {
return true; return true;
} }
// If we're about to update the height of the list, we don't really care about which rooms // If we're about to update the height of the list, we don't really care about which rooms
// are visible or not for no-op purposes, so ensure that the height calculation runs through. // are visible or not for no-op purposes, so ensure that the height calculation runs through.
if (RoomSublist.calcNumTiles(nextState.rooms, nextProps.extraBadTilesThatShouldntExist) !== this.numTiles) { if (RoomSublist.calcNumTiles(nextState.rooms, nextExtraTiles) !== this.numTiles) {
return true; return true;
} }
@ -232,16 +237,41 @@ export default class RoomSublist extends React.Component<IProps, IState> {
} }
public componentWillUnmount() { public componentWillUnmount() {
this.state.notificationState.destroy();
defaultDispatcher.unregister(this.dispatcherRef); defaultDispatcher.unregister(this.dispatcherRef);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated);
} }
private onListsUpdated = () => { private onListsUpdated = () => {
const stateUpdates: IState & any = {}; // &any is to avoid a cast on the initializer
if (this.props.extraBadTilesThatShouldntExist) {
const nameCondition = RoomListStore.instance.getFirstNameFilterCondition();
if (nameCondition) {
stateUpdates.filteredExtraTiles = this.props.extraBadTilesThatShouldntExist
.filter(t => nameCondition.matches(t.props.displayName || ""));
} else if (this.state.filteredExtraTiles) {
stateUpdates.filteredExtraTiles = null;
}
}
const currentRooms = this.state.rooms; const currentRooms = this.state.rooms;
const newRooms = RoomListStore.instance.orderedLists[this.props.tagId] || []; const newRooms = RoomListStore.instance.orderedLists[this.props.tagId] || [];
if (arrayHasOrderChange(currentRooms, newRooms)) { if (arrayHasOrderChange(currentRooms, newRooms)) {
this.setState({rooms: newRooms}); stateUpdates.rooms = newRooms;
}
const isStillBeingFiltered = !!RoomListStore.instance.getFirstNameFilterCondition();
if (isStillBeingFiltered !== this.isBeingFiltered) {
this.isBeingFiltered = isStillBeingFiltered;
if (isStillBeingFiltered) {
stateUpdates.isExpanded = true;
} else {
stateUpdates.isExpanded = !this.layout.isCollapsed;
}
}
if (Object.keys(stateUpdates).length > 0) {
this.setState(stateUpdates);
} }
}; };
@ -376,8 +406,8 @@ export default class RoomSublist extends React.Component<IProps, IState> {
} else { } else {
// find the first room with a count of the same colour as the badge count // find the first room with a count of the same colour as the badge count
room = this.state.rooms.find((r: Room) => { room = this.state.rooms.find((r: Room) => {
const notifState = this.state.notificationState.getForRoom(r); const notifState = this.notificationState.getForRoom(r);
return notifState.count > 0 && notifState.color === this.state.notificationState.color; return notifState.count > 0 && notifState.color === this.notificationState.color;
}); });
} }
@ -484,8 +514,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
} }
} }
if (this.props.extraBadTilesThatShouldntExist) { if (this.extraTiles) {
tiles.push(...this.props.extraBadTilesThatShouldntExist); // HACK: We break typing here, but this 'extra tiles' property shouldn't exist.
(tiles as any[]).push(...this.extraTiles);
} }
// We only have to do this because of the extra tiles. We do it conditionally // We only have to do this because of the extra tiles. We do it conditionally
@ -592,7 +623,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
const badge = ( const badge = (
<NotificationBadge <NotificationBadge
forceCount={true} forceCount={true}
notification={this.state.notificationState} notification={this.notificationState}
onClick={this.onBadgeClick} onClick={this.onBadgeClick}
tabIndex={tabIndex} tabIndex={tabIndex}
aria-label={ariaLabel} aria-label={ariaLabel}

View file

@ -148,7 +148,6 @@ export default class SettingsStore {
callbackFn(originalSettingName, changedInRoomId, atLevel, newValAtLevel, newValue); callbackFn(originalSettingName, changedInRoomId, atLevel, newValAtLevel, newValue);
}; };
console.log(`Starting watcher for ${settingName}@${roomId || '<null room>'} as ID ${watcherId}`);
SettingsStore._watchers[watcherId] = localizedCallback; SettingsStore._watchers[watcherId] = localizedCallback;
defaultWatchManager.watchSetting(settingName, roomId, localizedCallback); defaultWatchManager.watchSetting(settingName, roomId, localizedCallback);
@ -167,7 +166,6 @@ export default class SettingsStore {
return; return;
} }
console.log(`Ending watcher ID ${watcherReference}`);
defaultWatchManager.unwatchSetting(SettingsStore._watchers[watcherReference]); defaultWatchManager.unwatchSetting(SettingsStore._watchers[watcherReference]);
delete SettingsStore._watchers[watcherReference]; delete SettingsStore._watchers[watcherReference];
} }

View file

@ -22,6 +22,7 @@ import SettingsStore from "../settings/SettingsStore";
import RoomListStore, {LISTS_UPDATE_EVENT} from "./room-list/RoomListStore"; import RoomListStore, {LISTS_UPDATE_EVENT} from "./room-list/RoomListStore";
import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateStore"; import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateStore";
import {isCustomTag} from "./room-list/models"; import {isCustomTag} from "./room-list/models";
import {objectHasDiff} from "../utils/objects";
function commonPrefix(a, b) { function commonPrefix(a, b) {
const len = Math.min(a.length, b.length); const len = Math.min(a.length, b.length);
@ -107,7 +108,10 @@ class CustomRoomTagStore extends EventEmitter {
} }
_onListsUpdated = () => { _onListsUpdated = () => {
this._setState({tags: this._getUpdatedTags()}); const newTags = this._getUpdatedTags();
if (!this._state.tags || objectHasDiff(this._state.tags, newTags)) {
this._setState({tags: newTags});
}
}; };
_onDispatch(payload) { _onDispatch(payload) {
@ -134,7 +138,7 @@ class CustomRoomTagStore extends EventEmitter {
_getUpdatedTags() { _getUpdatedTags() {
if (!SettingsStore.isFeatureEnabled("feature_custom_tags")) { if (!SettingsStore.isFeatureEnabled("feature_custom_tags")) {
return; return {}; // none
} }
const newTagNames = Object.keys(RoomListStore.instance.orderedLists).filter(t => isCustomTag(t)).sort(); const newTagNames = Object.keys(RoomListStore.instance.orderedLists).filter(t => isCustomTag(t)).sort();

View file

@ -29,6 +29,7 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new RoomNotificationStateStore(); private static internalInstance = new RoomNotificationStateStore();
private roomMap = new Map<Room, RoomNotificationState>(); private roomMap = new Map<Room, RoomNotificationState>();
private listMap = new Map<TagID, ListNotificationState>();
private constructor() { private constructor() {
super(defaultDispatcher, {}); super(defaultDispatcher, {});
@ -52,21 +53,23 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
} }
/** /**
* Creates a new list notification state. The consumer is expected to set the rooms * Gets an instance of the list state class for the given tag.
* on the notification state, and destroy the state when it no longer needs it. * @param tagId The tag to get the notification state for.
* @param tagId The tag to create the notification state for.
* @returns The notification state for the tag. * @returns The notification state for the tag.
*/ */
public getListState(tagId: TagID): ListNotificationState { public getListState(tagId: TagID): ListNotificationState {
// Note: we don't cache these notification states as the consumer is expected to call if (this.listMap.has(tagId)) {
// .setRooms() on the returned object, which could confuse other consumers. return this.listMap.get(tagId);
}
// TODO: Update if/when invites move out of the room list. // TODO: Update if/when invites move out of the room list.
const useTileCount = tagId === DefaultTagID.Invite; const useTileCount = tagId === DefaultTagID.Invite;
const getRoomFn: FetchRoomFn = (room: Room) => { const getRoomFn: FetchRoomFn = (room: Room) => {
return this.getRoomState(room); return this.getRoomState(room);
}; };
return new ListNotificationState(useTileCount, tagId, getRoomFn); const state = new ListNotificationState(useTileCount, tagId, getRoomFn);
this.listMap.set(tagId, state);
return state;
} }
/** /**

View file

@ -32,6 +32,8 @@ import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import RoomListLayoutStore from "./RoomListLayoutStore"; import RoomListLayoutStore from "./RoomListLayoutStore";
import { MarkedExecution } from "../../utils/MarkedExecution"; import { MarkedExecution } from "../../utils/MarkedExecution";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { NameFilterCondition } from "./filters/NameFilterCondition";
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
interface IState { interface IState {
tagsEnabled?: boolean; tagsEnabled?: boolean;
@ -54,7 +56,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
private algorithm = new Algorithm(); private algorithm = new Algorithm();
private filterConditions: IFilterCondition[] = []; private filterConditions: IFilterCondition[] = [];
private tagWatcher = new TagWatcher(this); private tagWatcher = new TagWatcher(this);
private updateFn = new MarkedExecution(() => this.emit(LISTS_UPDATE_EVENT)); private updateFn = new MarkedExecution(() => {
for (const tagId of Object.keys(this.unfilteredLists)) {
RoomNotificationStateStore.instance.getListState(tagId).setRooms(this.unfilteredLists[tagId]);
}
this.emit(LISTS_UPDATE_EVENT);
});
private readonly watchedSettings = [ private readonly watchedSettings = [
'feature_custom_tags', 'feature_custom_tags',
@ -71,6 +78,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated); this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated);
} }
public get unfilteredLists(): ITagMap {
if (!this.algorithm) return {}; // No tags yet.
return this.algorithm.getUnfilteredRooms();
}
public get orderedLists(): ITagMap { public get orderedLists(): ITagMap {
if (!this.algorithm) return {}; // No tags yet. if (!this.algorithm) return {}; // No tags yet.
return this.algorithm.getOrderedRooms(); return this.algorithm.getOrderedRooms();
@ -588,6 +600,20 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
this.updateFn.trigger(); this.updateFn.trigger();
} }
/**
* Gets the first (and ideally only) name filter condition. If one isn't present,
* this returns null.
* @returns The first name filter condition, or null if none.
*/
public getFirstNameFilterCondition(): NameFilterCondition | null {
for (const filter of this.filterConditions) {
if (filter instanceof NameFilterCondition) {
return filter;
}
}
return null;
}
/** /**
* Gets the tags for a room identified by the store. The returned set * Gets the tags for a room identified by the store. The returned set
* should never be empty, and will contain DefaultTagID.Untagged if * should never be empty, and will contain DefaultTagID.Untagged if

View file

@ -465,6 +465,10 @@ export class Algorithm extends EventEmitter {
return this.filteredRooms; return this.filteredRooms;
} }
public getUnfilteredRooms(): ITagMap {
return this._cachedStickyRooms || this.cachedRooms;
}
/** /**
* This returns the same as getOrderedRooms(), but without the sticky room * This returns the same as getOrderedRooms(), but without the sticky room
* map as it causes issues for sticky room handling (see sticky room handling * map as it causes issues for sticky room handling (see sticky room handling

View file

@ -18,6 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition"; import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { removeHiddenChars } from "matrix-js-sdk/src/utils"; import { removeHiddenChars } from "matrix-js-sdk/src/utils";
import { throttle } from "lodash";
/** /**
* A filter condition for the room list which reveals rooms of a particular * A filter condition for the room list which reveals rooms of a particular
@ -41,9 +42,13 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
public set search(val: string) { public set search(val: string) {
this._search = val; this._search = val;
this.emit(FILTER_CHANGED); this.callUpdate();
} }
private callUpdate = throttle(() => {
this.emit(FILTER_CHANGED);
}, 200, {trailing: true, leading: true});
public isVisible(room: Room): boolean { public isVisible(room: Room): boolean {
const lcFilter = this.search.toLowerCase(); const lcFilter = this.search.toLowerCase();
if (this.search[0] === '#') { if (this.search[0] === '#') {

View file

@ -53,6 +53,9 @@ export function arrayHasDiff(a: any[], b: any[]): boolean {
// an element from the other. // an element from the other.
if (b.some(i => !a.includes(i))) return true; if (b.some(i => !a.includes(i))) return true;
if (a.some(i => !b.includes(i))) return true; if (a.some(i => !b.includes(i))) return true;
// if all the keys are common, say so
return false;
} else { } else {
return true; // different lengths means they are naturally diverged return true; // different lengths means they are naturally diverged
} }

View file

@ -72,6 +72,23 @@ export function objectHasValueChange(a: any, b: any): boolean {
return arrayHasDiff(aValues, bValues); return arrayHasDiff(aValues, bValues);
} }
/**
* Determines if any keys were added, removed, or changed between two objects.
* For changes, simple triple equal comparisons are done, not in-depth
* tree checking.
* @param a The first object. Must be defined.
* @param b The second object. Must be defined.
* @returns True if there's a difference between the objects, false otherwise
*/
export function objectHasDiff(a: any, b: any): boolean {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (arrayHasDiff(aKeys, bKeys)) return true;
const possibleChanges = arrayUnion(aKeys, bKeys);
return possibleChanges.some(k => a[k] !== b[k]);
}
/** /**
* Determines the keys added, changed, and removed between two objects. * Determines the keys added, changed, and removed between two objects.
* For changes, simple triple equal comparisons are done, not in-depth * For changes, simple triple equal comparisons are done, not in-depth