Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into joriks/eslint-config

This commit is contained in:
Jorik Schellekens 2020-07-20 16:22:32 +01:00
commit b3fa855bd8
454 changed files with 13522 additions and 17619 deletions

View file

@ -18,7 +18,6 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import { AsyncStore } from "./AsyncStore";
import { ActionPayload } from "../dispatcher/payloads";
export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<T> {
protected matrixClient: MatrixClient;

View file

@ -20,7 +20,7 @@ import { ActionPayload } from "../dispatcher/payloads";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import { arrayHasDiff } from "../utils/arrays";
import { RoomListStoreTempProxy } from "./room-list/RoomListStoreTempProxy";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
const MAX_ROOMS = 20; // arbitrary
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up
@ -51,15 +51,16 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
}
public get visible(): boolean {
return this.state.enabled;
return this.state.enabled && this.meetsRoomRequirement;
}
private get meetsRoomRequirement(): boolean {
return this.matrixClient.getVisibleRooms().length >= 20;
}
protected async onAction(payload: ActionPayload) {
if (!this.matrixClient) return;
// TODO: Remove when new room list is made the default
if (!RoomListStoreTempProxy.isUsingNewStore()) return;
if (payload.action === 'setting_updated') {
if (payload.settingName === 'breadcrumb_rooms') {
await this.updateRooms();
@ -80,9 +81,6 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
}
protected async onReady() {
// TODO: Remove when new room list is made the default
if (!RoomListStoreTempProxy.isUsingNewStore()) return;
await this.updateRooms();
await this.updateState({enabled: SettingsStore.getValue("breadcrumbs", null)});
@ -91,16 +89,14 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
}
protected async onNotReady() {
// TODO: Remove when new room list is made the default
if (!RoomListStoreTempProxy.isUsingNewStore()) return;
this.matrixClient.removeListener("Room.myMembership", this.onMyMembership);
this.matrixClient.removeListener("Room", this.onRoom);
}
private onMyMembership = async (room: Room) => {
// We turn on breadcrumbs by default once the user has at least 1 room to show.
if (!this.state.enabled) {
// Only turn on breadcrumbs is the user hasn't explicitly turned it off again.
const settingValueRaw = SettingsStore.getValue("breadcrumbs", null, /*excludeDefault=*/true);
if (this.meetsRoomRequirement && isNullOrUndefined(settingValueRaw)) {
await SettingsStore.setValue("breadcrumbs", null, SettingLevel.ACCOUNT, true);
}
};
@ -125,6 +121,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
}
private async appendRoom(room: Room) {
let updated = false;
const rooms = (this.state.rooms || []).slice(); // cheap clone
// If the room is upgraded, use that room instead. We'll also splice out
@ -136,30 +133,42 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
// Take out any room that isn't the most recent room
for (let i = 0; i < history.length - 1; i++) {
const idx = rooms.findIndex(r => r.roomId === history[i].roomId);
if (idx !== -1) rooms.splice(idx, 1);
if (idx !== -1) {
rooms.splice(idx, 1);
updated = true;
}
}
}
// Remove the existing room, if it is present
const existingIdx = rooms.findIndex(r => r.roomId === room.roomId);
if (existingIdx !== -1) {
rooms.splice(existingIdx, 1);
}
// Splice the room to the start of the list
rooms.splice(0, 0, room);
// If we're focusing on the first room no-op
if (existingIdx !== 0) {
if (existingIdx !== -1) {
rooms.splice(existingIdx, 1);
}
// Splice the room to the start of the list
rooms.splice(0, 0, room);
updated = true;
}
if (rooms.length > MAX_ROOMS) {
// This looks weird, but it's saying to start at the MAX_ROOMS point in the
// list and delete everything after it.
rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
updated = true;
}
// Update the breadcrumbs
await this.updateState({rooms});
const roomIds = rooms.map(r => r.roomId);
if (roomIds.length > 0) {
await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
if (updated) {
// Update the breadcrumbs
await this.updateState({rooms});
const roomIds = rooms.map(r => r.roomId);
if (roomIds.length > 0) {
await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
}
}
}
}

View file

@ -18,10 +18,11 @@ import * as RoomNotifs from '../RoomNotifs';
import EventEmitter from 'events';
import { throttle } from "lodash";
import SettingsStore from "../settings/SettingsStore";
import {RoomListStoreTempProxy} from "./room-list/RoomListStoreTempProxy";
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))$/;
/^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
function commonPrefix(a, b) {
const len = Math.min(a.length, b.length);
@ -61,9 +62,7 @@ class CustomRoomTagStore extends EventEmitter {
trailing: true,
},
);
this._roomListStoreToken = RoomListStoreTempProxy.addListener(() => {
this._setState({tags: this._getUpdatedTags()});
});
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this._onListsUpdated);
dis.register(payload => this._onDispatch(payload));
}
@ -86,7 +85,7 @@ class CustomRoomTagStore extends EventEmitter {
}
getSortedTags() {
const roomLists = RoomListStoreTempProxy.getRoomLists();
const roomLists = RoomListStore.instance.orderedLists;
const tagNames = Object.keys(this._state.tags).sort();
const prefixes = tagNames.map((name, i) => {
@ -110,6 +109,9 @@ class CustomRoomTagStore extends EventEmitter {
});
}
_onListsUpdated = () => {
this._setState({tags: this._getUpdatedTags()});
};
_onDispatch(payload) {
switch (payload.action) {
@ -127,10 +129,7 @@ class CustomRoomTagStore extends EventEmitter {
case 'on_logged_out': {
// we assume to always have a tags object in the state
this._state = {tags: {}};
if (this._roomListStoreToken) {
this._roomListStoreToken.remove();
this._roomListStoreToken = null;
}
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this._onListsUpdated);
}
break;
}
@ -141,7 +140,7 @@ class CustomRoomTagStore extends EventEmitter {
return;
}
const newTagNames = Object.keys(RoomListStoreTempProxy.getRoomLists())
const newTagNames = Object.keys(RoomListStore.instance.orderedLists)
.filter((tagName) => {
return !tagName.match(STANDARD_TAGS_REGEX);
}).sort();

View file

@ -1,813 +0,0 @@
/*
Copyright 2018, 2019 New Vector Ltd
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 {Store} from 'flux/utils';
import dis from '../dispatcher/dispatcher';
import DMRoomMap from '../utils/DMRoomMap';
import * as Unread from '../Unread';
import SettingsStore from "../settings/SettingsStore";
/*
Room sorting algorithm:
* Always prefer to have red > grey > bold > idle
* The room being viewed should be sticky (not jump down to the idle list)
* When switching to a new room, sort the last sticky room to the top of the idle list.
The approach taken by the store is to generate an initial representation of all the
tagged lists (accepting that it'll take a little bit longer to calculate) and make
small changes to that over time. This results in quick changes to the room list while
also having update operations feel more like popping/pushing to a stack.
*/
const CATEGORY_RED = "red"; // Mentions in the room
const CATEGORY_GREY = "grey"; // Unread notified messages (not mentions)
const CATEGORY_BOLD = "bold"; // Unread messages (not notified, 'Mentions Only' rooms)
const CATEGORY_IDLE = "idle"; // Nothing of interest
export const TAG_DM = "im.vector.fake.direct";
/**
* Identifier for manual sorting behaviour: sort by the user defined order.
* @type {string}
*/
export const ALGO_MANUAL = "manual";
/**
* Identifier for alphabetic sorting behaviour: sort by the room name alphabetically first.
* @type {string}
*/
export const ALGO_ALPHABETIC = "alphabetic";
/**
* Identifier for classic sorting behaviour: sort by the most recent message first.
* @type {string}
*/
export const ALGO_RECENT = "recent";
const CATEGORY_ORDER = [CATEGORY_RED, CATEGORY_GREY, CATEGORY_BOLD, CATEGORY_IDLE];
const getListAlgorithm = (listKey, settingAlgorithm) => {
// apply manual sorting only to m.favourite, otherwise respect the global setting
// all the known tags are listed explicitly here to simplify future changes
switch (listKey) {
case "im.vector.fake.invite":
case "im.vector.fake.recent":
case "im.vector.fake.archived":
case "m.lowpriority":
case TAG_DM:
return settingAlgorithm;
case "m.favourite":
default: // custom-tags
return ALGO_MANUAL;
}
};
const knownLists = new Set([
"m.favourite",
"im.vector.fake.invite",
"im.vector.fake.recent",
"im.vector.fake.archived",
"m.lowpriority",
TAG_DM,
]);
/**
* A class for storing application state for categorising rooms in
* the RoomList.
*/
class RoomListStore extends Store {
constructor() {
super(dis);
this._checkDisabled();
this._init();
this._getManualComparator = this._getManualComparator.bind(this);
this._recentsComparator = this._recentsComparator.bind(this);
}
_checkDisabled() {
this.disabled = SettingsStore.isFeatureEnabled("feature_new_room_list");
if (this.disabled) {
console.warn("👋 legacy room list store has been disabled");
}
}
/**
* Changes the sorting algorithm used by the RoomListStore.
* @param {string} algorithm The new algorithm to use. Should be one of the ALGO_* constants.
* @param {boolean} orderImportantFirst Whether to sort by categories of importance
*/
updateSortingAlgorithm(algorithm, orderImportantFirst) {
// Dev note: We only have two algorithms at the moment, but it isn't impossible that we want
// multiple in the future. Also constants make things slightly clearer.
console.log("Updating room sorting algorithm: ", {algorithm, orderImportantFirst});
this._setState({algorithm, orderImportantFirst});
// Trigger a resort of the entire list to reflect the change in algorithm
this._generateInitialRoomLists();
}
_init() {
if (this.disabled) return;
// Initialise state
const defaultLists = {
"m.server_notice": [/* { room: js-sdk room, category: string } */],
"im.vector.fake.invite": [],
"m.favourite": [],
"im.vector.fake.recent": [],
[TAG_DM]: [],
"m.lowpriority": [],
"im.vector.fake.archived": [],
};
this._state = {
// The rooms in these arrays are ordered according to either the
// 'recents' behaviour or 'manual' behaviour.
lists: defaultLists,
presentationLists: defaultLists, // like `lists`, but with arrays of rooms instead
ready: false,
stickyRoomId: null,
algorithm: ALGO_RECENT,
orderImportantFirst: false,
};
SettingsStore.monitorSetting('RoomList.orderAlphabetically', null);
SettingsStore.monitorSetting('RoomList.orderByImportance', null);
SettingsStore.monitorSetting('feature_custom_tags', null);
}
_setState(newState) {
if (this.disabled) return;
// If we're changing the lists, transparently change the presentation lists (which
// is given to requesting components). This dramatically simplifies our code elsewhere
// while also ensuring we don't need to update all the calling components to support
// categories.
if (newState['lists']) {
const presentationLists = {};
for (const key of Object.keys(newState['lists'])) {
presentationLists[key] = newState['lists'][key].map((e) => e.room);
}
newState['presentationLists'] = presentationLists;
}
this._state = Object.assign(this._state, newState);
this.__emitChange();
}
__onDispatch(payload) {
if (this.disabled) return;
const logicallyReady = this._matrixClient && this._state.ready;
switch (payload.action) {
case 'setting_updated': {
if (!logicallyReady) break;
switch (payload.settingName) {
case "RoomList.orderAlphabetically":
this.updateSortingAlgorithm(payload.newValue ? ALGO_ALPHABETIC : ALGO_RECENT,
this._state.orderImportantFirst);
break;
case "RoomList.orderByImportance":
this.updateSortingAlgorithm(this._state.algorithm, payload.newValue);
break;
case "feature_custom_tags":
this._setState({tagsEnabled: payload.newValue});
this._generateInitialRoomLists(); // Tags means we have to start from scratch
break;
}
}
break;
// Initialise state after initial sync
case 'MatrixActions.sync': {
if (!(payload.prevState !== 'PREPARED' && payload.state === 'PREPARED')) {
break;
}
this._checkDisabled();
if (this.disabled) return;
// Always ensure that we set any state needed for settings here. It is possible that
// setting updates trigger on startup before we are ready to sync, so we want to make
// sure that the right state is in place before we actually react to those changes.
this._setState({tagsEnabled: SettingsStore.isFeatureEnabled("feature_custom_tags")});
this._matrixClient = payload.matrixClient;
const orderByImportance = SettingsStore.getValue("RoomList.orderByImportance");
const orderAlphabetically = SettingsStore.getValue("RoomList.orderAlphabetically");
this.updateSortingAlgorithm(orderAlphabetically ? ALGO_ALPHABETIC : ALGO_RECENT, orderByImportance);
}
break;
case 'MatrixActions.Room.receipt': {
if (!logicallyReady) break;
// First see if the receipt event is for our own user. If it was, trigger
// a room update (we probably read the room on a different device).
const myUserId = this._matrixClient.getUserId();
for (const eventId of Object.keys(payload.event.getContent())) {
const receiptUsers = Object.keys(payload.event.getContent()[eventId]['m.read'] || {});
if (receiptUsers.includes(myUserId)) {
this._roomUpdateTriggered(payload.room.roomId);
return;
}
}
}
break;
case 'MatrixActions.Room.tags': {
if (!logicallyReady) break;
// TODO: Figure out which rooms changed in the tag and only change those.
// This is very blunt and wipes out the sticky room stuff
this._generateInitialRoomLists();
}
break;
case 'MatrixActions.Room.timeline': {
if (!logicallyReady ||
!payload.isLiveEvent ||
!payload.isLiveUnfilteredRoomTimelineEvent ||
!this._eventTriggersRecentReorder(payload.event) ||
this._state.algorithm !== ALGO_RECENT
) {
break;
}
this._roomUpdateTriggered(payload.event.getRoomId());
}
break;
// When an event is decrypted, it could mean we need to reorder the room
// list because we now know the type of the event.
case 'MatrixActions.Event.decrypted': {
if (!logicallyReady) break;
const roomId = payload.event.getRoomId();
// We may have decrypted an event without a roomId (e.g to_device)
if (!roomId) break;
const room = this._matrixClient.getRoom(roomId);
// We somehow decrypted an event for a room our client is unaware of
if (!room) break;
const liveTimeline = room.getLiveTimeline();
const eventTimeline = room.getTimelineForEvent(payload.event.getId());
// Either this event was not added to the live timeline (e.g. pagination)
// or it doesn't affect the ordering of the room list.
if (liveTimeline !== eventTimeline || !this._eventTriggersRecentReorder(payload.event)) {
break;
}
this._roomUpdateTriggered(roomId);
}
break;
case 'MatrixActions.accountData': {
if (!logicallyReady) break;
if (payload.event_type !== 'm.direct') break;
// TODO: Figure out which rooms changed in the direct chat and only change those.
// This is very blunt and wipes out the sticky room stuff
this._generateInitialRoomLists();
}
break;
case 'MatrixActions.Room.myMembership': {
if (!logicallyReady) break;
this._roomUpdateTriggered(payload.room.roomId, true);
}
break;
// This could be a new room that we've been invited to, joined or created
// we won't get a RoomMember.membership for these cases if we're not already
// a member.
case 'MatrixActions.Room': {
if (!logicallyReady) break;
this._roomUpdateTriggered(payload.room.roomId, true);
}
break;
// TODO: Re-enable optimistic updates when we support dragging again
// case 'RoomListActions.tagRoom.pending': {
// if (!logicallyReady) break;
// // XXX: we only show one optimistic update at any one time.
// // Ideally we should be making a list of in-flight requests
// // that are backed by transaction IDs. Until the js-sdk
// // supports this, we're stuck with only being able to use
// // the most recent optimistic update.
// console.log("!! Optimistic tag: ", payload);
// }
// break;
// case 'RoomListActions.tagRoom.failure': {
// if (!logicallyReady) break;
// // Reset state according to js-sdk
// console.log("!! Optimistic tag failure: ", payload);
// }
// break;
case 'on_client_not_viable':
case 'on_logged_out': {
// Reset state without pushing an update to the view, which generally assumes that
// the matrix client isn't `null` and so causing a re-render will cause NPEs.
this._init();
this._matrixClient = null;
}
break;
case 'view_room': {
if (!logicallyReady) break;
// Note: it is important that we set a new stickyRoomId before setting the old room
// to IDLE. If we don't, the wrong room gets counted as sticky.
const currentStickyId = this._state.stickyRoomId;
this._setState({stickyRoomId: payload.room_id});
if (currentStickyId) {
this._setRoomCategory(this._matrixClient.getRoom(currentStickyId), CATEGORY_IDLE);
}
}
break;
}
}
_roomUpdateTriggered(roomId, ignoreSticky) {
// We don't calculate categories for sticky rooms because we have a moderate
// interest in trying to maintain the category that they were last in before
// being artificially flagged as IDLE. Also, this reduces the amount of time
// we spend in _setRoomCategory ever so slightly.
if (this._state.stickyRoomId !== roomId || ignoreSticky) {
// Micro optimization: Only look up the room if we're confident we'll need it.
const room = this._matrixClient.getRoom(roomId);
if (!room) return;
const category = this._calculateCategory(room);
this._setRoomCategory(room, category);
}
}
_filterTags(tags) {
tags = tags ? Object.keys(tags) : [];
if (this._state.tagsEnabled) return tags;
return tags.filter((t) => knownLists.has(t));
}
_getRecommendedTagsForRoom(room) {
const tags = [];
const myMembership = room.getMyMembership();
if (myMembership === 'join' || myMembership === 'invite') {
// Stack the user's tags on top
tags.push(...this._filterTags(room.tags));
// Order matters here: The DMRoomMap updates before invites
// are accepted, so we check to see if the room is an invite
// first, then if it is a direct chat, and finally default
// to the "recents" list.
const dmRoomMap = DMRoomMap.shared();
if (myMembership === 'invite') {
tags.push("im.vector.fake.invite");
} else if (dmRoomMap.getUserIdForRoomId(room.roomId) && tags.length === 0) {
// We intentionally don't duplicate rooms in other tags into the people list
// as a feature.
tags.push(TAG_DM);
} else if (tags.length === 0) {
tags.push("im.vector.fake.recent");
}
} else if (myMembership) { // null-guard as null means it was peeked
tags.push("im.vector.fake.archived");
}
return tags;
}
_slotRoomIntoList(room, category, tag, existingEntries, newList, lastTimestampFn) {
const targetCategoryIndex = CATEGORY_ORDER.indexOf(category);
let categoryComparator = (a, b) => lastTimestampFn(a.room) >= lastTimestampFn(b.room);
const sortAlgorithm = getListAlgorithm(tag, this._state.algorithm);
if (sortAlgorithm === ALGO_RECENT) {
categoryComparator = (a, b) => this._recentsComparator(a, b, lastTimestampFn);
} else if (sortAlgorithm === ALGO_ALPHABETIC) {
categoryComparator = (a, b) => this._lexicographicalComparator(a, b);
}
// The slotting algorithm works by trying to position the room in the most relevant
// category of the list (red > grey > etc). To accomplish this, we need to consider
// a couple cases: the category existing in the list but having other rooms in it and
// the case of the category simply not existing and needing to be started. In order to
// do this efficiently, we only want to iterate over the list once and solve our sorting
// problem as we go.
//
// Firstly, we'll remove any existing entry that references the room we're trying to
// insert. We don't really want to consider the old entry and want to recreate it. We
// also exclude the sticky (currently active) room from the categorization logic and
// let it pass through wherever it resides in the list: it shouldn't be moving around
// the list too much, so we want to keep it where it is.
//
// The case of the category we want existing is easy to handle: once we hit the category,
// find the room that has a most recent event later than our own and insert just before
// that (making us the more recent room). If we end up hitting the next category before
// we can slot the room in, insert the room at the top of the category as a fallback. We
// do this to ensure that the room doesn't go too far down the list given it was previously
// considered important (in the case of going down in category) or is now more important
// (suddenly becoming red, for instance). The boundary tracking is how we end up achieving
// this, as described in the next paragraphs.
//
// The other case of the category not already existing is a bit more complicated. We track
// the boundaries of each category relative to the list we're currently building so that
// when we miss the category we can insert the room at the right spot. Most importantly, we
// can't assume that the end of the list being built is the right spot because of the last
// paragraph's requirement: the room should be put to the top of a category if the category
// runs out of places to put it.
//
// All told, our tracking looks something like this:
//
// ------ A <- Category boundary (start of red)
// RED
// RED
// RED
// ------ B <- In this example, we have a grey room we want to insert.
// BOLD
// BOLD
// ------ C
// IDLE
// IDLE
// ------ D <- End of list
//
// Given that example, and our desire to insert a GREY room into the list, this iterates
// over the room list until it realizes that BOLD comes after GREY and we're no longer
// in the RED section. Because there's no rooms there, we simply insert there which is
// also a "category boundary". If we change the example to wanting to insert a BOLD room
// which can't be ordered by timestamp with the existing couple rooms, we would still make
// use of the boundary flag to insert at B before changing the boundary indicator to C.
let desiredCategoryBoundaryIndex = 0;
let foundBoundary = false;
let pushedEntry = false;
for (const entry of existingEntries) {
// We insert our own record as needed, so don't let the old one through.
if (entry.room.roomId === room.roomId) {
continue;
}
// if the list is a recent list, and the room appears in this list, and we're
// not looking at a sticky room (sticky rooms have unreliable categories), try
// to slot the new room in
if (entry.room.roomId !== this._state.stickyRoomId && !pushedEntry) {
const entryCategoryIndex = CATEGORY_ORDER.indexOf(entry.category);
// As per above, check if we're meeting that boundary we wanted to locate.
if (entryCategoryIndex >= targetCategoryIndex && !foundBoundary) {
desiredCategoryBoundaryIndex = newList.length - 1;
foundBoundary = true;
}
// If we've hit the top of a boundary beyond our target category, insert at the top of
// the grouping to ensure the room isn't slotted incorrectly. Otherwise, try to insert
// based on most recent timestamp.
const changedBoundary = entryCategoryIndex > targetCategoryIndex;
const currentCategory = entryCategoryIndex === targetCategoryIndex;
if (changedBoundary || (currentCategory && categoryComparator({room}, entry) <= 0)) {
if (changedBoundary) {
// If we changed a boundary, then we've gone too far - go to the top of the last
// section instead.
newList.splice(desiredCategoryBoundaryIndex, 0, {room, category});
} else {
// If we're ordering by timestamp, just insert normally
newList.push({room, category});
}
pushedEntry = true;
}
}
// Fall through and clone the list.
newList.push(entry);
}
if (!pushedEntry && desiredCategoryBoundaryIndex >= 0) {
console.warn(`!! Room ${room.roomId} nearly lost: Ran off the end of ${tag}`);
console.warn(`!! Inserting at position ${desiredCategoryBoundaryIndex} with category ${category}`);
newList.splice(desiredCategoryBoundaryIndex, 0, {room, category});
pushedEntry = true;
}
return pushedEntry;
}
_setRoomCategory(room, category) {
if (!room) return; // This should only happen in tests
const listsClone = {};
// Micro optimization: Support lazily loading the last timestamp in a room
const timestampCache = {}; // {roomId => ts}
const lastTimestamp = (room) => {
if (!timestampCache[room.roomId]) {
timestampCache[room.roomId] = this._tsOfNewestEvent(room);
}
return timestampCache[room.roomId];
};
const targetTags = this._getRecommendedTagsForRoom(room);
const insertedIntoTags = [];
// We need to make sure all the tags (lists) are updated with the room's new position. We
// generally only get called here when there's a new room to insert or a room has potentially
// changed positions within the list.
//
// We do all our checks by iterating over the rooms in the existing lists, trying to insert
// our room where we can. As a guiding principle, we should be removing the room from all
// tags, and insert the room into targetTags. We should perform the deletion before the addition
// where possible to keep a consistent state. By the end of this, targetTags should be the
// same as insertedIntoTags.
for (const key of Object.keys(this._state.lists)) {
const shouldHaveRoom = targetTags.includes(key);
// Speed optimization: Don't do complicated math if we don't have to.
if (!shouldHaveRoom) {
listsClone[key] = this._state.lists[key].filter((e) => e.room.roomId !== room.roomId);
} else if (getListAlgorithm(key, this._state.algorithm) === ALGO_MANUAL) {
// Manually ordered tags are sorted later, so for now we'll just clone the tag
// and add our room if needed
listsClone[key] = this._state.lists[key].filter((e) => e.room.roomId !== room.roomId);
listsClone[key].push({room, category});
insertedIntoTags.push(key);
} else {
listsClone[key] = [];
const pushedEntry = this._slotRoomIntoList(
room, category, key, this._state.lists[key], listsClone[key], lastTimestamp);
if (!pushedEntry) {
// This should rarely happen: _slotRoomIntoList has several checks which attempt
// to make sure that a room is not lost in the list. If we do lose the room though,
// we shouldn't throw it on the floor and forget about it. Instead, we should insert
// it somewhere. We'll insert it at the top for a couple reasons: 1) it is probably
// an important room for the user and 2) if this does happen, we'd want a bug report.
console.warn(`!! Room ${room.roomId} nearly lost: Failed to find a position`);
console.warn(`!! Inserting at position 0 in the list and flagging as inserted`);
console.warn("!! Additional info: ", {
category,
key,
upToIndex: listsClone[key].length,
expectedCount: this._state.lists[key].length,
});
listsClone[key].splice(0, 0, {room, category});
}
insertedIntoTags.push(key);
}
}
// Double check that we inserted the room in the right places.
// There should never be a discrepancy.
for (const targetTag of targetTags) {
let count = 0;
for (const insertedTag of insertedIntoTags) {
if (insertedTag === targetTag) count++;
}
if (count !== 1) {
console.warn(`!! Room ${room.roomId} inserted ${count} times to ${targetTag}`);
}
// This is a workaround for https://github.com/vector-im/riot-web/issues/11303
// The logging is to try and identify what happened exactly.
if (count === 0) {
// Something went very badly wrong - try to recover the room.
// We don't bother checking how the target list is ordered - we're expecting
// to just insert it.
console.warn(`!! Recovering ${room.roomId} for tag ${targetTag} at position 0`);
if (!listsClone[targetTag]) {
console.warn(`!! List for tag ${targetTag} does not exist - creating`);
listsClone[targetTag] = [];
}
listsClone[targetTag].splice(0, 0, {room, category});
}
}
// Sort the favourites before we set the clone
for (const tag of Object.keys(listsClone)) {
if (getListAlgorithm(tag, this._state.algorithm) !== ALGO_MANUAL) continue; // skip recents (pre-sorted)
listsClone[tag].sort(this._getManualComparator(tag));
}
this._setState({lists: listsClone});
}
_generateInitialRoomLists() {
// Log something to show that we're throwing away the old results. This is for the inevitable
// question of "why is 100% of my CPU going towards Riot?" - a quick look at the logs would reveal
// that something is wrong with the RoomListStore.
console.log("Generating initial room lists");
const lists = {
"m.server_notice": [],
"im.vector.fake.invite": [],
"m.favourite": [],
"im.vector.fake.recent": [],
[TAG_DM]: [],
"m.lowpriority": [],
"im.vector.fake.archived": [],
};
const dmRoomMap = DMRoomMap.shared();
this._matrixClient.getRooms().forEach((room) => {
const myUserId = this._matrixClient.getUserId();
const membership = room.getMyMembership();
const me = room.getMember(myUserId);
if (membership === "invite") {
lists["im.vector.fake.invite"].push({room, category: CATEGORY_RED});
} else if (membership === "join" || membership === "ban" || (me && me.isKicked())) {
// Used to split rooms via tags
let tagNames = Object.keys(room.tags);
// ignore any m. tag names we don't know about
tagNames = tagNames.filter((t) => {
// Speed optimization: Avoid hitting the SettingsStore at all costs by making it the
// last condition possible.
return lists[t] !== undefined || (!t.startsWith('m.') && this._state.tagsEnabled);
});
if (tagNames.length) {
for (let i = 0; i < tagNames.length; i++) {
const tagName = tagNames[i];
lists[tagName] = lists[tagName] || [];
// Default to an arbitrary category for tags which aren't ordered by recents
let category = CATEGORY_IDLE;
if (getListAlgorithm(tagName, this._state.algorithm) !== ALGO_MANUAL) {
category = this._calculateCategory(room);
}
lists[tagName].push({room, category});
}
} else if (dmRoomMap.getUserIdForRoomId(room.roomId)) {
// "Direct Message" rooms (that we're still in and that aren't otherwise tagged)
lists[TAG_DM].push({room, category: this._calculateCategory(room)});
} else {
lists["im.vector.fake.recent"].push({room, category: this._calculateCategory(room)});
}
} else if (membership === "leave") {
// The category of these rooms is not super important, so deprioritize it to the lowest
// possible value.
lists["im.vector.fake.archived"].push({room, category: CATEGORY_IDLE});
}
});
// We use this cache in the recents comparator because _tsOfNewestEvent can take a while. This
// cache only needs to survive the sort operation below and should not be implemented outside
// of this function, otherwise the room lists will almost certainly be out of date and wrong.
const latestEventTsCache = {}; // roomId => timestamp
const tsOfNewestEventFn = (room) => {
if (!room) return Number.MAX_SAFE_INTEGER; // Should only happen in tests
if (latestEventTsCache[room.roomId]) {
return latestEventTsCache[room.roomId];
}
const ts = this._tsOfNewestEvent(room);
latestEventTsCache[room.roomId] = ts;
return ts;
};
Object.keys(lists).forEach((listKey) => {
let comparator;
switch (getListAlgorithm(listKey, this._state.algorithm)) {
case ALGO_RECENT:
comparator = (entryA, entryB) => this._recentsComparator(entryA, entryB, tsOfNewestEventFn);
break;
case ALGO_ALPHABETIC:
comparator = this._lexicographicalComparator;
break;
case ALGO_MANUAL:
default:
comparator = this._getManualComparator(listKey);
break;
}
if (this._state.orderImportantFirst) {
lists[listKey].sort((entryA, entryB) => {
if (entryA.category !== entryB.category) {
const idxA = CATEGORY_ORDER.indexOf(entryA.category);
const idxB = CATEGORY_ORDER.indexOf(entryB.category);
if (idxA > idxB) return 1;
if (idxA < idxB) return -1;
return 0; // Technically not possible
}
return comparator(entryA, entryB);
});
} else {
// skip the category comparison even though it should no-op when orderImportantFirst disabled
lists[listKey].sort(comparator);
}
});
this._setState({
lists,
ready: true, // Ready to receive updates to ordering
});
}
_eventTriggersRecentReorder(ev) {
return ev.getTs() && (
Unread.eventTriggersUnreadCount(ev) ||
ev.getSender() === this._matrixClient.credentials.userId
);
}
_tsOfNewestEvent(room) {
// Apparently we can have rooms without timelines, at least under testing
// environments. Just return MAX_INT when this happens.
if (!room || !room.timeline) return Number.MAX_SAFE_INTEGER;
for (let i = room.timeline.length - 1; i >= 0; --i) {
const ev = room.timeline[i];
if (this._eventTriggersRecentReorder(ev)) {
return ev.getTs();
}
}
// we might only have events that don't trigger the unread indicator,
// in which case use the oldest event even if normally it wouldn't count.
// This is better than just assuming the last event was forever ago.
if (room.timeline.length && room.timeline[0].getTs()) {
return room.timeline[0].getTs();
} else {
return Number.MAX_SAFE_INTEGER;
}
}
_calculateCategory(room) {
if (!this._state.orderImportantFirst) {
// Effectively disable the categorization of rooms if we're supposed to
// be sorting by more recent messages first. This triggers the timestamp
// comparison bit of _setRoomCategory and _recentsComparator instead of
// the category ordering.
return CATEGORY_IDLE;
}
const mentions = room.getUnreadNotificationCount("highlight") > 0;
if (mentions) return CATEGORY_RED;
let unread = room.getUnreadNotificationCount() > 0;
if (unread) return CATEGORY_GREY;
unread = Unread.doesRoomHaveUnreadMessages(room);
if (unread) return CATEGORY_BOLD;
return CATEGORY_IDLE;
}
_recentsComparator(entryA, entryB, tsOfNewestEventFn) {
const timestampA = tsOfNewestEventFn(entryA.room);
const timestampB = tsOfNewestEventFn(entryB.room);
return timestampB - timestampA;
}
_lexicographicalComparator(entryA, entryB) {
return entryA.room.name.localeCompare(entryB.room.name);
}
_getManualComparator(tagName, optimisticRequest) {
return (entryA, entryB) => {
const roomA = entryA.room;
const roomB = entryB.room;
let metaA = roomA.tags[tagName];
let metaB = roomB.tags[tagName];
if (optimisticRequest && roomA === optimisticRequest.room) metaA = optimisticRequest.metaData;
if (optimisticRequest && roomB === optimisticRequest.room) metaB = optimisticRequest.metaData;
// Make sure the room tag has an order element, if not set it to be the bottom
const a = metaA ? Number(metaA.order) : undefined;
const b = metaB ? Number(metaB.order) : undefined;
// Order undefined room tag orders to the bottom
if (a === undefined && b !== undefined) {
return 1;
} else if (a !== undefined && b === undefined) {
return -1;
}
return a === b ? this._lexicographicalComparator(entryA, entryB) : (a > b ? 1 : -1);
};
}
getRoomLists() {
return this._state.presentationLists;
}
}
if (global.singletonRoomListStore === undefined) {
global.singletonRoomListStore = new RoomListStore();
}
export default global.singletonRoomListStore;

View file

@ -24,7 +24,7 @@ export interface IToast<C extends keyof JSX.IntrinsicElements | JSXElementConstr
title: string;
icon?: string;
component: C;
props?: React.ComponentProps<C>;
props?: Omit<React.ComponentProps<C>, "toastKey">; // toastKey is injected by ToastContainer
}
/**

View file

@ -0,0 +1,101 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { NotificationColor } from "./NotificationColor";
import { TagID } from "../room-list/models";
import { Room } from "matrix-js-sdk/src/models/room";
import { arrayDiff } from "../../utils/arrays";
import { RoomNotificationState } from "./RoomNotificationState";
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationState";
export type FetchRoomFn = (room: Room) => RoomNotificationState;
export class ListNotificationState extends NotificationState {
private rooms: Room[] = [];
private states: { [roomId: string]: RoomNotificationState } = {};
constructor(private byTileCount = false, private tagId: TagID, private getRoomFn: FetchRoomFn) {
super();
}
public get symbol(): string {
return null; // This notification state doesn't support symbols
}
public setRooms(rooms: Room[]) {
// If we're only concerned about the tile count, don't bother setting up listeners.
if (this.byTileCount) {
this.rooms = rooms;
this.calculateTotalState();
return;
}
const oldRooms = this.rooms;
const diff = arrayDiff(oldRooms, rooms);
this.rooms = rooms;
for (const oldRoom of diff.removed) {
const state = this.states[oldRoom.roomId];
if (!state) continue; // We likely just didn't have a badge (race condition)
delete this.states[oldRoom.roomId];
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
}
for (const newRoom of diff.added) {
const state = this.getRoomFn(newRoom);
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
this.states[newRoom.roomId] = state;
}
this.calculateTotalState();
}
public getForRoom(room: Room) {
const state = this.states[room.roomId];
if (!state) throw new Error("Unknown room for notification state");
return state;
}
public destroy() {
super.destroy();
for (const state of Object.values(this.states)) {
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
}
this.states = {};
}
private onRoomNotificationStateUpdate = () => {
this.calculateTotalState();
};
private calculateTotalState() {
const snapshot = this.snapshot();
if (this.byTileCount) {
this._color = NotificationColor.Red;
this._count = this.rooms.length;
} else {
this._count = 0;
this._color = NotificationColor.None;
for (const state of Object.values(this.states)) {
this._count += state.count;
this._color = Math.max(this.color, state.color);
}
}
// finally, publish an update if needed
this.emitIfUpdated(snapshot);
}
}

View file

@ -0,0 +1,24 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export enum NotificationColor {
// Inverted (None -> Red) because we do integer comparisons on this
None, // nothing special
// TODO: Remove bold with notifications: https://github.com/vector-im/riot-web/issues/14227
Bold, // no badge, show as unread
Grey, // unread notified messages
Red, // unread pings
}

View file

@ -0,0 +1,87 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventEmitter } from "events";
import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable";
export const NOTIFICATION_STATE_UPDATE = "update";
export abstract class NotificationState extends EventEmitter implements IDestroyable {
protected _symbol: string;
protected _count: number;
protected _color: NotificationColor;
public get symbol(): string {
return this._symbol;
}
public get count(): number {
return this._count;
}
public get color(): NotificationColor {
return this._color;
}
public get isIdle(): boolean {
return this.color <= NotificationColor.None;
}
public get isUnread(): boolean {
return this.color >= NotificationColor.Bold;
}
public get hasUnreadCount(): boolean {
return this.color >= NotificationColor.Grey && (!!this.count || !!this.symbol);
}
public get hasMentions(): boolean {
return this.color >= NotificationColor.Red;
}
protected emitIfUpdated(snapshot: NotificationStateSnapshot) {
if (snapshot.isDifferentFrom(this)) {
this.emit(NOTIFICATION_STATE_UPDATE);
}
}
protected snapshot(): NotificationStateSnapshot {
return new NotificationStateSnapshot(this);
}
public destroy(): void {
this.removeAllListeners(NOTIFICATION_STATE_UPDATE);
}
}
export class NotificationStateSnapshot {
private readonly symbol: string;
private readonly count: number;
private readonly color: NotificationColor;
constructor(state: NotificationState) {
this.symbol = state.symbol;
this.count = state.count;
this.color = state.color;
}
public isDifferentFrom(other: NotificationState): boolean {
const before = {count: this.count, symbol: this.symbol, color: this.color};
const after = {count: other.count, symbol: other.symbol, color: other.color};
return JSON.stringify(before) !== JSON.stringify(after);
}
}

View file

@ -0,0 +1,125 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import * as RoomNotifs from '../../RoomNotifs';
import * as Unread from '../../Unread';
import { NotificationState } from "./NotificationState";
export class RoomNotificationState extends NotificationState implements IDestroyable {
constructor(public readonly room: Room) {
super();
this.room.on("Room.receipt", this.handleReadReceipt);
this.room.on("Room.timeline", this.handleRoomEventUpdate);
this.room.on("Room.redaction", this.handleRoomEventUpdate);
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
MatrixClientPeg.get().on("accountData", this.handleAccountDataUpdate);
this.updateNotificationState();
}
private get roomIsInvite(): boolean {
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
}
public destroy(): void {
super.destroy();
this.room.removeListener("Room.receipt", this.handleReadReceipt);
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
MatrixClientPeg.get().removeListener("accountData", this.handleAccountDataUpdate);
}
}
private handleReadReceipt = (event: MatrixEvent, room: Room) => {
if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore
if (room.roomId !== this.room.roomId) return; // not for us - ignore
this.updateNotificationState();
};
private handleRoomEventUpdate = (event: MatrixEvent) => {
const roomId = event.getRoomId();
if (roomId !== this.room.roomId) return; // ignore - not for us
this.updateNotificationState();
};
private handleAccountDataUpdate = (ev: MatrixEvent) => {
if (ev.getType() === "m.push_rules") {
this.updateNotificationState();
}
};
private updateNotificationState() {
const snapshot = this.snapshot();
if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) {
// When muted we suppress all notification states, even if we have context on them.
this._color = NotificationColor.None;
this._symbol = null;
this._count = 0;
} else if (this.roomIsInvite) {
this._color = NotificationColor.Red;
this._symbol = "!";
this._count = 1; // not used, technically
} else {
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'highlight');
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'total');
// For a 'true count' we pick the grey notifications first because they include the
// red notifications. If we don't have a grey count for some reason we use the red
// count. If that count is broken for some reason, assume zero. This avoids us showing
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
// Note: we only set the symbol if we have an actual count. We don't want to show
// zero on badges.
if (redNotifs > 0) {
this._color = NotificationColor.Red;
this._count = trueCount;
this._symbol = null; // symbol calculated by component
} else if (greyNotifs > 0) {
this._color = NotificationColor.Grey;
this._count = trueCount;
this._symbol = null; // symbol calculated by component
} else {
// We don't have any notified messages, but we might have unread messages. Let's
// find out.
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room);
if (hasUnread) {
this._color = NotificationColor.Bold;
} else {
this._color = NotificationColor.None;
}
// no symbol or count for this state
this._count = 0;
this._symbol = null;
}
}
// finally, publish an update if needed
this.emitIfUpdated(snapshot);
}
}

View file

@ -0,0 +1,96 @@
/*
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 { ActionPayload } from "../../dispatcher/payloads";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { DefaultTagID, TagID } from "../room-list/models";
import { FetchRoomFn, ListNotificationState } from "./ListNotificationState";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomNotificationState } from "./RoomNotificationState";
const INSPECIFIC_TAG = "INSPECIFIC_TAG";
type INSPECIFIC_TAG = "INSPECIFIC_TAG";
interface IState {}
export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new RoomNotificationStateStore();
private roomMap = new Map<Room, Map<TagID | INSPECIFIC_TAG, RoomNotificationState>>();
private constructor() {
super(defaultDispatcher, {});
}
/**
* Creates a new list notification state. The consumer is expected to set the rooms
* on the notification state, and destroy the state when it no longer needs it.
* @param tagId The tag to create the notification state for.
* @returns The notification state for the tag.
*/
public getListState(tagId: TagID): ListNotificationState {
// Note: we don't cache these notification states as the consumer is expected to call
// .setRooms() on the returned object, which could confuse other consumers.
// TODO: Update if/when invites move out of the room list.
const useTileCount = tagId === DefaultTagID.Invite;
const getRoomFn: FetchRoomFn = (room: Room) => {
return this.getRoomState(room, tagId);
};
return new ListNotificationState(useTileCount, tagId, getRoomFn);
}
/**
* Gets a copy of the notification state for a room. The consumer should not
* attempt to destroy the returned state as it may be shared with other
* consumers.
* @param room The room to get the notification state for.
* @param inTagId Optional tag ID to scope the notification state to.
* @returns The room's notification state.
*/
public getRoomState(room: Room, inTagId?: TagID): RoomNotificationState {
if (!this.roomMap.has(room)) {
this.roomMap.set(room, new Map<TagID | INSPECIFIC_TAG, RoomNotificationState>());
}
const targetTag = inTagId ? inTagId : INSPECIFIC_TAG;
const forRoomMap = this.roomMap.get(room);
if (!forRoomMap.has(targetTag)) {
forRoomMap.set(inTagId ? inTagId : INSPECIFIC_TAG, new RoomNotificationState(room));
}
return forRoomMap.get(targetTag);
}
public static get instance(): RoomNotificationStateStore {
return RoomNotificationStateStore.internalInstance;
}
protected async onNotReady(): Promise<any> {
for (const roomMap of this.roomMap.values()) {
for (const roomState of roomMap.values()) {
roomState.destroy();
}
}
}
// We don't need this, but our contract says we do.
protected async onAction(payload: ActionPayload) {
return Promise.resolve();
}
}

View file

@ -0,0 +1,35 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { NotificationColor } from "./NotificationColor";
import { NotificationState } from "./NotificationState";
export class StaticNotificationState extends NotificationState {
constructor(symbol: string, count: number, color: NotificationColor) {
super();
this._symbol = symbol;
this._count = count;
this._color = color;
}
public static forCount(count: number, color: NotificationColor): StaticNotificationState {
return new StaticNotificationState(null, count, color);
}
public static forSymbol(symbol: string, color: NotificationColor): StaticNotificationState {
return new StaticNotificationState(symbol, 0, color);
}
}

View file

@ -18,10 +18,6 @@ import { TagID } from "./models";
const TILE_HEIGHT_PX = 44;
// the .65 comes from the CSS where the show more button is
// mathematically 65% of a tile when floating.
const RESIZER_BOX_FACTOR = 0.65;
interface ISerializedListLayout {
numTiles: number;
showPreviews: boolean;
@ -81,28 +77,12 @@ export class ListLayout {
}
public get minVisibleTiles(): number {
return 1 + RESIZER_BOX_FACTOR;
return 1;
}
public get defaultVisibleTiles(): number {
// TODO: Remove dogfood flag
const val = Number(localStorage.getItem("mx_dogfood_rl_defTiles") || 4);
return val + RESIZER_BOX_FACTOR;
}
public calculateTilesToPixelsMin(maxTiles: number, n: number, possiblePadding: number): number {
// Only apply the padding if we're about to use maxTiles as we need to
// plan for the padding. If we're using n, the padding is already accounted
// for by the resizing stuff.
let padding = 0;
if (maxTiles < n) {
padding = possiblePadding;
}
return this.tilesToPixels(Math.min(maxTiles, n)) + padding;
}
public tilesWithResizerBoxFactor(n: number): number {
return n + RESIZER_BOX_FACTOR;
// This number is what "feels right", and mostly subject to design's opinion.
return 5;
}
public tilesWithPadding(n: number, paddingPx: number): number {
@ -121,6 +101,10 @@ export class ListLayout {
return px / this.tileHeight;
}
public reset() {
localStorage.removeItem(this.key);
}
private save() {
localStorage.setItem(this.key, JSON.stringify(this.serialize()));
}

View file

@ -18,7 +18,6 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { ActionPayload } from "../../dispatcher/payloads";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { RoomListStoreTempProxy } from "./RoomListStoreTempProxy";
import { MessageEventPreview } from "./previews/MessageEventPreview";
import { NameEventPreview } from "./previews/NameEventPreview";
import { TagID } from "./models";
@ -192,9 +191,6 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
protected async onAction(payload: ActionPayload) {
if (!this.matrixClient) return;
// TODO: Remove when new room list is made the default
if (!RoomListStoreTempProxy.isUsingNewStore()) return;
if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') {
const event = payload.event; // TODO: Type out the dispatcher
if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important

View file

@ -0,0 +1,73 @@
/*
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 { TagID } from "./models";
import { ListLayout } from "./ListLayout";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
interface IState {}
export default class RoomListLayoutStore extends AsyncStoreWithClient<IState> {
private static internalInstance: RoomListLayoutStore;
private readonly layoutMap = new Map<TagID, ListLayout>();
constructor() {
super(defaultDispatcher);
}
public static get instance(): RoomListLayoutStore {
if (!RoomListLayoutStore.internalInstance) {
RoomListLayoutStore.internalInstance = new RoomListLayoutStore();
}
return RoomListLayoutStore.internalInstance;
}
public ensureLayoutExists(tagId: TagID) {
if (!this.layoutMap.has(tagId)) {
this.layoutMap.set(tagId, new ListLayout(tagId));
}
}
public getLayoutFor(tagId: TagID): ListLayout {
if (!this.layoutMap.has(tagId)) {
this.layoutMap.set(tagId, new ListLayout(tagId));
}
return this.layoutMap.get(tagId);
}
// Note: this primarily exists for debugging, and isn't really intended to be used by anything.
public async resetLayouts() {
console.warn("Resetting layouts for room list");
for (const layout of this.layoutMap.values()) {
layout.reset();
}
}
protected async onNotReady(): Promise<any> {
// On logout, clear the map.
this.layoutMap.clear();
}
// We don't need this function, but our contract says we do
protected async onAction(payload: ActionPayload): Promise<any> {
return Promise.resolve();
}
}
window.mx_RoomListLayoutStore = RoomListLayoutStore.instance;

View file

@ -0,0 +1,609 @@
/*
Copyright 2018, 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.
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 { MatrixClient } from "matrix-js-sdk/src/client";
import SettingsStore from "../../settings/SettingsStore";
import { DefaultTagID, 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";
import { ActionPayload } from "../../dispatcher/payloads";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition";
import { TagWatcher } from "./TagWatcher";
import RoomViewStore from "../RoomViewStore";
import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import RoomListLayoutStore from "./RoomListLayoutStore";
import { MarkedExecution } from "../../utils/MarkedExecution";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
interface IState {
tagsEnabled?: boolean;
}
/**
* The event/channel which is called when the room lists have been changed. Raised
* with one argument: the instance of the store.
*/
export const LISTS_UPDATE_EVENT = "lists_update";
export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
/**
* Set to true if you're running tests on the store. Should not be touched in
* any other environment.
*/
public static TEST_MODE = false;
private initialListsGenerated = false;
private algorithm = new Algorithm();
private filterConditions: IFilterCondition[] = [];
private tagWatcher = new TagWatcher(this);
private updateFn = new MarkedExecution(() => this.emit(LISTS_UPDATE_EVENT));
private readonly watchedSettings = [
'feature_custom_tags',
'advancedRoomListLogging', // TODO: Remove watch: https://github.com/vector-im/riot-web/issues/14602
];
constructor() {
super(defaultDispatcher);
this.checkLoggingEnabled();
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
RoomViewStore.addListener(() => this.handleRVSUpdate({}));
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated);
}
public get orderedLists(): ITagMap {
if (!this.algorithm) return {}; // No tags yet.
return this.algorithm.getOrderedRooms();
}
public get matrixClient(): MatrixClient {
return super.matrixClient;
}
// Intended for test usage
public async resetStore() {
await this.reset();
this.tagWatcher = new TagWatcher(this);
this.filterConditions = [];
this.initialListsGenerated = false;
this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.off(FILTER_CHANGED, this.onAlgorithmListUpdated);
this.algorithm = new Algorithm();
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmListUpdated);
// Reset state without causing updates as the client will have been destroyed
// and downstream code will throw NPE errors.
await this.reset(null, true);
}
// Public for test usage. Do not call this.
public async makeReady(forcedClient?: MatrixClient) {
if (forcedClient) {
super.matrixClient = forcedClient;
}
this.checkLoggingEnabled();
// Update any settings here, as some may have happened before we were logically ready.
// Update any settings here, as some may have happened before we were logically ready.
console.log("Regenerating room lists: Startup");
await this.readAndCacheSettingsFromStore();
await this.regenerateAllLists({trigger: false});
await this.handleRVSUpdate({trigger: false}); // fake an RVS update to adjust sticky room, if needed
this.updateFn.mark(); // we almost certainly want to trigger an update.
this.updateFn.trigger();
}
private checkLoggingEnabled() {
if (SettingsStore.getValue("advancedRoomListLogging")) {
console.warn("Advanced room list logging is enabled");
}
}
private async readAndCacheSettingsFromStore() {
const tagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
await this.updateState({
tagsEnabled,
});
await this.updateAlgorithmInstances();
}
/**
* Handles suspected RoomViewStore changes.
* @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
private async handleRVSUpdate({trigger = true}) {
if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
const activeRoomId = RoomViewStore.getRoomId();
if (!activeRoomId && this.algorithm.stickyRoom) {
await this.algorithm.setStickyRoom(null);
} else if (activeRoomId) {
const activeRoom = this.matrixClient.getRoom(activeRoomId);
if (!activeRoom) {
console.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`);
await this.algorithm.setStickyRoom(null);
} else if (activeRoom !== this.algorithm.stickyRoom) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`Changing sticky room to ${activeRoomId}`);
}
await this.algorithm.setStickyRoom(activeRoom);
}
}
if (trigger) this.updateFn.trigger();
}
protected async onReady(): Promise<any> {
await this.makeReady();
}
protected async onNotReady(): Promise<any> {
await this.resetStore();
}
protected async onAction(payload: ActionPayload) {
// When we're running tests we can't reliably use setImmediate out of timing concerns.
// As such, we use a more synchronous model.
if (RoomListStoreClass.TEST_MODE) {
await this.onDispatchAsync(payload);
return;
}
// We do this to intentionally break out of the current event loop task, allowing
// us to instead wait for a more convenient time to run our updates.
setImmediate(() => this.onDispatchAsync(payload));
}
protected async onDispatchAsync(payload: ActionPayload) {
// Everything here requires a MatrixClient or some sort of logical readiness.
const logicallyReady = this.matrixClient && this.initialListsGenerated;
if (!logicallyReady) return;
if (payload.action === 'setting_updated') {
if (this.watchedSettings.includes(payload.settingName)) {
// TODO: Remove with https://github.com/vector-im/riot-web/issues/14602
if (payload.settingName === "advancedRoomListLogging") {
// Log when the setting changes so we know when it was turned on in the rageshake
const enabled = SettingsStore.getValue("advancedRoomListLogging");
console.warn("Advanced room list logging is enabled? " + enabled);
return;
}
console.log("Regenerating room lists: Settings changed");
await this.readAndCacheSettingsFromStore();
await this.regenerateAllLists({trigger: false}); // regenerate the lists now
this.updateFn.trigger();
}
}
if (!this.algorithm) {
// This shouldn't happen because `initialListsGenerated` implies we have an algorithm.
throw new Error("Room list store has no algorithm to process dispatcher update with");
}
if (payload.action === 'MatrixActions.Room.receipt') {
// First see if the receipt event is for our own user. If it was, trigger
// a room update (we probably read the room on a different device).
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
const room = payload.room;
if (!room) {
console.warn(`Own read receipt was in unknown room ${room.roomId}`);
return;
}
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Got own read receipt in ${room.roomId}`);
}
await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt);
this.updateFn.trigger();
return;
}
} else if (payload.action === 'MatrixActions.Room.tags') {
const roomPayload = (<any>payload); // TODO: Type out the dispatcher types
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Got tag change in ${roomPayload.room.roomId}`);
}
await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange);
this.updateFn.trigger();
} else if (payload.action === 'MatrixActions.Room.timeline') {
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
// Ignore non-live events (backfill)
if (!eventPayload.isLiveEvent || !payload.isLiveUnfilteredRoomTimelineEvent) return;
const roomId = eventPayload.event.getRoomId();
const room = this.matrixClient.getRoom(roomId);
const tryUpdate = async (updatedRoom: Room) => {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()}` +
` in ${updatedRoom.roomId}`);
}
if (eventPayload.event.getType() === 'm.room.tombstone' && eventPayload.event.getStateKey() === '') {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Got tombstone event - trying to remove now-dead room`);
}
const newRoom = this.matrixClient.getRoom(eventPayload.event.getContent()['replacement_room']);
if (newRoom) {
// If we have the new room, then the new room check will have seen the predecessor
// and did the required updates, so do nothing here.
return;
}
}
await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.Timeline);
this.updateFn.trigger();
};
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 = (<any>payload); // TODO: Type out the dispatcher types
const roomId = eventPayload.event.getRoomId();
const room = this.matrixClient.getRoom(roomId);
if (!room) {
console.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`);
return;
}
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`);
}
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
this.updateFn.trigger();
} else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Received updated DM map`);
}
const dmMap = eventPayload.event.getContent();
for (const userId of Object.keys(dmMap)) {
const roomIds = dmMap[userId];
for (const roomId of roomIds) {
const room = this.matrixClient.getRoom(roomId);
if (!room) {
console.warn(`${roomId} was found in DMs but the room is not in the store`);
continue;
}
// We expect this RoomUpdateCause to no-op if there's no change, and we don't expect
// the user to have hundreds of rooms to update in one event. As such, we just hammer
// away at updates until the problem is solved. If we were expecting more than a couple
// of rooms to be updated at once, we would consider batching the rooms up.
await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange);
}
}
this.updateFn.trigger();
} else if (payload.action === 'MatrixActions.Room.myMembership') {
const membershipPayload = (<any>payload); // TODO: Type out the dispatcher types
const oldMembership = getEffectiveMembership(membershipPayload.oldMembership);
const newMembership = getEffectiveMembership(membershipPayload.membership);
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`);
}
// If we're joining an upgraded room, we'll want to make sure we don't proliferate
// the dead room in the list.
const createEvent = membershipPayload.room.currentState.getStateEvents("m.room.create", "");
if (createEvent && createEvent.getContent()['predecessor']) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Room has a predecessor`);
}
const prevRoom = this.matrixClient.getRoom(createEvent.getContent()['predecessor']['room_id']);
if (prevRoom) {
const isSticky = this.algorithm.stickyRoom === prevRoom;
if (isSticky) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Clearing sticky room due to room upgrade`);
}
await this.algorithm.setStickyRoom(null);
}
// Note: we hit the algorithm instead of our handleRoomUpdate() function to
// avoid redundant updates.
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Removing previous room from room list`);
}
await this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved);
}
}
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Adding new room to room list`);
}
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
this.updateFn.trigger();
return;
}
if (oldMembership !== EffectiveMembership.Invite && newMembership === EffectiveMembership.Invite) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Handling invite to ${membershipPayload.room.roomId}`);
}
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
this.updateFn.trigger();
return;
}
// If it's not a join, it's transitioning into a different list (possibly historical)
if (oldMembership !== newMembership) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Handling membership change in ${membershipPayload.room.roomId}`);
}
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange);
this.updateFn.trigger();
return;
}
}
}
private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> {
const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause);
if (shouldUpdate) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`);
}
this.updateFn.mark();
}
}
public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
await this.setAndPersistTagSorting(tagId, sort);
this.updateFn.trigger();
}
private async setAndPersistTagSorting(tagId: TagID, sort: SortAlgorithm) {
await this.algorithm.setTagSorting(tagId, sort);
// TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114
localStorage.setItem(`mx_tagSort_${tagId}`, sort);
}
public getTagSorting(tagId: TagID): SortAlgorithm {
return this.algorithm.getTagSorting(tagId);
}
// noinspection JSMethodCanBeStatic
private getStoredTagSorting(tagId: TagID): SortAlgorithm {
// TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114
return <SortAlgorithm>localStorage.getItem(`mx_tagSort_${tagId}`);
}
// logic must match calculateListOrder
private calculateTagSorting(tagId: TagID): SortAlgorithm {
const isDefaultRecent = tagId === DefaultTagID.Invite || tagId === DefaultTagID.DM;
const defaultSort = isDefaultRecent ? SortAlgorithm.Recent : SortAlgorithm.Alphabetic;
const settingAlphabetical = SettingsStore.getValue("RoomList.orderAlphabetically", null, true);
const definedSort = this.getTagSorting(tagId);
const storedSort = this.getStoredTagSorting(tagId);
// We use the following order to determine which of the 4 flags to use:
// Stored > Settings > Defined > Default
let tagSort = defaultSort;
if (storedSort) {
tagSort = storedSort;
} else if (!isNullOrUndefined(settingAlphabetical)) {
tagSort = settingAlphabetical ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent;
} else if (definedSort) {
tagSort = definedSort;
} // else default (already set)
return tagSort;
}
public async setListOrder(tagId: TagID, order: ListAlgorithm) {
await this.setAndPersistListOrder(tagId, order);
this.updateFn.trigger();
}
private async setAndPersistListOrder(tagId: TagID, order: ListAlgorithm) {
await this.algorithm.setListOrdering(tagId, order);
// TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114
localStorage.setItem(`mx_listOrder_${tagId}`, order);
}
public getListOrder(tagId: TagID): ListAlgorithm {
return this.algorithm.getListOrdering(tagId);
}
// noinspection JSMethodCanBeStatic
private getStoredListOrder(tagId: TagID): ListAlgorithm {
// TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114
return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`);
}
// logic must match calculateTagSorting
private calculateListOrder(tagId: TagID): ListAlgorithm {
const defaultOrder = ListAlgorithm.Natural;
const settingImportance = SettingsStore.getValue("RoomList.orderByImportance", null, true);
const definedOrder = this.getListOrder(tagId);
const storedOrder = this.getStoredListOrder(tagId);
// We use the following order to determine which of the 4 flags to use:
// Stored > Settings > Defined > Default
let listOrder = defaultOrder;
if (storedOrder) {
listOrder = storedOrder;
} else if (!isNullOrUndefined(settingImportance)) {
listOrder = settingImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural;
} else if (definedOrder) {
listOrder = definedOrder;
} // else default (already set)
return listOrder;
}
private async updateAlgorithmInstances() {
// We'll require an update, so mark for one. Marking now also prevents the calls
// to setTagSorting and setListOrder from causing triggers.
this.updateFn.mark();
for (const tag of Object.keys(this.orderedLists)) {
const definedSort = this.getTagSorting(tag);
const definedOrder = this.getListOrder(tag);
const tagSort = this.calculateTagSorting(tag);
const listOrder = this.calculateListOrder(tag);
if (tagSort !== definedSort) {
await this.setAndPersistTagSorting(tag, tagSort);
}
if (listOrder !== definedOrder) {
await this.setAndPersistListOrder(tag, listOrder);
}
}
}
private onAlgorithmListUpdated = () => {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log("Underlying algorithm has triggered a list update - marking");
}
this.updateFn.mark();
};
private onAlgorithmFilterUpdated = () => {
// The filter can happen off-cycle, so trigger an update. The filter will have
// already caused a mark.
this.updateFn.trigger();
};
/**
* Regenerates the room whole room list, discarding any previous results.
*
* Note: This is only exposed externally for the tests. Do not call this from within
* the app.
* @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
public async regenerateAllLists({trigger = true}) {
console.warn("Regenerating all room lists");
const sorts: ITagSortingMap = {};
const orders: IListOrderingMap = {};
for (const tagId of OrderedDefaultTagIDs) {
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());
this.initialListsGenerated = true;
if (trigger) this.updateFn.trigger();
}
public addFilter(filter: IFilterCondition): void {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log("Adding filter condition:", filter);
}
this.filterConditions.push(filter);
if (this.algorithm) {
this.algorithm.addFilterCondition(filter);
}
this.updateFn.trigger();
}
public removeFilter(filter: IFilterCondition): void {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
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);
}
}
this.updateFn.trigger();
}
/**
* Gets the tags for a room identified by the store. The returned set
* should never be empty, and will contain DefaultTagID.Untagged if
* the store is not aware of any tags.
* @param room The room to get the tags for.
* @returns The tags for the room.
*/
public getTagsForRoom(room: Room): TagID[] {
const algorithmTags = this.algorithm.getTagsForRoom(room);
if (!algorithmTags) return [DefaultTagID.Untagged];
return algorithmTags;
}
}
export default class RoomListStore {
private static internalInstance: RoomListStoreClass;
public static get instance(): RoomListStoreClass {
if (!RoomListStore.internalInstance) {
RoomListStore.internalInstance = new RoomListStoreClass();
}
return RoomListStore.internalInstance;
}
}
window.mx_RoomListStore = RoomListStore.instance;

View file

@ -1,385 +0,0 @@
/*
Copyright 2018, 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.
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 { MatrixClient } from "matrix-js-sdk/src/client";
import SettingsStore from "../../settings/SettingsStore";
import { OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
import TagOrderStore from "../TagOrderStore";
import { AsyncStore } from "../AsyncStore";
import { Room } from "matrix-js-sdk/src/models/room";
import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
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";
import RoomViewStore from "../RoomViewStore";
import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
interface IState {
tagsEnabled?: boolean;
}
/**
* The event/channel which is called when the room lists have been changed. Raised
* with one argument: the instance of the store.
*/
export const LISTS_UPDATE_EVENT = "lists_update";
export class RoomListStore2 extends AsyncStore<ActionPayload> {
private _matrixClient: MatrixClient;
private initialListsGenerated = false;
private enabled = false;
private algorithm = new Algorithm();
private filterConditions: IFilterCondition[] = [];
private tagWatcher = new TagWatcher(this);
private readonly watchedSettings = [
'RoomList.orderAlphabetically',
'RoomList.orderByImportance',
'feature_custom_tags',
];
constructor() {
super(defaultDispatcher);
this.checkEnabled();
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
RoomViewStore.addListener(this.onRVSUpdate);
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
}
public get orderedLists(): ITagMap {
if (!this.algorithm) return {}; // No tags yet.
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");
if (this.enabled) {
console.log("⚡ new room list store engaged");
}
}
private async readAndCacheSettingsFromStore() {
const tagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
await this.updateState({
tagsEnabled,
});
await this.updateAlgorithmInstances();
}
private onRVSUpdate = () => {
if (!this.enabled) return; // TODO: Remove enabled flag when RoomListStore2 takes over
if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
const activeRoomId = RoomViewStore.getRoomId();
if (!activeRoomId && this.algorithm.stickyRoom) {
this.algorithm.stickyRoom = null;
} else if (activeRoomId) {
const activeRoom = this.matrixClient.getRoom(activeRoomId);
if (!activeRoom) throw new Error(`${activeRoomId} is current in RVS but missing from client`);
if (activeRoom !== this.algorithm.stickyRoom) {
console.log(`Changing sticky room to ${activeRoomId}`);
this.algorithm.stickyRoom = activeRoom;
}
}
};
protected async onDispatch(payload: ActionPayload) {
if (payload.action === 'MatrixActions.sync') {
// Filter out anything that isn't the first PREPARED sync.
if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
return;
}
// TODO: Remove this once the RoomListStore becomes default
this.checkEnabled();
if (!this.enabled) return;
this._matrixClient = payload.matrixClient;
// Update any settings here, as some may have happened before we were logically ready.
console.log("Regenerating room lists: Startup");
await this.readAndCacheSettingsFromStore();
await this.regenerateAllLists();
this.onRVSUpdate(); // fake an RVS update to adjust sticky room, if needed
}
// TODO: Remove this once the RoomListStore becomes default
if (!this.enabled) return;
if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
// 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.initialListsGenerated = false; // we'll want to regenerate them
}
// Everything below here requires a MatrixClient or some sort of logical readiness.
const logicallyReady = this.matrixClient && this.initialListsGenerated;
if (!logicallyReady) return;
if (payload.action === 'setting_updated') {
if (this.watchedSettings.includes(payload.settingName)) {
console.log("Regenerating room lists: Settings changed");
await this.readAndCacheSettingsFromStore();
await this.regenerateAllLists(); // regenerate the lists now
}
}
if (!this.algorithm) {
// This shouldn't happen because `initialListsGenerated` implies we have an algorithm.
throw new Error("Room list store has no algorithm to process dispatcher update with");
}
if (payload.action === 'MatrixActions.Room.receipt') {
// First see if the receipt event is for our own user. If it was, trigger
// a room update (we probably read the room on a different device).
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
const room = payload.room;
if (!room) {
console.warn(`Own read receipt was in unknown room ${room.roomId}`);
return;
}
console.log(`[RoomListDebug] Got own read receipt in ${room.roomId}`);
await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt);
return;
}
} else if (payload.action === 'MatrixActions.Room.tags') {
const roomPayload = (<any>payload); // TODO: Type out the dispatcher types
console.log(`[RoomListDebug] Got tag change in ${roomPayload.room.roomId}`);
await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange);
} else if (payload.action === 'MatrixActions.Room.timeline') {
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
// Ignore non-live events (backfill)
if (!eventPayload.isLiveEvent || !payload.isLiveUnfilteredRoomTimelineEvent) return;
const roomId = eventPayload.event.getRoomId();
const room = this.matrixClient.getRoom(roomId);
const tryUpdate = async (updatedRoom: Room) => {
console.log(
`[RoomListDebug] Live timeline event ${eventPayload.event.getId()} in ${updatedRoom.roomId}`,
);
if (eventPayload.event.getType() === 'm.room.tombstone' && eventPayload.event.getStateKey() === '') {
console.log(`[RoomListDebug] Got tombstone event - regenerating room list`);
// TODO: We could probably be smarter about this
await this.regenerateAllLists();
return; // don't pass the update down - we will have already handled it in the regen
}
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 = (<any>payload); // TODO: Type out the dispatcher types
const roomId = eventPayload.event.getRoomId();
const room = this.matrixClient.getRoom(roomId);
if (!room) {
console.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`);
return;
}
console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`);
// TODO: Check that e2e rooms are calculated correctly on initial load.
// It seems like when viewing the room the timeline is decrypted, rather than at startup. This could
// cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :(
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
} else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
console.log(`[RoomListDebug] Received updated DM map`);
const dmMap = eventPayload.event.getContent();
for (const userId of Object.keys(dmMap)) {
const roomIds = dmMap[userId];
for (const roomId of roomIds) {
const room = this.matrixClient.getRoom(roomId);
if (!room) {
console.warn(`${roomId} was found in DMs but the room is not in the store`);
continue;
}
// We expect this RoomUpdateCause to no-op if there's no change, and we don't expect
// the user to have hundreds of rooms to update in one event. As such, we just hammer
// away at updates until the problem is solved. If we were expecting more than a couple
// of rooms to be updated at once, we would consider batching the rooms up.
await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange);
}
}
} else if (payload.action === 'MatrixActions.Room.myMembership') {
const membershipPayload = (<any>payload); // TODO: Type out the dispatcher types
if (membershipPayload.oldMembership !== "join" && membershipPayload.membership === "join") {
console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`);
await this.algorithm.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
return;
}
// If it's not a join, it's transitioning into a different list (possibly historical)
if (membershipPayload.oldMembership !== membershipPayload.membership) {
console.log(`[RoomListDebug] Handling membership change in ${membershipPayload.room.roomId}`);
await this.algorithm.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange);
return;
}
}
}
private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> {
const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause);
if (shouldUpdate) {
console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`);
this.emit(LISTS_UPDATE_EVENT, this);
}
}
public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
await this.algorithm.setTagSorting(tagId, sort);
localStorage.setItem(`mx_tagSort_${tagId}`, sort);
}
public getTagSorting(tagId: TagID): SortAlgorithm {
return this.algorithm.getTagSorting(tagId);
}
// noinspection JSMethodCanBeStatic
private getStoredTagSorting(tagId: TagID): SortAlgorithm {
return <SortAlgorithm>localStorage.getItem(`mx_tagSort_${tagId}`);
}
public async setListOrder(tagId: TagID, order: ListAlgorithm) {
await this.algorithm.setListOrdering(tagId, order);
localStorage.setItem(`mx_listOrder_${tagId}`, order);
}
public getListOrder(tagId: TagID): ListAlgorithm {
return this.algorithm.getListOrdering(tagId);
}
// noinspection JSMethodCanBeStatic
private getStoredListOrder(tagId: TagID): ListAlgorithm {
return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`);
}
private async updateAlgorithmInstances() {
const orderByImportance = SettingsStore.getValue("RoomList.orderByImportance");
const orderAlphabetically = SettingsStore.getValue("RoomList.orderAlphabetically");
const defaultSort = orderAlphabetically ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent;
const defaultOrder = orderByImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural;
for (const tag of Object.keys(this.orderedLists)) {
const definedSort = this.getTagSorting(tag);
const definedOrder = this.getListOrder(tag);
const storedSort = this.getStoredTagSorting(tag);
const storedOrder = this.getStoredListOrder(tag);
const tagSort = storedSort ? storedSort : (definedSort ? definedSort : defaultSort);
const listOrder = storedOrder ? storedOrder : (definedOrder ? definedOrder : defaultOrder);
if (tagSort !== definedSort) {
await this.setTagSorting(tag, tagSort);
}
if (listOrder !== definedOrder) {
await this.setListOrder(tag, listOrder);
}
}
}
protected async updateState(newState: IState) {
if (!this.enabled) return;
await super.updateState(newState);
}
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 sorts: ITagSortingMap = {};
const orders: IListOrderingMap = {};
for (const tagId of OrderedDefaultTagIDs) {
sorts[tagId] = this.getStoredTagSorting(tagId) || SortAlgorithm.Alphabetic;
orders[tagId] = this.getStoredListOrder(tagId) || ListAlgorithm.Natural;
}
if (this.state.tagsEnabled) {
// TODO: Find a more reliable way to get tags (this doesn't work)
const roomTags = TagOrderStore.getOrderedTags() || [];
console.log("rtags", roomTags);
}
await this.algorithm.populateTags(sorts, orders);
await this.algorithm.setKnownRooms(this.matrixClient.getVisibleRooms());
this.initialListsGenerated = true;
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: RoomListStore2;
public static get instance(): RoomListStore2 {
if (!RoomListStore.internalInstance) {
RoomListStore.internalInstance = new RoomListStore2();
}
return RoomListStore.internalInstance;
}
}
window.mxRoomListStore2 = RoomListStore.instance;

View file

@ -1,61 +0,0 @@
/*
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 SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "./RoomListStore2";
import OldRoomListStore from "../RoomListStore";
import { UPDATE_EVENT } from "../AsyncStore";
import { ITagMap } from "./algorithms/models";
/**
* Temporary RoomListStore proxy. Should be replaced with RoomListStore2 when
* it is available to everyone.
*
* TODO: Remove this when RoomListStore gets fully replaced.
*/
export class RoomListStoreTempProxy {
public static isUsingNewStore(): boolean {
return SettingsStore.isFeatureEnabled("feature_new_room_list");
}
public static addListener(handler: () => void): RoomListStoreTempToken {
if (RoomListStoreTempProxy.isUsingNewStore()) {
const offFn = () => RoomListStore.instance.off(UPDATE_EVENT, handler);
RoomListStore.instance.on(UPDATE_EVENT, handler);
return new RoomListStoreTempToken(offFn);
} else {
const token = OldRoomListStore.addListener(handler);
return new RoomListStoreTempToken(() => token.remove());
}
}
public static getRoomLists(): ITagMap {
if (RoomListStoreTempProxy.isUsingNewStore()) {
return RoomListStore.instance.orderedLists;
} else {
return OldRoomListStore.getRoomLists();
}
}
}
export class RoomListStoreTempToken {
constructor(private offFn: () => void) {
}
public remove(): void {
this.offFn();
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { RoomListStore2 } from "./RoomListStore2";
import { RoomListStoreClass } from "./RoomListStore";
import TagOrderStore from "../TagOrderStore";
import { CommunityFilterCondition } from "./filters/CommunityFilterCondition";
import { arrayDiff, arrayHasDiff } from "../../utils/arrays";
@ -23,10 +23,10 @@ import { arrayDiff, arrayHasDiff } 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)
// TODO: Support custom tags, somehow: https://github.com/vector-im/riot-web/issues/14091
private filters = new Map<string, CommunityFilterCondition>();
constructor(private store: RoomListStore2) {
constructor(private store: RoomListStoreClass) {
TagOrderStore.addListener(this.onTagsUpdated);
}
@ -44,7 +44,7 @@ export class TagWatcher {
const newFilters = new Map<string, CommunityFilterCondition>();
// TODO: Support custom tags properly
// 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) {
@ -61,6 +61,7 @@ export class TagWatcher {
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;

View file

@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import DMRoomMap from "../../../utils/DMRoomMap";
import { EventEmitter } from "events";
import { arrayHasDiff, ArrayUtil } from "../../../utils/arrays";
import { arrayDiff, arrayHasDiff, ArrayUtil } from "../../../utils/arrays";
import { getEnumValues } from "../../../utils/enums";
import { DefaultTagID, RoomUpdateCause, TagID } from "../models";
import {
@ -30,17 +30,27 @@ import {
SortAlgorithm,
} from "./models";
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "../filters/IFilterCondition";
import { EffectiveMembership, splitRoomsByMembership } from "../membership";
import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } from "../../../utils/membership";
import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
import { getListAlgorithmInstance } from "./list-ordering";
// TODO: Add locking support to avoid concurrent writes?
import SettingsStore from "../../../settings/SettingsStore";
/**
* Fired when the Algorithm has determined a list has been updated.
*/
export const LIST_UPDATED_EVENT = "list_updated_event";
// These are the causes which require a room to be known in order for us to handle them. If
// a cause in this list is raised and we don't know about the room, we don't handle the update.
//
// Note: these typically happen when a new room is coming in, such as the user creating or
// joining the room. For these cases, we need to know about the room prior to handling it otherwise
// we'll make bad assumptions.
const CAUSES_REQUIRING_ROOM = [
RoomUpdateCause.Timeline,
RoomUpdateCause.ReadReceipt,
];
interface IStickyRoom {
room: Room;
position: number;
@ -57,6 +67,7 @@ export class Algorithm extends EventEmitter {
private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room
private filteredRooms: ITagMap = {};
private _stickyRoom: IStickyRoom = null;
private _lastStickyRoom: IStickyRoom = null; // only not-null when changing the sticky room
private sortAlgorithms: ITagSortingMap;
private listAlgorithms: IListOrderingMap;
private algorithms: IOrderingAlgorithmMap;
@ -75,12 +86,6 @@ export class Algorithm extends EventEmitter {
return this._stickyRoom ? this._stickyRoom.room : null;
}
public set stickyRoom(val: Room) {
// setters can't be async, so we call a private function to do the work
// noinspection JSIgnoredPromiseFromCall
this.updateStickyRoom(val);
}
protected get hasFilters(): boolean {
return this.allowedByFilter.size > 0;
}
@ -99,7 +104,16 @@ export class Algorithm extends EventEmitter {
return this._cachedRooms;
}
/**
* Awaitable version of the sticky room setter.
* @param val The new room to sticky.
*/
public async setStickyRoom(val: Room) {
await this.updateStickyRoom(val);
}
public getTagSorting(tagId: TagID): SortAlgorithm {
if (!this.sortAlgorithms) return null;
return this.sortAlgorithms[tagId];
}
@ -116,6 +130,7 @@ export class Algorithm extends EventEmitter {
}
public getListOrdering(tagId: TagID): ListAlgorithm {
if (!this.listAlgorithms) return null;
return this.listAlgorithms[tagId];
}
@ -137,13 +152,14 @@ export class Algorithm extends EventEmitter {
// 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));
filterCondition.on(FILTER_CHANGED, this.handleFilterChange.bind(this));
}
public removeFilterCondition(filterCondition: IFilterCondition): void {
filterCondition.off(FILTER_CHANGED, this.recalculateFilteredRooms.bind(this));
filterCondition.off(FILTER_CHANGED, this.handleFilterChange.bind(this));
if (this.allowedByFilter.has(filterCondition)) {
this.allowedByFilter.delete(filterCondition);
this.recalculateFilteredRooms();
// 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.
@ -153,22 +169,44 @@ export class Algorithm extends EventEmitter {
}
}
private async handleFilterChange() {
await this.recalculateFilteredRooms();
// re-emit the update so the list store can fire an off-cycle update if needed
this.emit(FILTER_CHANGED);
}
private async updateStickyRoom(val: Room) {
try {
return await this.doUpdateStickyRoom(val);
} finally {
this._lastStickyRoom = null; // clear to indicate we're done changing
}
}
private async doUpdateStickyRoom(val: Room) {
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
// otherwise we risk duplicating rooms.
// Set the last sticky room to indicate that we're in a change. The code throughout the
// class can safely handle a null room, so this should be safe to do as a backup.
this._lastStickyRoom = this._stickyRoom || <IStickyRoom>{};
// It's possible to have no selected room. In that case, clear the sticky room
if (!val) {
if (this._stickyRoom) {
const stickyRoom = this._stickyRoom.room;
this._stickyRoom = null; // clear before we go to update the algorithm
// Lie to the algorithm and re-add the room to the algorithm
await this.handleRoomUpdate(this._stickyRoom.room, RoomUpdateCause.NewRoom);
await this.handleRoomUpdate(stickyRoom, RoomUpdateCause.NewRoom);
return;
}
this._stickyRoom = null;
return;
}
// When we do have a room though, we expect to be able to find it
const tag = this.roomIdsToTags[val.roomId][0];
let tag = this.roomIdsToTags[val.roomId][0];
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
// We specifically do NOT use the ordered rooms set as it contains the sticky room, which
@ -185,20 +223,41 @@ export class Algorithm extends EventEmitter {
// the same thing it no-ops. After we're done calling the algorithm, we'll issue
// a new update for ourselves.
const lastStickyRoom = this._stickyRoom;
console.log(`Last sticky room:`, lastStickyRoom);
this._stickyRoom = null;
this._stickyRoom = null; // clear before we update the algorithm
this.recalculateStickyRoom();
// When we do have the room, re-add the old room (if needed) to the algorithm
// and remove the sticky room from the algorithm. This is so the underlying
// algorithm doesn't try and confuse itself with the sticky room concept.
if (lastStickyRoom) {
// We don't add the new room if the sticky room isn't changing because that's
// an easy way to cause duplication. We have to do room ID checks instead of
// referential checks as the references can differ through the lifecycle.
if (lastStickyRoom && lastStickyRoom.room && lastStickyRoom.room.roomId !== val.roomId) {
// Lie to the algorithm and re-add the room to the algorithm
await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
}
// Lie to the algorithm and remove the room from it's field of view
await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
// Check for tag & position changes while we're here. We also check the room to ensure
// it is still the same room.
if (this._stickyRoom) {
if (this._stickyRoom.room !== val) {
// Check the room IDs just in case
if (this._stickyRoom.room.roomId === val.roomId) {
console.warn("Sticky room changed references");
} else {
throw new Error("Sticky room changed while the sticky room was changing");
}
}
console.warn(`Sticky room changed tag & position from ${tag} / ${position} `
+ `to ${this._stickyRoom.tag} / ${this._stickyRoom.position}`);
tag = this._stickyRoom.tag;
position = this._stickyRoom.position;
}
// Now that we're done lying to the algorithm, we need to update our position
// marker only if the user is moving further down the same list. If they're switching
// lists, or moving upwards, the position marker will splice in just fine but if
@ -262,7 +321,11 @@ export class Algorithm extends EventEmitter {
}
}
newMap[tagId] = allowedRoomsInThisTag;
console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`);
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`);
}
}
const allowedRooms = Object.values(newMap).reduce((rv, v) => { rv.push(...v); return rv; }, <Room[]>[]);
@ -271,24 +334,13 @@ export class Algorithm extends EventEmitter {
this.emit(LIST_UPDATED_EVENT);
}
// TODO: Remove or use.
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}`);
if (!this.hasFilters) return; // don't bother doing work if there's nothing to do
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`Recalculating filtered rooms for ${tagId}`);
}
delete this.filteredRooms[tagId];
const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone
this.tryInsertStickyRoomToFilterSet(rooms, tagId);
@ -296,7 +348,11 @@ export class Algorithm extends EventEmitter {
if (filteredRooms.length > 0) {
this.filteredRooms[tagId] = filteredRooms;
}
console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`);
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`);
}
}
protected tryInsertStickyRoomToFilterSet(rooms: Room[], tagId: TagID) {
@ -335,7 +391,10 @@ export class Algorithm extends EventEmitter {
}
if (!this._cachedStickyRooms || !updatedTag) {
console.log(`Generating clone of cached rooms for sticky room handling`);
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`Generating clone of cached rooms for sticky room handling`);
}
const stickiedTagMap: ITagMap = {};
for (const tagId of Object.keys(this.cachedRooms)) {
stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone
@ -346,7 +405,10 @@ export class Algorithm extends EventEmitter {
if (updatedTag) {
// Update the tag indicated by the caller, if possible. This is mostly to ensure
// our cache is up to date.
console.log(`Replacing cached sticky rooms for ${updatedTag}`);
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`Replacing cached sticky rooms for ${updatedTag}`);
}
this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone
}
@ -355,7 +417,10 @@ export class Algorithm extends EventEmitter {
// we might have updated from the cache is also our sticky room.
const sticky = this._stickyRoom;
if (!updatedTag || updatedTag === sticky.tag) {
console.log(`Inserting sticky room ${sticky.room.roomId} at position ${sticky.position} in ${sticky.tag}`);
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`Inserting sticky room ${sticky.room.roomId} at position ${sticky.position} in ${sticky.tag}`);
}
this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room);
}
@ -422,6 +487,13 @@ export class Algorithm extends EventEmitter {
if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`);
if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`);
console.warn("Resetting known rooms, initiating regeneration");
// Before we go any further we need to clear (but remember) the sticky room to
// avoid accidentally duplicating it in the list.
const oldStickyRoom = this._stickyRoom;
await this.updateStickyRoom(null);
this.rooms = rooms;
const newTags: ITagMap = {};
@ -440,31 +512,20 @@ export class Algorithm extends EventEmitter {
// Split out the easy rooms first (leave and invite)
const memberships = splitRoomsByMembership(rooms);
for (const room of memberships[EffectiveMembership.Invite]) {
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is an Invite`);
newTags[DefaultTagID.Invite].push(room);
}
for (const room of memberships[EffectiveMembership.Leave]) {
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Historical`);
newTags[DefaultTagID.Archived].push(room);
}
// Now process all the joined rooms. This is a bit more complicated
for (const room of memberships[EffectiveMembership.Join]) {
let tags = Object.keys(room.tags || {});
if (tags.length === 0) {
// Check to see if it's a DM if it isn't anything else
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
tags = [DefaultTagID.DM];
}
}
const tags = this.getTagsOfJoinedRoom(room);
let inTag = false;
if (tags.length > 0) {
for (const tag of tags) {
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged as ${tag}`);
if (!isNullOrUndefined(newTags[tag])) {
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged with VALID tag ${tag}`);
newTags[tag].push(room);
inTag = true;
}
@ -472,9 +533,11 @@ export class Algorithm extends EventEmitter {
}
if (!inTag) {
// TODO: Determine if DM and push there instead
newTags[DefaultTagID.Untagged].push(room);
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`);
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
newTags[DefaultTagID.DM].push(room);
} else {
newTags[DefaultTagID.Untagged].push(room);
}
}
}
@ -482,12 +545,60 @@ export class Algorithm extends EventEmitter {
this.cachedRooms = newTags;
this.updateTagsFromCache();
this.recalculateFilteredRooms();
// Now that we've finished generation, we need to update the sticky room to what
// it was. It's entirely possible that it changed lists though, so if it did then
// we also have to update the position of it.
if (oldStickyRoom && oldStickyRoom.room) {
await this.updateStickyRoom(oldStickyRoom.room);
if (this._stickyRoom && this._stickyRoom.room) { // just in case the update doesn't go according to plan
if (this._stickyRoom.tag !== oldStickyRoom.tag) {
// We put the sticky room at the top of the list to treat it as an obvious tag change.
this._stickyRoom.position = 0;
this.recalculateStickyRoom(this._stickyRoom.tag);
}
}
}
}
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());
if (membership === EffectiveMembership.Invite) {
tags.push(DefaultTagID.Invite);
} else if (membership === EffectiveMembership.Leave) {
tags.push(DefaultTagID.Archived);
} else {
tags.push(...this.getTagsOfJoinedRoom(room));
}
if (!tags.length) tags.push(DefaultTagID.Untagged);
return tags;
}
private getTagsOfJoinedRoom(room: Room): TagID[] {
let tags = Object.keys(room.tags || {});
if (tags.length === 0) {
// Check to see if it's a DM if it isn't anything else
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
tags = [DefaultTagID.DM];
}
}
return tags;
}
/**
* Updates the roomsToTags map
*/
protected updateTagsFromCache() {
private updateTagsFromCache() {
const newMap = {};
const tags = Object.keys(this.cachedRooms);
@ -534,13 +645,118 @@ export class Algorithm extends EventEmitter {
* processing.
*/
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`Handle room update for ${room.roomId} called with cause ${cause}`);
}
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
// Note: check the isSticky against the room ID just in case the reference is wrong
const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId;
if (cause === RoomUpdateCause.NewRoom) {
const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room;
const roomTags = this.roomIdsToTags[room.roomId];
const hasTags = roomTags && roomTags.length > 0;
// Don't change the cause if the last sticky room is being re-added. If we fail to
// pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
// lose the room.
if (hasTags && !isForLastSticky) {
console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
cause = RoomUpdateCause.PossibleTagChange;
}
// Check to see if the room is known first
let knownRoomRef = this.rooms.includes(room);
if (hasTags && !knownRoomRef) {
console.warn(`${room.roomId} might be a reference change - attempting to update reference`);
this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r);
knownRoomRef = this.rooms.includes(room);
if (!knownRoomRef) {
console.warn(`${room.roomId} is still not referenced. It may be sticky.`);
}
}
// If we have tags for a room and don't have the room referenced, something went horribly
// wrong - the reference should have been updated above.
if (hasTags && !knownRoomRef && !isSticky) {
throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
}
// Like above, update the reference to the sticky room if we need to
if (hasTags && isSticky) {
// Go directly in and set the sticky room's new reference, being careful not
// to trigger a sticky room update ourselves.
this._stickyRoom.room = room;
}
// If after all that we're still a NewRoom update, add the room if applicable.
// We don't do this for the sticky room (because it causes duplication issues)
// or if we know about the reference (as it should be replaced).
if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) {
this.rooms.push(room);
}
}
let didTagChange = false;
if (cause === RoomUpdateCause.PossibleTagChange) {
// TODO: Be smarter and splice rather than regen the planet.
// TODO: No-op if no change.
await this.setKnownRooms(this.rooms);
return true;
const oldTags = this.roomIdsToTags[room.roomId] || [];
const newTags = this.getTagsForRoom(room);
const diff = arrayDiff(oldTags, newTags);
if (diff.removed.length > 0 || diff.added.length > 0) {
for (const rmTag of diff.removed) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`Removing ${room.roomId} from ${rmTag}`);
}
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
this.cachedRooms[rmTag] = algorithm.orderedRooms;
}
for (const addTag of diff.added) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`Adding ${room.roomId} to ${addTag}`);
}
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
this.cachedRooms[addTag] = algorithm.orderedRooms;
}
// Update the tag map so we don't regen it in a moment
this.roomIdsToTags[room.roomId] = newTags;
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`);
}
cause = RoomUpdateCause.Timeline;
didTagChange = true;
} else {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`);
}
cause = RoomUpdateCause.Timeline;
}
if (didTagChange && isSticky) {
// Manually update the tag for the sticky room without triggering a sticky room
// update. The update will be handled implicitly by the sticky room handling and
// requires no changes on our part, if we're in the middle of a sticky room change.
if (this._lastStickyRoom) {
this._stickyRoom = {
room,
tag: this.roomIdsToTags[room.roomId][0],
position: 0, // right at the top as it changed tags
};
} else {
// We have to clear the lock as the sticky room change will trigger updates.
await this.setStickyRoom(room);
}
}
}
// If the update is for a room change which might be the sticky room, prevent it. We
@ -548,17 +764,55 @@ export class Algorithm extends EventEmitter {
// as the sticky room relies on this.
if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) {
if (this.stickyRoom === room) {
console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`);
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`);
}
return false;
}
}
if (!this.roomIdsToTags[room.roomId]) {
if (CAUSES_REQUIRING_ROOM.includes(cause)) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`);
}
return false;
}
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`);
}
// Get the tags for the room and populate the cache
const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t]));
// "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(),
// which means we should *always* have a tag to go off of.
if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`);
this.roomIdsToTags[room.roomId] = roomTags;
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags);
}
}
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`);
}
const tags = this.roomIdsToTags[room.roomId];
if (!tags) {
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false;
}
let changed = didTagChange;
for (const tag of tags) {
const algorithm: OrderingAlgorithm = this.algorithms[tag];
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
@ -571,6 +825,10 @@ export class Algorithm extends EventEmitter {
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
}
return true;
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14602
console.log(`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`);
}
return changed;
}
}

View file

@ -19,47 +19,29 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { RoomUpdateCause, TagID } from "../../models";
import { SortAlgorithm } from "../models";
import { sortRoomsWithAlgorithm } from "../tag-sorting";
import * as Unread from '../../../../Unread';
import { OrderingAlgorithm } from "./OrderingAlgorithm";
/**
* The determined category of a room.
*/
export enum Category {
/**
* The room has unread mentions within.
*/
Red = "RED",
/**
* The room has unread notifications within. Note that these are not unread
* mentions - they are simply messages which the user has asked to cause a
* badge count update or push notification.
*/
Grey = "GREY",
/**
* The room has unread messages within (grey without the badge).
*/
Bold = "BOLD",
/**
* The room has no relevant unread messages within.
*/
Idle = "IDLE",
}
import { NotificationColor } from "../../../notifications/NotificationColor";
import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore";
interface ICategorizedRoomMap {
// @ts-ignore - TS wants this to be a string, but we know better
[category: Category]: Room[];
[category: NotificationColor]: Room[];
}
interface ICategoryIndex {
// @ts-ignore - TS wants this to be a string, but we know better
[category: Category]: number; // integer
[category: NotificationColor]: number; // integer
}
// Caution: changing this means you'll need to update a bunch of assumptions and
// comments! Check the usage of Category carefully to figure out what needs changing
// if you're going to change this array's order.
const CATEGORY_ORDER = [Category.Red, Category.Grey, Category.Bold, Category.Idle];
const CATEGORY_ORDER = [
NotificationColor.Red,
NotificationColor.Grey,
NotificationColor.Bold,
NotificationColor.None, // idle
];
/**
* An implementation of the "importance" algorithm for room list sorting. Where
@ -87,16 +69,15 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm);
console.log(`[RoomListDebug] Constructed an ImportanceAlgorithm for ${tagId}`);
}
// noinspection JSMethodCanBeStatic
private categorizeRooms(rooms: Room[]): ICategorizedRoomMap {
const map: ICategorizedRoomMap = {
[Category.Red]: [],
[Category.Grey]: [],
[Category.Bold]: [],
[Category.Idle]: [],
[NotificationColor.Red]: [],
[NotificationColor.Grey]: [],
[NotificationColor.Bold]: [],
[NotificationColor.None]: [],
};
for (const room of rooms) {
const category = this.getRoomCategory(room);
@ -106,25 +87,11 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
}
// noinspection JSMethodCanBeStatic
private getRoomCategory(room: Room): Category {
// Function implementation borrowed from old RoomListStore
const mentions = room.getUnreadNotificationCount('highlight') > 0;
if (mentions) {
return Category.Red;
}
let unread = room.getUnreadNotificationCount() > 0;
if (unread) {
return Category.Grey;
}
unread = Unread.doesRoomHaveUnreadMessages(room);
if (unread) {
return Category.Bold;
}
return Category.Idle;
private getRoomCategory(room: Room): NotificationColor {
// It's fine for us to call this a lot because it's cached, and we shouldn't be
// wasting anything by doing so as the store holds single references
const state = RoomNotificationStateStore.instance.getRoomState(room, this.tagId);
return state.color;
}
public async setRooms(rooms: Room[]): Promise<any> {
@ -158,7 +125,10 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
} else if (cause === RoomUpdateCause.RoomRemoved) {
const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) return false; // no change
if (roomIdx === -1) {
console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
return false; // no change
}
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
this.alterCategoryPositionBy(oldCategory, -1, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // remove the room
@ -167,58 +137,55 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
}
}
private getRoomIndex(room: Room): number {
let roomIdx = this.cachedOrderedRooms.indexOf(room);
if (roomIdx === -1) { // can only happen if the js-sdk's store goes sideways.
console.warn(`Degrading performance to find missing room in "${this.tagId}": ${room.roomId}`);
roomIdx = this.cachedOrderedRooms.findIndex(r => r.roomId === room.roomId);
}
return roomIdx;
}
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
if (cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved) {
return this.handleSplice(room, cause);
try {
await this.updateLock.acquireAsync();
if (cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved) {
return this.handleSplice(room, cause);
}
if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) {
throw new Error(`Unsupported update cause: ${cause}`);
}
const category = this.getRoomCategory(room);
if (this.sortingAlgorithm === SortAlgorithm.Manual) {
return; // Nothing to do here.
}
const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) {
throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`);
}
// Try to avoid doing array operations if we don't have to: only move rooms within
// the categories if we're jumping categories
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
if (oldCategory !== category) {
// Move the room and update the indices
this.moveRoomIndexes(1, oldCategory, category, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
// Note: if moveRoomIndexes() is called after the splice then the insert operation
// will happen in the wrong place. Because we would have already adjusted the index
// for the category, we don't need to determine how the room is moving in the list.
// If we instead tried to insert before updating the indices, we'd have to determine
// whether the room was moving later (towards IDLE) or earlier (towards RED) from its
// current position, as it'll affect the category's start index after we remove the
// room from the array.
}
// Sort the category now that we've dumped the room in
await this.sortCategory(category);
return true; // change made
} finally {
await this.updateLock.release();
}
if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) {
throw new Error(`Unsupported update cause: ${cause}`);
}
const category = this.getRoomCategory(room);
if (this.sortingAlgorithm === SortAlgorithm.Manual) {
return; // Nothing to do here.
}
const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) {
throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`);
}
// Try to avoid doing array operations if we don't have to: only move rooms within
// the categories if we're jumping categories
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
if (oldCategory !== category) {
// Move the room and update the indices
this.moveRoomIndexes(1, oldCategory, category, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
// Note: if moveRoomIndexes() is called after the splice then the insert operation
// will happen in the wrong place. Because we would have already adjusted the index
// for the category, we don't need to determine how the room is moving in the list.
// If we instead tried to insert before updating the indices, we'd have to determine
// whether the room was moving later (towards IDLE) or earlier (towards RED) from its
// current position, as it'll affect the category's start index after we remove the
// room from the array.
}
// Sort the category now that we've dumped the room in
await this.sortCategory(category);
return true; // change made
}
private async sortCategory(category: Category) {
private async sortCategory(category: NotificationColor) {
// This should be relatively quick because the room is usually inserted at the top of the
// category, and most popular sorting algorithms will deal with trying to keep the active
// room at the top/start of the category. For the few algorithms that will have to move the
@ -235,7 +202,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
}
// noinspection JSMethodCanBeStatic
private getCategoryFromIndices(index: number, indices: ICategoryIndex): Category {
private getCategoryFromIndices(index: number, indices: ICategoryIndex): NotificationColor {
for (let i = 0; i < CATEGORY_ORDER.length; i++) {
const category = CATEGORY_ORDER[i];
const isLast = i === (CATEGORY_ORDER.length - 1);
@ -251,7 +218,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
}
// noinspection JSMethodCanBeStatic
private moveRoomIndexes(nRooms: number, fromCategory: Category, toCategory: Category, indices: ICategoryIndex) {
private moveRoomIndexes(nRooms: number, fromCategory: NotificationColor, toCategory: NotificationColor, indices: ICategoryIndex) {
// We have to update the index of the category *after* the from/toCategory variables
// in order to update the indices correctly. Because the room is moving from/to those
// categories, the next category's index will change - not the category we're modifying.
@ -262,7 +229,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
this.alterCategoryPositionBy(toCategory, +nRooms, indices);
}
private alterCategoryPositionBy(category: Category, n: number, indices: ICategoryIndex) {
private alterCategoryPositionBy(category: NotificationColor, n: number, indices: ICategoryIndex) {
// Note: when we alter a category's index, we actually have to modify the ones following
// the target and not the target itself.
@ -293,7 +260,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
// eslint-disable-next-line max-len
console.warn(`!! Room list index corruption: ${lastCat} (i:${indices[lastCat]}) is greater than ${thisCat} (i:${indices[thisCat]}) - category indices are likely desynced from reality`);
// TODO: Regenerate index when this happens
// TODO: Regenerate index when this happens: https://github.com/vector-im/riot-web/issues/14234
}
}
}

View file

@ -27,7 +27,6 @@ import { Room } from "matrix-js-sdk/src/models/room";
export class NaturalAlgorithm extends OrderingAlgorithm {
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm);
console.log(`[RoomListDebug] Constructed a NaturalAlgorithm for ${tagId}`);
}
public async setRooms(rooms: Room[]): Promise<any> {
@ -35,27 +34,33 @@ export class NaturalAlgorithm extends OrderingAlgorithm {
}
public async handleRoomUpdate(room, cause): Promise<boolean> {
const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved;
const isInPlace = cause === RoomUpdateCause.Timeline || cause === RoomUpdateCause.ReadReceipt;
if (!isSplice && !isInPlace) {
throw new Error(`Unsupported update cause: ${cause}`);
try {
await this.updateLock.acquireAsync();
const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved;
const isInPlace = cause === RoomUpdateCause.Timeline || cause === RoomUpdateCause.ReadReceipt;
if (!isSplice && !isInPlace) {
throw new Error(`Unsupported update cause: ${cause}`);
}
if (cause === RoomUpdateCause.NewRoom) {
this.cachedOrderedRooms.push(room);
} else if (cause === RoomUpdateCause.RoomRemoved) {
const idx = this.getRoomIndex(room);
if (idx >= 0) {
this.cachedOrderedRooms.splice(idx, 1);
} else {
console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
}
}
// TODO: Optimize this to avoid useless operations: https://github.com/vector-im/riot-web/issues/14457
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
this.cachedOrderedRooms = await sortRoomsWithAlgorithm(this.cachedOrderedRooms, this.tagId, this.sortingAlgorithm);
return true;
} finally {
await this.updateLock.release();
}
if (cause === RoomUpdateCause.NewRoom) {
this.cachedOrderedRooms.push(room);
} else if (cause === RoomUpdateCause.RoomRemoved) {
const idx = this.cachedOrderedRooms.indexOf(room);
if (idx >= 0) this.cachedOrderedRooms.splice(idx, 1);
}
// TODO: Optimize this to avoid useless operations
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
this.cachedOrderedRooms = await sortRoomsWithAlgorithm(
this.cachedOrderedRooms,
this.tagId,
this.sortingAlgorithm,
);
return true;
}
}

View file

@ -17,6 +17,7 @@ limitations under the License.
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomUpdateCause, TagID } from "../../models";
import { SortAlgorithm } from "../models";
import AwaitLock from "await-lock";
/**
* Represents a list ordering algorithm. Subclasses should populate the
@ -25,6 +26,7 @@ import { SortAlgorithm } from "../models";
export abstract class OrderingAlgorithm {
protected cachedOrderedRooms: Room[];
protected sortingAlgorithm: SortAlgorithm;
protected readonly updateLock = new AwaitLock();
protected constructor(protected tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
// noinspection JSIgnoredPromiseFromCall
@ -68,4 +70,13 @@ export abstract class OrderingAlgorithm {
* @returns True if the update requires the Algorithm to update the presentation layers.
*/
public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean>;
protected getRoomIndex(room: Room): number {
let roomIdx = this.cachedOrderedRooms.indexOf(room);
if (roomIdx === -1) { // can only happen if the js-sdk's store goes sideways.
console.warn(`Degrading performance to find missing room in "${this.tagId}": ${room.roomId}`);
roomIdx = this.cachedOrderedRooms.findIndex(r => r.roomId === room.roomId);
}
return roomIdx;
}
}

View file

@ -19,6 +19,7 @@ import { TagID } from "../../models";
import { IAlgorithm } from "./IAlgorithm";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import * as Unread from "../../../../Unread";
import { EffectiveMembership, getEffectiveMembership } from "../../../../utils/membership";
/**
* Sorts rooms according to the last event's timestamp in each room that seems
@ -32,10 +33,18 @@ export class RecentAlgorithm implements IAlgorithm {
// of the rooms to each other.
// TODO: We could probably improve the sorting algorithm here by finding changes.
// See https://github.com/vector-im/riot-web/issues/14459
// For example, if we spent a little bit of time to determine which elements have
// actually changed (probably needs to be done higher up?) then we could do an
// insertion sort or similar on the limited set of changes.
// TODO: Don't assume we're using the same client as the peg
// See https://github.com/vector-im/riot-web/issues/14458
let myUserId = '';
if (MatrixClientPeg.get()) {
myUserId = MatrixClientPeg.get().getUserId();
}
const tsCache: { [roomId: string]: number } = {};
const getLastTs = (r: Room) => {
if (tsCache[r.roomId]) {
@ -49,13 +58,22 @@ export class RecentAlgorithm implements IAlgorithm {
return Number.MAX_SAFE_INTEGER;
}
// If the room hasn't been joined yet, it probably won't have a timeline to
// parse. We'll still fall back to the timeline if this fails, but chances
// are we'll at least have our own membership event to go off of.
const effectiveMembership = getEffectiveMembership(r.getMyMembership());
if (effectiveMembership !== EffectiveMembership.Join) {
const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId);
if (membershipEvent && !Array.isArray(membershipEvent)) {
return membershipEvent.getTs();
}
}
for (let i = r.timeline.length - 1; i >= 0; --i) {
const ev = r.timeline[i];
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
// TODO: Don't assume we're using the same client as the peg
if (ev.getSender() === MatrixClientPeg.get().getUserId()
|| Unread.eventTriggersUnreadCount(ev)) {
if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) {
return ev.getTs();
}
}

View file

@ -21,6 +21,7 @@ import { EventEmitter } from "events";
import GroupStore from "../../GroupStore";
import { arrayHasDiff } from "../../../utils/arrays";
import { IDestroyable } from "../../../utils/IDestroyable";
import DMRoomMap from "../../../utils/DMRoomMap";
/**
* A filter condition for the room list which reveals rooms which
@ -28,6 +29,7 @@ import { IDestroyable } from "../../../utils/IDestroyable";
*/
export class CommunityFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable {
private roomIds: string[] = [];
private userIds: string[] = [];
constructor(private community: Group) {
super();
@ -43,16 +45,19 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
}
public isVisible(room: Room): boolean {
return this.roomIds.includes(room.roomId);
return this.roomIds.includes(room.roomId) ||
this.userIds.includes(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
}
private onStoreUpdate = async (): Promise<any> => {
// We don't actually know if the room list changed for the community, so just
// check it again.
// 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);
const beforeUserIds = this.userIds;
this.userIds = (await GroupStore.getGroupMembers(this.community.groupId)).map(u => u.userId);
if (arrayHasDiff(beforeRoomIds, this.roomIds) || arrayHasDiff(beforeUserIds, this.userIds)) {
this.emit(FILTER_CHANGED);
}
};

View file

@ -41,7 +41,6 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
public set search(val: string) {
this._search = val;
console.log("Updating filter for room name search:", this._search);
this.emit(FILTER_CHANGED);
}
@ -57,13 +56,17 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
}
}
if (!room.name) return false; // should realisitically not happen: the js-sdk always calculates a name
if (!room.name) return false; // should realistically not happen: the js-sdk always calculates a name
return this.matches(room.name);
}
public matches(val: string): boolean {
// Note: we have to match the filter with the removeHiddenChars() room name because the
// function strips spaces and other characters (M becomes RN for example, in lowercase).
// We also doubly convert to lowercase to work around oddities of the library.
const noSecretsFilter = removeHiddenChars(lcFilter).toLowerCase();
const noSecretsName = removeHiddenChars(room.name.toLowerCase()).toLowerCase();
const noSecretsFilter = removeHiddenChars(this.search.toLowerCase()).toLowerCase();
const noSecretsName = removeHiddenChars(val.toLowerCase()).toLowerCase();
return noSecretsName.includes(noSecretsFilter);
}
}

View file

@ -1,72 +0,0 @@
/*
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";
/**
* Approximation of a membership status for a given room.
*/
export enum EffectiveMembership {
/**
* The user is effectively joined to the room. For example, actually joined
* or knocking on the room (when that becomes possible).
*/
Join = "JOIN",
/**
* The user is effectively invited to the room. Currently this is a direct map
* to the invite membership as no other membership states are effectively
* invites.
*/
Invite = "INVITE",
/**
* The user is effectively no longer in the room. For example, kicked,
* banned, or voluntarily left.
*/
Leave = "LEAVE",
}
export interface MembershipSplit {
// @ts-ignore - TS wants this to be a string key, but we know better.
[state: EffectiveMembership]: Room[];
}
export function splitRoomsByMembership(rooms: Room[]): MembershipSplit {
const split: MembershipSplit = {
[EffectiveMembership.Invite]: [],
[EffectiveMembership.Join]: [],
[EffectiveMembership.Leave]: [],
};
for (const room of rooms) {
split[getEffectiveMembership(room.getMyMembership())].push(room);
}
return split;
}
export function getEffectiveMembership(membership: string): EffectiveMembership {
if (membership === 'invite') {
return EffectiveMembership.Invite;
} else if (membership === 'join') {
// TODO: Do the same for knock? Update docs as needed in the enum.
return EffectiveMembership.Join;
} else {
// Probably a leave, kick, or ban
return EffectiveMembership.Leave;
}
}

View file

@ -20,6 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import ReplyThread from "../../../components/views/elements/ReplyThread";
import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils";
export class MessageEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
@ -30,18 +31,33 @@ export class MessageEventPreview implements IPreview {
eventContent = event.getContent()['m.new_content'];
}
if (!eventContent || !eventContent['body']) return null; // invalid for our purposes
let body = (eventContent['body'] || '').trim();
const msgtype = eventContent['msgtype'];
if (!body || !msgtype) return null; // invalid event, no preview
const hasHtml = eventContent.format === "org.matrix.custom.html" && eventContent.formatted_body;
if (hasHtml) {
body = eventContent.formatted_body;
}
// XXX: Newer relations have a getRelation() function which is not compatible with replies.
const mRelatesTo = event.getWireContent()['m.relates_to'];
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
// If this is a reply, get the real reply and use that
body = (ReplyThread.stripPlainReply(body) || '').trim();
if (hasHtml) {
body = (ReplyThread.stripHTMLReply(body) || '').trim();
} else {
body = (ReplyThread.stripPlainReply(body) || '').trim();
}
if (!body) return null; // invalid event, no preview
}
if (hasHtml) {
body = sanitizedHtmlNodeInnerText(body);
}
if (msgtype === 'm.emote') {
return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body});
}

View file

@ -22,8 +22,11 @@ import { _t } from "../../../languageHandler";
export class ReactionEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
const reaction = event.getRelation().key;
if (!reaction) return;
const relation = event.getRelation();
if (!relation) return null; // invalid reaction (probably redacted)
const reaction = relation.key;
if (!reaction) return null; // invalid reaction (unknown format)
if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
return reaction;