Merge pull request #4085 from matrix-org/t3chguy/alpha_room_list

Room List sorting algorithms
This commit is contained in:
Michael Telatynski
2020-02-28 12:04:10 +00:00
committed by GitHub
7 changed files with 150 additions and 100 deletions

View File

@@ -36,32 +36,52 @@ 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];
export const TAG_DM = "im.vector.fake.direct";
const LIST_ORDERS = {
"m.favourite": "manual",
"im.vector.fake.invite": "recent",
"im.vector.fake.recent": "recent",
[TAG_DM]: "recent",
"m.lowpriority": "recent",
"im.vector.fake.archived": "recent",
};
/**
* Identifier for the "breadcrumb" (or "sort by most important room first") algorithm.
* Includes a provision for keeping the currently open room from flying down the room
* list.
* Identifier for manual sorting behaviour: sort by the user defined order.
* @type {string}
*/
const ALGO_IMPORTANCE = "importance";
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}
*/
const ALGO_RECENT = "recent";
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 "m.favourite":
return ALGO_MANUAL;
case "im.vector.fake.invite":
case "im.vector.fake.recent":
case "im.vector.fake.archived":
case "m.lowpriority":
case TAG_DM:
default:
return settingAlgorithm;
}
};
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
@@ -79,13 +99,13 @@ class RoomListStore extends Store {
/**
* 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) {
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.
const byImportance = algorithm === ALGO_IMPORTANCE;
console.log("Updating room sorting algorithm: sortByImportance=" + byImportance);
this._setState({orderRoomsByImportance: byImportance});
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();
@@ -109,9 +129,11 @@ class RoomListStore extends Store {
presentationLists: defaultLists, // like `lists`, but with arrays of rooms instead
ready: false,
stickyRoomId: null,
orderRoomsByImportance: true,
algorithm: ALGO_RECENT,
orderImportantFirst: false,
};
SettingsStore.monitorSetting('RoomList.orderAlphabetically', null);
SettingsStore.monitorSetting('RoomList.orderByImportance', null);
SettingsStore.monitorSetting('feature_custom_tags', null);
}
@@ -138,11 +160,18 @@ class RoomListStore extends Store {
case 'setting_updated': {
if (!logicallyReady) break;
if (payload.settingName === 'RoomList.orderByImportance') {
this.updateSortingAlgorithm(payload.newValue === true ? ALGO_IMPORTANCE : ALGO_RECENT);
} else if (payload.settingName === 'feature_custom_tags') {
this._setState({tagsEnabled: payload.newValue});
this._generateInitialRoomLists(); // Tags means we have to start from scratch
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;
@@ -160,9 +189,9 @@ class RoomListStore extends Store {
this._matrixClient = payload.matrixClient;
const algorithm = SettingsStore.getValue("RoomList.orderByImportance")
? ALGO_IMPORTANCE : ALGO_RECENT;
this.updateSortingAlgorithm(algorithm);
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': {
@@ -191,7 +220,8 @@ class RoomListStore extends Store {
if (!logicallyReady ||
!payload.isLiveEvent ||
!payload.isLiveUnfilteredRoomTimelineEvent ||
!this._eventTriggersRecentReorder(payload.event)
!this._eventTriggersRecentReorder(payload.event) ||
this._state.algorithm !== ALGO_RECENT
) {
break;
}
@@ -305,7 +335,7 @@ class RoomListStore extends Store {
_filterTags(tags) {
tags = tags ? Object.keys(tags) : [];
if (this._state.tagsEnabled) return tags;
return tags.filter((t) => !!LIST_ORDERS[t]);
return tags.filter((t) => knownLists.has(t));
}
_getRecommendedTagsForRoom(room) {
@@ -448,6 +478,12 @@ class RoomListStore extends Store {
_setRoomCategory(room, category) {
if (!room) return; // This should only happen in tests
if (!this._state.orderImportantFirst) {
// XXX bail here early to avoid https://github.com/vector-im/riot-web/issues/9216
// this may mean that category updates are missed whilst not ordering by importance first
return;
}
const listsClone = {};
// Micro optimization: Support lazily loading the last timestamp in a room
@@ -477,7 +513,7 @@ class RoomListStore extends Store {
// 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 (LIST_ORDERS[key] !== 'recent') {
} 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);
@@ -538,7 +574,7 @@ class RoomListStore extends Store {
// Sort the favourites before we set the clone
for (const tag of Object.keys(listsClone)) {
if (LIST_ORDERS[tag] === 'recent') continue; // skip recents (pre-sorted)
if (getListAlgorithm(tag, this._state.algorithm) !== ALGO_MANUAL) continue; // skip recents (pre-sorted)
listsClone[tag].sort(this._getManualComparator(tag));
}
@@ -588,8 +624,10 @@ class RoomListStore extends Store {
// 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});
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)
@@ -608,31 +646,48 @@ class RoomListStore extends Store {
// 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 (LIST_ORDERS[listKey]) {
case "recent":
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;
});
};
switch (getListAlgorithm(listKey, this._state.algorithm)) {
case ALGO_RECENT:
comparator = (entryA, entryB) => this._recentsComparator(entryA, entryB, tsOfNewestEventFn);
break;
case "manual":
case ALGO_ALPHABETIC:
comparator = this._lexicographicalComparator;
break;
case ALGO_MANUAL:
default:
comparator = this._getManualComparator(listKey);
break;
}
lists[listKey].sort(comparator);
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({
@@ -671,7 +726,7 @@ class RoomListStore extends Store {
}
_calculateCategory(room) {
if (!this._state.orderRoomsByImportance) {
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
@@ -692,26 +747,13 @@ class RoomListStore extends Store {
}
_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
}
const timestampA = tsOfNewestEventFn(roomA);
const timestampB = tsOfNewestEventFn(roomB);
const timestampA = tsOfNewestEventFn(entryA.room);
const timestampB = tsOfNewestEventFn(entryB.room);
return timestampB - timestampA;
}
_lexicographicalComparator(roomA, roomB) {
return roomA.name > roomB.name ? 1 : -1;
_lexicographicalComparator(entryA, entryB) {
return entryA.room.name.localeCompare(entryB.room.name);
}
_getManualComparator(tagName, optimisticRequest) {
@@ -736,7 +778,7 @@ class RoomListStore extends Store {
return -1;
}
return a === b ? this._lexicographicalComparator(roomA, roomB) : (a > b ? 1 : -1);
return a === b ? this._lexicographicalComparator(entryA, entryB) : (a > b ? 1 : -1);
};
}