diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.js
index c1d42ffd0d2..c89ec444359 100644
--- a/src/actions/MatrixActionCreators.js
+++ b/src/actions/MatrixActionCreators.js
@@ -131,6 +131,24 @@ function createRoomTagsAction(matrixClient, roomTagsEvent, 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
* @type {Object}
@@ -233,6 +251,7 @@ export default {
this._addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction);
this._addMatrixClientListener(matrixClient, 'Room', createRoomAction);
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
+ this._addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction);
this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction);
this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);
diff --git a/src/components/views/settings/tabs/PreferencesSettingsTab.js b/src/components/views/settings/tabs/PreferencesSettingsTab.js
index b4559385634..d76dc8f3dd7 100644
--- a/src/components/views/settings/tabs/PreferencesSettingsTab.js
+++ b/src/components/views/settings/tabs/PreferencesSettingsTab.js
@@ -30,11 +30,6 @@ export default class PreferencesSettingsTab extends React.Component {
'sendTypingNotifications',
];
- static ROOM_LIST_SETTINGS = [
- 'pinUnreadRooms',
- 'pinMentionedRooms',
- ];
-
static TIMELINE_SETTINGS = [
'autoplayGifsAndVideos',
'urlPreviewsEnabled',
@@ -106,9 +101,6 @@ export default class PreferencesSettingsTab extends React.Component {
{_t("Composer")}
{this._renderGroup(PreferencesSettingsTab.COMPOSER_SETTINGS)}
- {_t("Room list")}
- {this._renderGroup(PreferencesSettingsTab.ROOM_LIST_SETTINGS)}
-
{_t("Timeline")}
{this._renderGroup(PreferencesSettingsTab.TIMELINE_SETTINGS)}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 28774d3c551..00486364119 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -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 by default for participants in this room": "Enable URL previews by default for participants in this room",
"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",
"Prompt before sending invites to potentially invalid matrix IDs": "Prompt before sending invites to potentially invalid matrix IDs",
"Show developer tools": "Show developer tools",
@@ -552,7 +550,6 @@
"Start automatically after system login": "Start automatically after system login",
"Preferences": "Preferences",
"Composer": "Composer",
- "Room list": "Room list",
"Timeline": "Timeline",
"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",
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index bb3cf9330cb..cf68fed8ba2 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -321,16 +321,6 @@ export const SETTINGS = {
default: true,
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": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Enable widget screenshots on supported widgets'),
diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js
index d98adc5cae6..47480aef85c 100644
--- a/src/stores/RoomListStore.js
+++ b/src/stores/RoomListStore.js
@@ -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");
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 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
+
+const CATEGORY_ORDER = [CATEGORY_RED, CATEGORY_GREY, CATEGORY_BOLD, CATEGORY_IDLE];
+const LIST_ORDERS = {
+ "m.favourite": "manual",
+ "im.vector.fake.invite": "recent",
+ "im.vector.fake.recent": "recent",
+ "im.vector.fake.direct": "recent",
+ "m.lowpriority": "recent",
+ "im.vector.fake.archived": "recent",
+};
+
/**
* A class for storing application state for categorising rooms in
* the RoomList.
*/
class RoomListStore extends Store {
- static _listOrders = {
- "m.favourite": "manual",
- "im.vector.fake.invite": "recent",
- "im.vector.fake.recent": "recent",
- "im.vector.fake.direct": "recent",
- "m.lowpriority": "recent",
- "im.vector.fake.archived": "recent",
- };
-
constructor() {
super(dis);
@@ -43,44 +61,43 @@ class RoomListStore extends Store {
_init() {
// Initialise state
+ const defaultLists = {
+ "m.server_notice": [/* { room: js-sdk room, category: string } */],
+ "im.vector.fake.invite": [],
+ "m.favourite": [],
+ "im.vector.fake.recent": [],
+ "im.vector.fake.direct": [],
+ "m.lowpriority": [],
+ "im.vector.fake.archived": [],
+ };
this._state = {
- lists: {
- "m.server_notice": [],
- "im.vector.fake.invite": [],
- "m.favourite": [],
- "im.vector.fake.recent": [],
- "im.vector.fake.direct": [],
- "m.lowpriority": [],
- "im.vector.fake.archived": [],
- },
+ // 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,
-
- // 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: {},
+ stickyRoomId: null,
};
}
_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.__emitChange();
}
__onDispatch(payload) {
+ const logicallyReady = this._matrixClient && this._state.ready;
switch (payload.action) {
// Initialise state after initial sync
case 'MatrixActions.sync': {
@@ -89,30 +106,47 @@ class RoomListStore extends Store {
}
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;
case 'MatrixActions.Room.tags': {
- if (!this._state.ready) break;
- this._generateRoomLists();
+ 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 (!this._state.ready ||
+ if (!logicallyReady ||
!payload.isLiveEvent ||
!payload.isLiveUnfilteredRoomTimelineEvent ||
!this._eventTriggersRecentReorder(payload.event)
- ) break;
+ ) {
+ break;
+ }
- this._clearCachedRoomState(payload.event.getRoomId());
- this._generateRoomLists();
+ 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': {
- // We may not have synced or done an initial generation of the lists
- if (!this._matrixClient || !this._state.ready) break;
+ if (!logicallyReady) break;
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)
// or it doesn't affect the ordering of the room list.
- if (liveTimeline !== eventTimeline ||
- !this._eventTriggersRecentReorder(payload.event)
- ) break;
+ if (liveTimeline !== eventTimeline || !this._eventTriggersRecentReorder(payload.event)) {
+ break;
+ }
- this._clearCachedRoomState(payload.event.getRoomId());
- this._generateRoomLists();
+ this._roomUpdateTriggered(roomId);
}
break;
case 'MatrixActions.accountData': {
+ if (!logicallyReady) break;
if (payload.event_type !== 'm.direct') break;
- this._generateRoomLists();
- }
- break;
- case 'MatrixActions.Room.accountData': {
- if (payload.event_type === 'm.fully_read') {
- this._clearCachedRoomState(payload.room.roomId);
- 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
+ this._generateInitialRoomLists();
}
break;
case 'MatrixActions.Room.myMembership': {
- this._generateRoomLists();
+ if (!logicallyReady) break;
+ this._roomUpdateTriggered(payload.room.roomId);
}
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 (!this._state.ready || !this._matrixClient.credentials.userId) break;
- this._generateRoomLists();
- }
- 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();
+ if (!logicallyReady) break;
+ this._roomUpdateTriggered(payload.room.roomId);
}
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': {
// 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.
@@ -182,10 +215,174 @@ class RoomListStore extends Store {
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) {
+ // 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});
}
- _generateRoomLists(optimisticRequest) {
+ _generateInitialRoomLists() {
const lists = {
"m.server_notice": [],
"im.vector.fake.invite": [],
@@ -196,74 +393,84 @@ class RoomListStore extends Store {
"im.vector.fake.archived": [],
};
-
const dmRoomMap = DMRoomMap.shared();
- // If somehow we dispatched a RoomListActions.tagRoom.failure before a MatrixActions.sync
- if (!this._matrixClient) return;
-
- const isCustomTagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
+ // Speed optimization: Hitting the SettingsStore is expensive, so avoid that at all costs.
+ let _isCustomTagsEnabled = null;
+ const isCustomTagsEnabled = () => {
+ if (_isCustomTagsEnabled === null) {
+ _isCustomTagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
+ }
+ return _isCustomTagsEnabled;
+ };
- this._matrixClient.getRooms().forEach((room, index) => {
+ 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);
- } else if (membership == "join" || membership === "ban" || (me && me.isKicked())) {
+ 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);
- 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
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) {
for (let i = 0; i < tagNames.length; i++) {
const tagName = tagNames[i];
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)) {
// "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 {
- lists["im.vector.fake.recent"].push(room);
+ lists["im.vector.fake.recent"].push({room, category: this._calculateCategory(room)});
}
} 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
- // in the _recentsComparator to avoid hitting the SettingsStore a few
- // thousand times.
- const pinUnread = SettingsStore.getValue("pinUnreadRooms");
- const pinMentioned = SettingsStore.getValue("pinMentionedRooms");
+ // 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
+
Object.keys(lists).forEach((listKey) => {
let comparator;
- switch (RoomListStore._listOrders[listKey]) {
+ switch (LIST_ORDERS[listKey]) {
case "recent":
- comparator = (roomA, roomB) => {
- return this._recentsComparator(roomA, roomB, pinUnread, pinMentioned);
+ comparator = (entryA, entryB) => {
+ 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;
case "manual":
default:
- comparator = this._getManualComparator(listKey, optimisticRequest);
+ comparator = this._getManualComparator(listKey);
break;
}
lists[listKey].sort(comparator);
@@ -271,52 +478,10 @@ class RoomListStore extends Store {
this._setState({
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) {
return ev.getTs() && (
Unread.eventTriggersUnreadCount(ev) ||
@@ -325,6 +490,10 @@ class RoomListStore extends Store {
}
_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)) {
@@ -342,53 +511,36 @@ class RoomListStore extends Store {
}
}
- _recentsComparator(roomA, roomB, pinUnread, pinMentioned) {
- // We try and set the ordering to be Mentioned > Unread > Recent
- // assuming the user has the right settings, of course.
-
- const timestampA = this._getRoomState(roomA, "timestamp");
- const timestampB = this._getRoomState(roomB, "timestamp");
- const timestampDiff = timestampB - timestampA;
-
- if (pinMentioned) {
- const mentionsA = this._getRoomState(roomA, "notifications");
- const mentionsB = this._getRoomState(roomB, "notifications");
- if (mentionsA && !mentionsB) return -1;
- if (!mentionsA && mentionsB) return 1;
-
- // If they both have notifications, sort by timestamp.
- // 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;
- }
+ _calculateCategory(room) {
+ const mentions = room.getUnreadNotificationCount("highlight") > 0;
+ if (mentions) return CATEGORY_RED;
- if (pinUnread) {
- let unreadA = this._getRoomState(roomA, "unread");
- let unreadB = this._getRoomState(roomB, "unread");
- 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;
-
- // Unread can also mean "unread without badge", which is
- // 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;
+ 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 roomA = entryA.room;
+ const roomB = entryB.room;
+ const categoryA = entryA.category;
+ const categoryB = entryB.category;
+
+ if (categoryA !== categoryB) {
+ const idxA = CATEGORY_ORDER.indexOf(categoryA);
+ const idxB = CATEGORY_ORDER.indexOf(categoryB);
+ if (idxA > idxB) return 1;
+ if (idxA < idxB) return -1;
+ return 0; // Technically not possible
}
- return timestampDiff;
+ const timestampA = tsOfNewestEventFn(roomA);
+ const timestampB = tsOfNewestEventFn(roomB);
+ return timestampB - timestampA;
}
_lexicographicalComparator(roomA, roomB) {
@@ -396,7 +548,10 @@ class RoomListStore extends Store {
}
_getManualComparator(tagName, optimisticRequest) {
- return (roomA, roomB) => {
+ return (entryA, entryB) => {
+ const roomA = entryA.room;
+ const roomB = entryB.room;
+
let metaA = roomA.tags[tagName];
let metaB = roomB.tags[tagName];
@@ -404,8 +559,8 @@ class RoomListStore extends Store {
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 ? metaA.order : undefined;
- const b = metaB ? metaB.order : undefined;
+ 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) {
@@ -414,12 +569,12 @@ class RoomListStore extends Store {
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() {
- return this._state.lists;
+ return this._state.presentationLists;
}
}
diff --git a/test/components/views/rooms/RoomList-test.js b/test/components/views/rooms/RoomList-test.js
index 0c970edb0bc..754367cd238 100644
--- a/test/components/views/rooms/RoomList-test.js
+++ b/test/components/views/rooms/RoomList-test.js
@@ -180,7 +180,8 @@ describe('RoomList', () => {
}
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', () => {
expectCorrectMove(undefined, 'im.vector.fake.direct');
});