diff --git a/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index d8c6723d7b..f12d4d3084 100644 --- a/src/actions/RoomListActions.ts +++ b/src/actions/RoomListActions.ts @@ -16,7 +16,6 @@ limitations under the License. */ import { asyncAction } from './actionCreators'; -import { TAG_DM } from '../stores/RoomListStore'; import Modal from '../Modal'; import * as Rooms from '../Rooms'; import { _t } from '../languageHandler'; @@ -26,6 +25,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { AsyncActionPayload } from "../dispatcher/payloads"; import RoomListStore from "../stores/room-list/RoomListStore2"; import { SortAlgorithm } from "../stores/room-list/algorithms/models"; +import { DefaultTagID } from "../stores/room-list/models"; export default class RoomListActions { /** @@ -82,11 +82,11 @@ export default class RoomListActions { const roomId = room.roomId; // Evil hack to get DMs behaving - if ((oldTag === undefined && newTag === TAG_DM) || - (oldTag === TAG_DM && newTag === undefined) + if ((oldTag === undefined && newTag === DefaultTagID.DM) || + (oldTag === DefaultTagID.DM && newTag === undefined) ) { return Rooms.guessAndSetDMRoom( - room, newTag === TAG_DM, + room, newTag === DefaultTagID.DM, ).catch((err) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to set direct chat tag " + err); @@ -103,7 +103,7 @@ export default class RoomListActions { // but we avoid ever doing a request with TAG_DM. // // if we moved lists, remove the old tag - if (oldTag && oldTag !== TAG_DM && + if (oldTag && oldTag !== DefaultTagID.DM && hasChangedSubLists ) { const promiseToDelete = matrixClient.deleteRoomTag( @@ -121,7 +121,7 @@ export default class RoomListActions { } // if we moved lists or the ordering changed, add the new tag - if (newTag && newTag !== TAG_DM && + if (newTag && newTag !== DefaultTagID.DM && (hasChangedSubLists || metaData) ) { // metaData is the body of the PUT to set the tag, so it must diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js deleted file mode 100644 index 6c18aa83ad..0000000000 --- a/src/stores/RoomListStore.js +++ /dev/null @@ -1,805 +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.disabled = true; - this._init(); - this._getManualComparator = this._getManualComparator.bind(this); - this._recentsComparator = this._recentsComparator.bind(this); - } - - /** - * 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; - } - - 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;