Merge pull request #2634 from matrix-org/travis/better-room-sorting

"Breadcrumb" room sorting algorithm
This commit is contained in:
Travis Ralston 2019-02-21 08:46:17 -07:00 committed by GitHub
commit ade9ee3c18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 377 additions and 223 deletions

View file

@ -131,6 +131,24 @@ function createRoomTagsAction(matrixClient, roomTagsEvent, room) {
return { action: 'MatrixActions.Room.tags', room }; return { action: 'MatrixActions.Room.tags', room };
} }
/**
* Create a MatrixActions.Room.receipt action that represents a MatrixClient
* `Room.receipt` event, each parameter mapping to a key-value in the action.
*
* @param {MatrixClient} matrixClient the matrix client
* @param {MatrixEvent} event the receipt event.
* @param {Room} room the room the receipt happened in.
* @returns {Object} an action of type MatrixActions.Room.receipt.
*/
function createRoomReceiptAction(matrixClient, event, room) {
return {
action: 'MatrixActions.Room.receipt',
event,
room,
matrixClient,
};
}
/** /**
* @typedef RoomTimelineAction * @typedef RoomTimelineAction
* @type {Object} * @type {Object}
@ -233,6 +251,7 @@ export default {
this._addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction); this._addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction);
this._addMatrixClientListener(matrixClient, 'Room', createRoomAction); this._addMatrixClientListener(matrixClient, 'Room', createRoomAction);
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
this._addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction);
this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction); this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction); this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction);
this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction); this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);

View file

@ -30,11 +30,6 @@ export default class PreferencesSettingsTab extends React.Component {
'sendTypingNotifications', 'sendTypingNotifications',
]; ];
static ROOM_LIST_SETTINGS = [
'pinUnreadRooms',
'pinMentionedRooms',
];
static TIMELINE_SETTINGS = [ static TIMELINE_SETTINGS = [
'autoplayGifsAndVideos', 'autoplayGifsAndVideos',
'urlPreviewsEnabled', 'urlPreviewsEnabled',
@ -106,9 +101,6 @@ export default class PreferencesSettingsTab extends React.Component {
<span className="mx_SettingsTab_subheading">{_t("Composer")}</span> <span className="mx_SettingsTab_subheading">{_t("Composer")}</span>
{this._renderGroup(PreferencesSettingsTab.COMPOSER_SETTINGS)} {this._renderGroup(PreferencesSettingsTab.COMPOSER_SETTINGS)}
<span className="mx_SettingsTab_subheading">{_t("Room list")}</span>
{this._renderGroup(PreferencesSettingsTab.ROOM_LIST_SETTINGS)}
<span className="mx_SettingsTab_subheading">{_t("Timeline")}</span> <span className="mx_SettingsTab_subheading">{_t("Timeline")}</span>
{this._renderGroup(PreferencesSettingsTab.TIMELINE_SETTINGS)} {this._renderGroup(PreferencesSettingsTab.TIMELINE_SETTINGS)}

View file

@ -300,8 +300,6 @@
"Enable URL previews for this room (only affects you)": "Enable URL previews for this room (only affects you)", "Enable URL previews for this room (only affects you)": "Enable URL previews for this room (only affects you)",
"Enable URL previews by default for participants in this room": "Enable URL previews by default for participants in this room", "Enable URL previews by default for participants in this room": "Enable URL previews by default for participants in this room",
"Room Colour": "Room Colour", "Room Colour": "Room Colour",
"Pin rooms I'm mentioned in to the top of the room list": "Pin rooms I'm mentioned in to the top of the room list",
"Pin unread rooms to the top of the room list": "Pin unread rooms to the top of the room list",
"Enable widget screenshots on supported widgets": "Enable widget screenshots on supported widgets", "Enable widget screenshots on supported widgets": "Enable widget screenshots on supported widgets",
"Prompt before sending invites to potentially invalid matrix IDs": "Prompt before sending invites to potentially invalid matrix IDs", "Prompt before sending invites to potentially invalid matrix IDs": "Prompt before sending invites to potentially invalid matrix IDs",
"Show developer tools": "Show developer tools", "Show developer tools": "Show developer tools",
@ -552,7 +550,6 @@
"Start automatically after system login": "Start automatically after system login", "Start automatically after system login": "Start automatically after system login",
"Preferences": "Preferences", "Preferences": "Preferences",
"Composer": "Composer", "Composer": "Composer",
"Room list": "Room list",
"Timeline": "Timeline", "Timeline": "Timeline",
"Autocomplete delay (ms)": "Autocomplete delay (ms)", "Autocomplete delay (ms)": "Autocomplete delay (ms)",
"To change the room's avatar, you must be a": "To change the room's avatar, you must be a", "To change the room's avatar, you must be a": "To change the room's avatar, you must be a",

View file

@ -321,16 +321,6 @@ export const SETTINGS = {
default: true, default: true,
controller: new AudioNotificationsEnabledController(), controller: new AudioNotificationsEnabledController(),
}, },
"pinMentionedRooms": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Pin rooms I'm mentioned in to the top of the room list"),
default: true,
},
"pinUnreadRooms": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Pin unread rooms to the top of the room list"),
default: true,
},
"enableWidgetScreenshots": { "enableWidgetScreenshots": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Enable widget screenshots on supported widgets'), displayName: _td('Enable widget screenshots on supported widgets'),

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2018 New Vector Ltd Copyright 2018, 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -19,20 +19,38 @@ import DMRoomMap from '../utils/DMRoomMap';
import Unread from '../Unread'; import Unread from '../Unread';
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
/** /*
* A class for storing application state for categorising rooms in Room sorting algorithm:
* the RoomList. * 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.
*/ */
class RoomListStore extends Store {
static _listOrders = { 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
const CATEGORY_ORDER = [CATEGORY_RED, CATEGORY_GREY, CATEGORY_BOLD, CATEGORY_IDLE];
const LIST_ORDERS = {
"m.favourite": "manual", "m.favourite": "manual",
"im.vector.fake.invite": "recent", "im.vector.fake.invite": "recent",
"im.vector.fake.recent": "recent", "im.vector.fake.recent": "recent",
"im.vector.fake.direct": "recent", "im.vector.fake.direct": "recent",
"m.lowpriority": "recent", "m.lowpriority": "recent",
"im.vector.fake.archived": "recent", "im.vector.fake.archived": "recent",
}; };
/**
* A class for storing application state for categorising rooms in
* the RoomList.
*/
class RoomListStore extends Store {
constructor() { constructor() {
super(dis); super(dis);
@ -43,44 +61,43 @@ class RoomListStore extends Store {
_init() { _init() {
// Initialise state // Initialise state
this._state = { const defaultLists = {
lists: { "m.server_notice": [/* { room: js-sdk room, category: string } */],
"m.server_notice": [],
"im.vector.fake.invite": [], "im.vector.fake.invite": [],
"m.favourite": [], "m.favourite": [],
"im.vector.fake.recent": [], "im.vector.fake.recent": [],
"im.vector.fake.direct": [], "im.vector.fake.direct": [],
"m.lowpriority": [], "m.lowpriority": [],
"im.vector.fake.archived": [], "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, ready: false,
stickyRoomId: null,
// The room cache stores a mapping of roomId to cache record.
// Each cache record is a key/value pair for various bits of
// data used to sort the room list. Currently this stores the
// following bits of informations:
// "timestamp": number, The timestamp of the last relevant
// event in the room.
// "notifications": boolean, Whether or not the user has been
// highlighted on any unread events.
// "unread": boolean, Whether or not the user has any
// unread events.
//
// All of the cached values are lazily loaded on read in the
// recents comparator. When an event is received for a particular
// room, all the cached values are invalidated - forcing the
// next read to set new values. The entries do not expire on
// their own.
roomCache: {},
}; };
} }
_setState(newState) { _setState(newState) {
// 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._state = Object.assign(this._state, newState);
this.__emitChange(); this.__emitChange();
} }
__onDispatch(payload) { __onDispatch(payload) {
const logicallyReady = this._matrixClient && this._state.ready;
switch (payload.action) { switch (payload.action) {
// Initialise state after initial sync // Initialise state after initial sync
case 'MatrixActions.sync': { case 'MatrixActions.sync': {
@ -89,30 +106,47 @@ class RoomListStore extends Store {
} }
this._matrixClient = payload.matrixClient; this._matrixClient = payload.matrixClient;
this._generateRoomLists(); this._generateInitialRoomLists();
}
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; break;
case 'MatrixActions.Room.tags': { case 'MatrixActions.Room.tags': {
if (!this._state.ready) break; if (!logicallyReady) break;
this._generateRoomLists(); // 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; break;
case 'MatrixActions.Room.timeline': { case 'MatrixActions.Room.timeline': {
if (!this._state.ready || if (!logicallyReady ||
!payload.isLiveEvent || !payload.isLiveEvent ||
!payload.isLiveUnfilteredRoomTimelineEvent || !payload.isLiveUnfilteredRoomTimelineEvent ||
!this._eventTriggersRecentReorder(payload.event) !this._eventTriggersRecentReorder(payload.event)
) break; ) {
break;
}
this._clearCachedRoomState(payload.event.getRoomId()); this._roomUpdateTriggered(payload.event.getRoomId());
this._generateRoomLists();
} }
break; break;
// When an event is decrypted, it could mean we need to reorder the room // When an event is decrypted, it could mean we need to reorder the room
// list because we now know the type of the event. // list because we now know the type of the event.
case 'MatrixActions.Event.decrypted': { case 'MatrixActions.Event.decrypted': {
// We may not have synced or done an initial generation of the lists if (!logicallyReady) break;
if (!this._matrixClient || !this._state.ready) break;
const roomId = payload.event.getRoomId(); const roomId = payload.event.getRoomId();
@ -129,52 +163,51 @@ class RoomListStore extends Store {
// Either this event was not added to the live timeline (e.g. pagination) // Either this event was not added to the live timeline (e.g. pagination)
// or it doesn't affect the ordering of the room list. // or it doesn't affect the ordering of the room list.
if (liveTimeline !== eventTimeline || if (liveTimeline !== eventTimeline || !this._eventTriggersRecentReorder(payload.event)) {
!this._eventTriggersRecentReorder(payload.event) break;
) break; }
this._clearCachedRoomState(payload.event.getRoomId()); this._roomUpdateTriggered(roomId);
this._generateRoomLists();
} }
break; break;
case 'MatrixActions.accountData': { case 'MatrixActions.accountData': {
if (!logicallyReady) break;
if (payload.event_type !== 'm.direct') break; if (payload.event_type !== 'm.direct') break;
this._generateRoomLists(); // 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
break; this._generateInitialRoomLists();
case 'MatrixActions.Room.accountData': {
if (payload.event_type === 'm.fully_read') {
this._clearCachedRoomState(payload.room.roomId);
this._generateRoomLists();
}
} }
break; break;
case 'MatrixActions.Room.myMembership': { case 'MatrixActions.Room.myMembership': {
this._generateRoomLists(); if (!logicallyReady) break;
this._roomUpdateTriggered(payload.room.roomId);
} }
break; break;
// This could be a new room that we've been invited to, joined or created // 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 // we won't get a RoomMember.membership for these cases if we're not already
// a member. // a member.
case 'MatrixActions.Room': { case 'MatrixActions.Room': {
if (!this._state.ready || !this._matrixClient.credentials.userId) break; if (!logicallyReady) break;
this._generateRoomLists(); this._roomUpdateTriggered(payload.room.roomId);
}
break;
case 'RoomListActions.tagRoom.pending': {
// 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.
this._generateRoomLists(payload.request);
}
break;
case 'RoomListActions.tagRoom.failure': {
// Reset state according to js-sdk
this._generateRoomLists();
} }
break; 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_logged_out': { case 'on_logged_out': {
// Reset state without pushing an update to the view, which generally assumes that // 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. // the matrix client isn't `null` and so causing a re-render will cause NPEs.
@ -182,10 +215,174 @@ class RoomListStore extends Store {
this._matrixClient = null; this._matrixClient = null;
} }
break; 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;
} }
} }
_generateRoomLists(optimisticRequest) { _roomUpdateTriggered(roomId) {
// 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) {
// 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);
}
}
_setRoomCategory(room, category) {
if (!room) return; // This should only happen in tests
const listsClone = {};
const targetCategoryIndex = CATEGORY_ORDER.indexOf(category);
// Micro optimization: Support lazily loading the last timestamp in a room
let _targetTimestamp = null;
const targetTimestamp = () => {
if (_targetTimestamp === null) {
_targetTimestamp = this._tsOfNewestEvent(room);
}
return _targetTimestamp;
};
const myMembership = room.getMyMembership();
let doInsert = true;
const targetTags = [];
if (myMembership !== "join" && myMembership !== "invite") {
doInsert = false;
} else {
const dmRoomMap = DMRoomMap.shared();
if (dmRoomMap.getUserIdForRoomId(room.roomId)) {
targetTags.push('im.vector.fake.direct');
} else {
targetTags.push('im.vector.fake.recent');
}
}
// We need to update all instances of a room to ensure that they are correctly organized
// in the list. We do this by shallow-cloning the entire `lists` object using a single
// iterator. Within the loop, we also rebuild the list of rooms per tag (key) so that the
// updated room gets slotted into the right spot. This sacrifices code clarity for not
// iterating on potentially large collections multiple times.
let inserted = false;
for (const key of Object.keys(this._state.lists)) {
const hasRoom = this._state.lists[key].some((e) => e.room.roomId === room.roomId);
// Speed optimization: Skip the loop below if we're not going to do anything productive
if (!hasRoom || LIST_ORDERS[key] !== 'recent') {
listsClone[key] = this._state.lists[key];
continue;
} else {
listsClone[key] = [];
}
// We track where the boundary within listsClone[key] is just in case our timestamp
// ordering fails. If we can't stick the room in at the correct place in the category
// grouping based on timestamp, we'll stick it at the top of the group which will be
// the index we track here.
let desiredCategoryBoundaryIndex = 0;
let foundBoundary = false;
let pushedEntry = false;
for (const entry of this._state.lists[key]) {
// 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) {
if (!pushedEntry && doInsert && (targetTags.length === 0 || targetTags.includes(key))) {
// Micro optimization: Support lazily loading the last timestamp in a room
let _entryTimestamp = null;
const entryTimestamp = () => {
if (_entryTimestamp === null) {
_entryTimestamp = this._tsOfNewestEvent(entry.room);
}
return _entryTimestamp;
};
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 = listsClone[key].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 && targetTimestamp() >= entryTimestamp())) {
if (changedBoundary) {
// If we changed a boundary, then we've gone too far - go to the top of the last
// section instead.
listsClone[key].splice(desiredCategoryBoundaryIndex, 0, {room, category});
} else {
// If we're ordering by timestamp, just insert normally
listsClone[key].push({room, category});
}
pushedEntry = true;
inserted = true;
}
}
// We insert our own record as needed, so don't let the old one through.
if (entry.room.roomId === room.roomId) {
continue;
}
}
// Fall through and clone the list.
listsClone[key].push(entry);
}
}
if (!inserted) {
// There's a good chance that we just joined the room, so we need to organize it
// We also could have left it...
let tags = [];
if (doInsert) {
tags = Object.keys(room.tags);
if (tags.length === 0) {
tags = targetTags;
}
if (tags.length === 0) {
tags = [myMembership === 'join' ? 'im.vector.fake.recent' : 'im.vector.fake.invite'];
}
} else {
tags = ['im.vector.fake.archived'];
}
for (const tag of tags) {
for (let i = 0; i < listsClone[tag].length; i++) {
// Just find the top of our category grouping and insert it there.
const catIdxAtPosition = CATEGORY_ORDER.indexOf(listsClone[tag][i].category);
if (catIdxAtPosition >= targetCategoryIndex) {
listsClone[tag].splice(i, 0, {room: room, category: category});
break;
}
}
}
}
this._setState({lists: listsClone});
}
_generateInitialRoomLists() {
const lists = { const lists = {
"m.server_notice": [], "m.server_notice": [],
"im.vector.fake.invite": [], "im.vector.fake.invite": [],
@ -196,74 +393,84 @@ class RoomListStore extends Store {
"im.vector.fake.archived": [], "im.vector.fake.archived": [],
}; };
const dmRoomMap = DMRoomMap.shared(); const dmRoomMap = DMRoomMap.shared();
// If somehow we dispatched a RoomListActions.tagRoom.failure before a MatrixActions.sync // Speed optimization: Hitting the SettingsStore is expensive, so avoid that at all costs.
if (!this._matrixClient) return; let _isCustomTagsEnabled = null;
const isCustomTagsEnabled = () => {
if (_isCustomTagsEnabled === null) {
_isCustomTagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
}
return _isCustomTagsEnabled;
};
const isCustomTagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags"); this._matrixClient.getRooms().forEach((room) => {
this._matrixClient.getRooms().forEach((room, index) => {
const myUserId = this._matrixClient.getUserId(); const myUserId = this._matrixClient.getUserId();
const membership = room.getMyMembership(); const membership = room.getMyMembership();
const me = room.getMember(myUserId); const me = room.getMember(myUserId);
if (membership == "invite") { if (membership === "invite") {
lists["im.vector.fake.invite"].push(room); lists["im.vector.fake.invite"].push({room, category: CATEGORY_RED});
} else if (membership == "join" || membership === "ban" || (me && me.isKicked())) { } else if (membership === "join" || membership === "ban" || (me && me.isKicked())) {
// Used to split rooms via tags // Used to split rooms via tags
let tagNames = Object.keys(room.tags); let tagNames = Object.keys(room.tags);
if (optimisticRequest && optimisticRequest.room === room) {
// Remove old tag
tagNames = tagNames.filter((tagName) => tagName !== optimisticRequest.oldTag);
// Add new tag
if (optimisticRequest.newTag &&
!tagNames.includes(optimisticRequest.newTag)
) {
tagNames.push(optimisticRequest.newTag);
}
}
// ignore any m. tag names we don't know about // ignore any m. tag names we don't know about
tagNames = tagNames.filter((t) => { tagNames = tagNames.filter((t) => {
return (isCustomTagsEnabled && !t.startsWith('m.')) || lists[t] !== undefined; // Speed optimization: Avoid hitting the SettingsStore at all costs by making it the
// last condition possible.
return lists[t] !== undefined || (!t.startsWith('m.') && isCustomTagsEnabled());
}); });
if (tagNames.length) { if (tagNames.length) {
for (let i = 0; i < tagNames.length; i++) { for (let i = 0; i < tagNames.length; i++) {
const tagName = tagNames[i]; const tagName = tagNames[i];
lists[tagName] = lists[tagName] || []; lists[tagName] = lists[tagName] || [];
lists[tagName].push(room);
// Default to an arbitrary category for tags which aren't ordered by recents
let category = CATEGORY_IDLE;
if (LIST_ORDERS[tagName] === 'recent') category = this._calculateCategory(room);
lists[tagName].push({room, category: category});
} }
} else if (dmRoomMap.getUserIdForRoomId(room.roomId)) { } else if (dmRoomMap.getUserIdForRoomId(room.roomId)) {
// "Direct Message" rooms (that we're still in and that aren't otherwise tagged) // "Direct Message" rooms (that we're still in and that aren't otherwise tagged)
lists["im.vector.fake.direct"].push(room); lists["im.vector.fake.direct"].push({room, category: this._calculateCategory(room)});
} else { } else {
lists["im.vector.fake.recent"].push(room); lists["im.vector.fake.recent"].push({room, category: this._calculateCategory(room)});
} }
} else if (membership === "leave") { } else if (membership === "leave") {
lists["im.vector.fake.archived"].push(room); // 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});
} }
}); });
// Note: we check the settings up here instead of in the forEach or // We use this cache in the recents comparator because _tsOfNewestEvent can take a while. This
// in the _recentsComparator to avoid hitting the SettingsStore a few // cache only needs to survive the sort operation below and should not be implemented outside
// thousand times. // of this function, otherwise the room lists will almost certainly be out of date and wrong.
const pinUnread = SettingsStore.getValue("pinUnreadRooms"); const latestEventTsCache = {}; // roomId => timestamp
const pinMentioned = SettingsStore.getValue("pinMentionedRooms");
Object.keys(lists).forEach((listKey) => { Object.keys(lists).forEach((listKey) => {
let comparator; let comparator;
switch (RoomListStore._listOrders[listKey]) { switch (LIST_ORDERS[listKey]) {
case "recent": case "recent":
comparator = (roomA, roomB) => { comparator = (entryA, entryB) => {
return this._recentsComparator(roomA, roomB, pinUnread, pinMentioned); return this._recentsComparator(entryA, entryB, (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;
});
}; };
break; break;
case "manual": case "manual":
default: default:
comparator = this._getManualComparator(listKey, optimisticRequest); comparator = this._getManualComparator(listKey);
break; break;
} }
lists[listKey].sort(comparator); lists[listKey].sort(comparator);
@ -271,52 +478,10 @@ class RoomListStore extends Store {
this._setState({ this._setState({
lists, lists,
ready: true, // Ready to receive updates via Room.tags events ready: true, // Ready to receive updates to ordering
}); });
} }
_updateCachedRoomState(roomId, type, value) {
const roomCache = this._state.roomCache;
if (!roomCache[roomId]) roomCache[roomId] = {};
if (typeof value !== "undefined") roomCache[roomId][type] = value;
else delete roomCache[roomId][type];
this._setState({roomCache});
}
_clearCachedRoomState(roomId) {
const roomCache = this._state.roomCache;
delete roomCache[roomId];
this._setState({roomCache});
}
_getRoomState(room, type) {
const roomId = room.roomId;
const roomCache = this._state.roomCache;
if (roomCache[roomId] && typeof roomCache[roomId][type] !== 'undefined') {
return roomCache[roomId][type];
}
if (type === "timestamp") {
const ts = this._tsOfNewestEvent(room);
this._updateCachedRoomState(roomId, "timestamp", ts);
return ts;
} else if (type === "unread-muted") {
const unread = Unread.doesRoomHaveUnreadMessages(room);
this._updateCachedRoomState(roomId, "unread-muted", unread);
return unread;
} else if (type === "unread") {
const unread = room.getUnreadNotificationCount() > 0;
this._updateCachedRoomState(roomId, "unread", unread);
return unread;
} else if (type === "notifications") {
const notifs = room.getUnreadNotificationCount("highlight") > 0;
this._updateCachedRoomState(roomId, "notifications", notifs);
return notifs;
} else throw new Error("Unrecognized room cache type: " + type);
}
_eventTriggersRecentReorder(ev) { _eventTriggersRecentReorder(ev) {
return ev.getTs() && ( return ev.getTs() && (
Unread.eventTriggersUnreadCount(ev) || Unread.eventTriggersUnreadCount(ev) ||
@ -325,6 +490,10 @@ class RoomListStore extends Store {
} }
_tsOfNewestEvent(room) { _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) { for (let i = room.timeline.length - 1; i >= 0; --i) {
const ev = room.timeline[i]; const ev = room.timeline[i];
if (this._eventTriggersRecentReorder(ev)) { if (this._eventTriggersRecentReorder(ev)) {
@ -342,53 +511,36 @@ class RoomListStore extends Store {
} }
} }
_recentsComparator(roomA, roomB, pinUnread, pinMentioned) { _calculateCategory(room) {
// We try and set the ordering to be Mentioned > Unread > Recent const mentions = room.getUnreadNotificationCount("highlight") > 0;
// assuming the user has the right settings, of course. if (mentions) return CATEGORY_RED;
const timestampA = this._getRoomState(roomA, "timestamp"); let unread = room.getUnreadNotificationCount() > 0;
const timestampB = this._getRoomState(roomB, "timestamp"); if (unread) return CATEGORY_GREY;
const timestampDiff = timestampB - timestampA;
if (pinMentioned) { unread = Unread.doesRoomHaveUnreadMessages(room);
const mentionsA = this._getRoomState(roomA, "notifications"); if (unread) return CATEGORY_BOLD;
const mentionsB = this._getRoomState(roomB, "notifications");
if (mentionsA && !mentionsB) return -1;
if (!mentionsA && mentionsB) return 1;
// If they both have notifications, sort by timestamp. return CATEGORY_IDLE;
// If neither have notifications (the fourth check not shown
// here), then try and sort by unread messages and finally by
// timestamp.
if (mentionsA && mentionsB) return timestampDiff;
} }
if (pinUnread) { _recentsComparator(entryA, entryB, tsOfNewestEventFn) {
let unreadA = this._getRoomState(roomA, "unread"); const roomA = entryA.room;
let unreadB = this._getRoomState(roomB, "unread"); const roomB = entryB.room;
if (unreadA && !unreadB) return -1; const categoryA = entryA.category;
if (!unreadA && unreadB) return 1; const categoryB = entryB.category;
// If they both have unread messages, sort by timestamp if (categoryA !== categoryB) {
// If nether have unread message (the fourth check not shown const idxA = CATEGORY_ORDER.indexOf(categoryA);
// here), then just sort by timestamp anyways. const idxB = CATEGORY_ORDER.indexOf(categoryB);
if (unreadA && unreadB) return timestampDiff; if (idxA > idxB) return 1;
if (idxA < idxB) return -1;
// Unread can also mean "unread without badge", which is return 0; // Technically not possible
// different from what the above checks for. We're also
// going to sort those here.
unreadA = this._getRoomState(roomA, "unread-muted");
unreadB = this._getRoomState(roomB, "unread-muted");
if (unreadA && !unreadB) return -1;
if (!unreadA && unreadB) return 1;
// If they both have unread messages, sort by timestamp
// If nether have unread message (the fourth check not shown
// here), then just sort by timestamp anyways.
if (unreadA && unreadB) return timestampDiff;
} }
return timestampDiff; const timestampA = tsOfNewestEventFn(roomA);
const timestampB = tsOfNewestEventFn(roomB);
return timestampB - timestampA;
} }
_lexicographicalComparator(roomA, roomB) { _lexicographicalComparator(roomA, roomB) {
@ -396,7 +548,10 @@ class RoomListStore extends Store {
} }
_getManualComparator(tagName, optimisticRequest) { _getManualComparator(tagName, optimisticRequest) {
return (roomA, roomB) => { return (entryA, entryB) => {
const roomA = entryA.room;
const roomB = entryB.room;
let metaA = roomA.tags[tagName]; let metaA = roomA.tags[tagName];
let metaB = roomB.tags[tagName]; let metaB = roomB.tags[tagName];
@ -404,8 +559,8 @@ class RoomListStore extends Store {
if (optimisticRequest && roomB === optimisticRequest.room) metaB = 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 // Make sure the room tag has an order element, if not set it to be the bottom
const a = metaA ? metaA.order : undefined; const a = metaA ? Number(metaA.order) : undefined;
const b = metaB ? metaB.order : undefined; const b = metaB ? Number(metaB.order) : undefined;
// Order undefined room tag orders to the bottom // Order undefined room tag orders to the bottom
if (a === undefined && b !== undefined) { if (a === undefined && b !== undefined) {
@ -414,12 +569,12 @@ class RoomListStore extends Store {
return -1; return -1;
} }
return a == b ? this._lexicographicalComparator(roomA, roomB) : ( a > b ? 1 : -1); return a === b ? this._lexicographicalComparator(roomA, roomB) : ( a > b ? 1 : -1);
}; };
} }
getRoomLists() { getRoomLists() {
return this._state.lists; return this._state.presentationLists;
} }
} }

View file

@ -180,7 +180,8 @@ describe('RoomList', () => {
} }
function itDoesCorrectOptimisticUpdatesForDraggedRoomTiles() { function itDoesCorrectOptimisticUpdatesForDraggedRoomTiles() {
describe('does correct optimistic update when dragging from', () => { // TODO: Re-enable dragging tests when we support dragging again.
xdescribe('does correct optimistic update when dragging from', () => {
it('rooms to people', () => { it('rooms to people', () => {
expectCorrectMove(undefined, 'im.vector.fake.direct'); expectCorrectMove(undefined, 'im.vector.fake.direct');
}); });