diff --git a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss index d003e175d9..be0af9123b 100644 --- a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss @@ -14,6 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_PreferencesUserSettingsTab .mx_Field { - @mixin mx_Settings_fullWidthField; +.mx_PreferencesUserSettingsTab { + .mx_Field { + @mixin mx_Settings_fullWidthField; + } + + .mx_SettingsTab_section { + margin-bottom: 30px; + } } diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index 600b418fe0..fa2231328c 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -46,8 +46,6 @@ export default class RoomSubList extends React.PureComponent { tagName: PropTypes.string, addRoomLabel: PropTypes.string, - order: PropTypes.string.isRequired, - // passed through to RoomTile and used to highlight room with `!` regardless of notifications count isInvite: PropTypes.bool, diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 99f30d9ba1..ad67d8e308 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -700,13 +700,11 @@ export default createReactClass({ list: [], extraTiles: this._makeGroupInviteTiles(this.props.searchFilter), label: _t('Community Invites'), - order: "recent", isInvite: true, }, { list: this.state.lists['im.vector.fake.invite'], label: _t('Invites'), - order: "recent", incomingCall: incomingCallIfTaggedAs('im.vector.fake.invite'), isInvite: true, }, @@ -714,14 +712,12 @@ export default createReactClass({ list: this.state.lists['m.favourite'], label: _t('Favourites'), tagName: "m.favourite", - order: "manual", incomingCall: incomingCallIfTaggedAs('m.favourite'), }, { list: this.state.lists[TAG_DM], label: _t('Direct Messages'), tagName: TAG_DM, - order: "recent", incomingCall: incomingCallIfTaggedAs(TAG_DM), onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});}, addRoomLabel: _t("Start chat"), @@ -729,7 +725,6 @@ export default createReactClass({ { list: this.state.lists['im.vector.fake.recent'], label: _t('Rooms'), - order: "recent", incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'), onAddRoom: () => {dis.dispatch({action: 'view_create_room'});}, }, @@ -744,7 +739,6 @@ export default createReactClass({ key: tagName, label: labelForTagName(tagName), tagName: tagName, - order: "manual", incomingCall: incomingCallIfTaggedAs(tagName), }; }); @@ -754,13 +748,11 @@ export default createReactClass({ list: this.state.lists['m.lowpriority'], label: _t('Low priority'), tagName: "m.lowpriority", - order: "recent", incomingCall: incomingCallIfTaggedAs('m.lowpriority'), }, { list: this.state.lists['im.vector.fake.archived'], label: _t('Historical'), - order: "recent", incomingCall: incomingCallIfTaggedAs('im.vector.fake.archived'), startAsHidden: true, showSpinner: this.state.isLoadingLeftRooms, @@ -770,7 +762,6 @@ export default createReactClass({ list: this.state.lists['m.server_notice'], label: _t('System Alerts'), tagName: "m.lowpriority", - order: "recent", incomingCall: incomingCallIfTaggedAs('m.server_notice'), }, ]); diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js index fea2e8c81f..8df65e6590 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js @@ -25,6 +25,12 @@ import * as sdk from "../../../../.."; import PlatformPeg from "../../../../../PlatformPeg"; export default class PreferencesUserSettingsTab extends React.Component { + static ROOM_LIST_SETTINGS = [ + 'RoomList.orderAlphabetically', + 'RoomList.orderByImportance', + 'breadcrumbs', + ]; + static COMPOSER_SETTINGS = [ 'MessageComposerInput.autoReplaceEmoji', 'MessageComposerInput.suggestEmoji', @@ -47,11 +53,6 @@ export default class PreferencesUserSettingsTab extends React.Component { 'showImages', ]; - static ROOM_LIST_SETTINGS = [ - 'RoomList.orderByImportance', - 'breadcrumbs', - ]; - static ADVANCED_SETTINGS = [ 'alwaysShowEncryptionIcons', 'Pill.shouldShowPillAvatar', @@ -173,15 +174,21 @@ export default class PreferencesUserSettingsTab extends React.Component {
{_t("Preferences")}
- {_t("Composer")} - {this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)} - - {_t("Timeline")} - {this._renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)} - {_t("Room list")} {this._renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)} +
+
+ {_t("Composer")} + {this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)} +
+ +
+ {_t("Timeline")} + {this._renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)} +
+ +
{_t("Advanced")} {this._renderGroup(PreferencesUserSettingsTab.ADVANCED_SETTINGS)} {minimizeToTrayOption} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 79b045dd34..f5209f8e18 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -414,8 +414,9 @@ "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", - "Order rooms in the room list by most important first instead of most recent": "Order rooms in the room list by most important first instead of most recent", - "Show recently visited rooms above the room list": "Show recently visited rooms above the room list", + "Order rooms by name": "Order rooms by name", + "Show rooms with unread notifications first": "Show rooms with unread notifications first", + "Show shortcuts to recently viewed rooms above the room list": "Show shortcuts to recently viewed rooms above the room list", "Show hidden events in timeline": "Show hidden events in timeline", "Low bandwidth mode": "Low bandwidth mode", "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)", @@ -774,9 +775,9 @@ "Always show the window menu bar": "Always show the window menu bar", "Show tray icon and minimize window to it on close": "Show tray icon and minimize window to it on close", "Preferences": "Preferences", + "Room list": "Room list", "Composer": "Composer", "Timeline": "Timeline", - "Room list": "Room list", "Autocomplete delay (ms)": "Autocomplete delay (ms)", "Read Marker lifetime (ms)": "Read Marker lifetime (ms)", "Read Marker off-screen lifetime (ms)": "Read Marker off-screen lifetime (ms)", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 936b651211..b77fb392e9 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -433,14 +433,19 @@ export const SETTINGS = { deny: [], }, }, + "RoomList.orderAlphabetically": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + displayName: _td("Order rooms by name"), + default: false, + }, "RoomList.orderByImportance": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td('Order rooms in the room list by most important first instead of most recent'), + displayName: _td("Show rooms with unread notifications first"), default: true, }, "breadcrumbs": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td("Show recently visited rooms above the room list"), + displayName: _td("Show shortcuts to recently viewed rooms above the room list"), default: true, }, "showHiddenEventsInTimeline": { diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index 0a38ac6591..30b523d35d 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -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); }; }