Merge branch 'develop' into joriks/field-ts
This commit is contained in:
commit
b4624baf2f
40 changed files with 2537 additions and 269 deletions
|
@ -6,7 +6,6 @@ src/components/structures/RoomView.js
|
||||||
src/components/structures/ScrollPanel.js
|
src/components/structures/ScrollPanel.js
|
||||||
src/components/structures/SearchBox.js
|
src/components/structures/SearchBox.js
|
||||||
src/components/structures/UploadBar.js
|
src/components/structures/UploadBar.js
|
||||||
src/components/views/avatars/BaseAvatar.js
|
|
||||||
src/components/views/avatars/MemberAvatar.js
|
src/components/views/avatars/MemberAvatar.js
|
||||||
src/components/views/create_room/RoomAlias.js
|
src/components/views/create_room/RoomAlias.js
|
||||||
src/components/views/dialogs/SetPasswordDialog.js
|
src/components/views/dialogs/SetPasswordDialog.js
|
||||||
|
|
|
@ -1,3 +1,12 @@
|
||||||
|
Changes in [2.6.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.6.1) (2020-05-22)
|
||||||
|
===================================================================================================
|
||||||
|
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.6.0...v2.6.1)
|
||||||
|
|
||||||
|
* Fix key backup restore with SSSS
|
||||||
|
[\#4617](https://github.com/matrix-org/matrix-react-sdk/pull/4617)
|
||||||
|
* Remove SSSS key upgrade check from rageshake
|
||||||
|
[\#4616](https://github.com/matrix-org/matrix-react-sdk/pull/4616)
|
||||||
|
|
||||||
Changes in [2.6.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.6.0) (2020-05-19)
|
Changes in [2.6.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.6.0) (2020-05-19)
|
||||||
===================================================================================================
|
===================================================================================================
|
||||||
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.6.0-rc.1...v2.6.0)
|
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.6.0-rc.1...v2.6.0)
|
||||||
|
|
|
@ -151,6 +151,7 @@ General Style
|
||||||
Don't set things to undefined. Reserve that value to mean "not yet set to anything."
|
Don't set things to undefined. Reserve that value to mean "not yet set to anything."
|
||||||
Boolean objects are verboten.
|
Boolean objects are verboten.
|
||||||
- Use JSDoc
|
- Use JSDoc
|
||||||
|
- Use switch-case statements where there are 5 or more branches running against the same variable.
|
||||||
|
|
||||||
ECMAScript
|
ECMAScript
|
||||||
----------
|
----------
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "matrix-react-sdk",
|
"name": "matrix-react-sdk",
|
||||||
"version": "2.6.0",
|
"version": "2.6.1",
|
||||||
"description": "SDK for matrix.org using React",
|
"description": "SDK for matrix.org using React",
|
||||||
"author": "matrix.org",
|
"author": "matrix.org",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
@ -55,6 +55,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.8.3",
|
"@babel/runtime": "^7.8.3",
|
||||||
|
"await-lock": "^2.0.1",
|
||||||
"blueimp-canvas-to-blob": "^3.5.0",
|
"blueimp-canvas-to-blob": "^3.5.0",
|
||||||
"browser-encrypt-attachment": "^0.3.0",
|
"browser-encrypt-attachment": "^0.3.0",
|
||||||
"browser-request": "^0.3.3",
|
"browser-request": "^0.3.3",
|
||||||
|
@ -142,6 +143,7 @@
|
||||||
"flow-parser": "^0.57.3",
|
"flow-parser": "^0.57.3",
|
||||||
"glob": "^5.0.14",
|
"glob": "^5.0.14",
|
||||||
"jest": "^24.9.0",
|
"jest": "^24.9.0",
|
||||||
|
"jest-canvas-mock": "^2.2.0",
|
||||||
"lolex": "^5.1.2",
|
"lolex": "^5.1.2",
|
||||||
"matrix-mock-request": "^1.2.3",
|
"matrix-mock-request": "^1.2.3",
|
||||||
"matrix-react-test-utils": "^0.2.2",
|
"matrix-react-test-utils": "^0.2.2",
|
||||||
|
@ -161,6 +163,7 @@
|
||||||
"testMatch": [
|
"testMatch": [
|
||||||
"<rootDir>/test/**/*-test.js"
|
"<rootDir>/test/**/*-test.js"
|
||||||
],
|
],
|
||||||
|
"setupFiles": ["jest-canvas-mock"],
|
||||||
"setupFilesAfterEnv": [
|
"setupFilesAfterEnv": [
|
||||||
"<rootDir>/test/setupTests.js"
|
"<rootDir>/test/setupTests.js"
|
||||||
],
|
],
|
||||||
|
|
|
@ -98,6 +98,7 @@ limitations under the License.
|
||||||
|
|
||||||
.mx_EventTile .mx_MessageTimestamp {
|
.mx_EventTile .mx_MessageTimestamp {
|
||||||
display: block;
|
display: block;
|
||||||
|
visibility: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
left: 0px;
|
left: 0px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@ -158,10 +159,15 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
|
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
|
||||||
|
// The first set is to handle the 'group layout' (default) and the second for the IRC layout
|
||||||
.mx_EventTile_last > div > a > .mx_MessageTimestamp,
|
.mx_EventTile_last > div > a > .mx_MessageTimestamp,
|
||||||
.mx_EventTile:hover > div > a > .mx_MessageTimestamp,
|
.mx_EventTile:hover > div > a > .mx_MessageTimestamp,
|
||||||
.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp,
|
.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp,
|
||||||
.mx_EventTile.focus-visible:focus-within > div > a > .mx_MessageTimestamp {
|
.mx_EventTile.focus-visible:focus-within > div > a > .mx_MessageTimestamp,
|
||||||
|
.mx_IRCLayout .mx_EventTile_last > a > .mx_MessageTimestamp,
|
||||||
|
.mx_IRCLayout .mx_EventTile:hover > a > .mx_MessageTimestamp,
|
||||||
|
.mx_IRCLayout .mx_EventTile.mx_EventTile_actionBarFocused > a > .mx_MessageTimestamp,
|
||||||
|
.mx_IRCLayout .mx_EventTile.focus-visible:focus-within > a > .mx_MessageTimestamp {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,6 @@ $left-gutter: 65px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_MessageTimestamp {
|
.mx_MessageTimestamp {
|
||||||
visibility: hidden;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 46px; /* 8 + 30 (avatar) + 8 */
|
width: 46px; /* 8 + 30 (avatar) + 8 */
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
// @flow
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Copyright 2016 Aviral Dasgupta
|
Copyright 2016 Aviral Dasgupta
|
||||||
Copyright 2016 OpenMarket Ltd
|
Copyright 2016 OpenMarket Ltd
|
||||||
|
@ -19,9 +17,10 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {MatrixClient} from "matrix-js-sdk";
|
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||||
import dis from './dispatcher/dispatcher';
|
import dis from './dispatcher/dispatcher';
|
||||||
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
|
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
|
||||||
|
import {ActionPayload} from "./dispatcher/payloads";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for classes that provide platform-specific functionality
|
* Base class for classes that provide platform-specific functionality
|
||||||
|
@ -29,27 +28,25 @@ import BaseEventIndexManager from './indexing/BaseEventIndexManager';
|
||||||
*
|
*
|
||||||
* Instances of this class are provided by the application.
|
* Instances of this class are provided by the application.
|
||||||
*/
|
*/
|
||||||
export default class BasePlatform {
|
export default abstract class BasePlatform {
|
||||||
constructor() {
|
protected notificationCount = 0;
|
||||||
this.notificationCount = 0;
|
protected errorDidOccur = false;
|
||||||
this.errorDidOccur = false;
|
|
||||||
|
|
||||||
dis.register(this._onAction.bind(this));
|
constructor() {
|
||||||
|
dis.register(this.onAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
_onAction(payload: Object) {
|
protected onAction = (payload: ActionPayload) => {
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case 'on_client_not_viable':
|
case 'on_client_not_viable':
|
||||||
case 'on_logged_out':
|
case 'on_logged_out':
|
||||||
this.setNotificationCount(0);
|
this.setNotificationCount(0);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Used primarily for Analytics
|
// Used primarily for Analytics
|
||||||
getHumanReadableName(): string {
|
abstract getHumanReadableName(): string;
|
||||||
return 'Base Platform';
|
|
||||||
}
|
|
||||||
|
|
||||||
setNotificationCount(count: number) {
|
setNotificationCount(count: number) {
|
||||||
this.notificationCount = count;
|
this.notificationCount = count;
|
||||||
|
@ -84,22 +81,17 @@ export default class BasePlatform {
|
||||||
* that is 'granted' if the user allowed the request or
|
* that is 'granted' if the user allowed the request or
|
||||||
* 'denied' otherwise.
|
* 'denied' otherwise.
|
||||||
*/
|
*/
|
||||||
requestNotificationPermission(): Promise<string> {
|
abstract requestNotificationPermission(): Promise<string>;
|
||||||
}
|
|
||||||
|
|
||||||
displayNotification(title: string, msg: string, avatarUrl: string, room: Object) {
|
abstract displayNotification(title: string, msg: string, avatarUrl: string, room: Object);
|
||||||
}
|
|
||||||
|
|
||||||
loudNotification(ev: Event, room: Object) {
|
loudNotification(ev: Event, room: Object) {
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a promise that resolves to a string representing
|
* Returns a promise that resolves to a string representing the current version of the application.
|
||||||
* the current version of the application.
|
|
||||||
*/
|
*/
|
||||||
getAppVersion(): Promise<string> {
|
abstract getAppVersion(): Promise<string>;
|
||||||
throw new Error("getAppVersion not implemented!");
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* If it's not expected that capturing the screen will work
|
* If it's not expected that capturing the screen will work
|
||||||
|
@ -114,20 +106,18 @@ export default class BasePlatform {
|
||||||
* Restarts the application, without neccessarily reloading
|
* Restarts the application, without neccessarily reloading
|
||||||
* any application code
|
* any application code
|
||||||
*/
|
*/
|
||||||
reload() {
|
abstract reload();
|
||||||
throw new Error("reload not implemented!");
|
|
||||||
}
|
|
||||||
|
|
||||||
supportsAutoLaunch(): boolean {
|
supportsAutoLaunch(): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX: Surely this should be a setting like any other?
|
// XXX: Surely this should be a setting like any other?
|
||||||
async getAutoLaunchEnabled(): boolean {
|
async getAutoLaunchEnabled(): Promise<boolean> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setAutoLaunchEnabled(enabled: boolean): void {
|
async setAutoLaunchEnabled(enabled: boolean): Promise<void> {
|
||||||
throw new Error("Unimplemented");
|
throw new Error("Unimplemented");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,11 +125,11 @@ export default class BasePlatform {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAutoHideMenuBarEnabled(): boolean {
|
async getAutoHideMenuBarEnabled(): Promise<boolean> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setAutoHideMenuBarEnabled(enabled: boolean): void {
|
async setAutoHideMenuBarEnabled(enabled: boolean): Promise<void> {
|
||||||
throw new Error("Unimplemented");
|
throw new Error("Unimplemented");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,11 +137,11 @@ export default class BasePlatform {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMinimizeToTrayEnabled(): boolean {
|
async getMinimizeToTrayEnabled(): Promise<boolean> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMinimizeToTrayEnabled(enabled: boolean): void {
|
async setMinimizeToTrayEnabled(enabled: boolean): Promise<void> {
|
||||||
throw new Error("Unimplemented");
|
throw new Error("Unimplemented");
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { asyncAction } from './actionCreators';
|
import { asyncAction } from './actionCreators';
|
||||||
import RoomListStore, { TAG_DM } from '../stores/RoomListStore';
|
import { TAG_DM } from '../stores/RoomListStore';
|
||||||
import Modal from '../Modal';
|
import Modal from '../Modal';
|
||||||
import * as Rooms from '../Rooms';
|
import * as Rooms from '../Rooms';
|
||||||
import { _t } from '../languageHandler';
|
import { _t } from '../languageHandler';
|
||||||
|
@ -24,6 +24,7 @@ import * as sdk from '../index';
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { AsyncActionPayload } from "../dispatcher/payloads";
|
import { AsyncActionPayload } from "../dispatcher/payloads";
|
||||||
|
import { RoomListStoreTempProxy } from "../stores/room-list/RoomListStoreTempProxy";
|
||||||
|
|
||||||
export default class RoomListActions {
|
export default class RoomListActions {
|
||||||
/**
|
/**
|
||||||
|
@ -51,7 +52,7 @@ export default class RoomListActions {
|
||||||
|
|
||||||
// Is the tag ordered manually?
|
// Is the tag ordered manually?
|
||||||
if (newTag && !newTag.match(/^(m\.lowpriority|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
|
if (newTag && !newTag.match(/^(m\.lowpriority|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
|
||||||
const lists = RoomListStore.getRoomLists();
|
const lists = RoomListStoreTempProxy.getRoomLists();
|
||||||
const newList = [...lists[newTag]];
|
const newList = [...lists[newTag]];
|
||||||
|
|
||||||
newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order);
|
newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order);
|
||||||
|
|
|
@ -26,6 +26,7 @@ import * as VectorConferenceHandler from '../../VectorConferenceHandler';
|
||||||
import SettingsStore from '../../settings/SettingsStore';
|
import SettingsStore from '../../settings/SettingsStore';
|
||||||
import {_t} from "../../languageHandler";
|
import {_t} from "../../languageHandler";
|
||||||
import Analytics from "../../Analytics";
|
import Analytics from "../../Analytics";
|
||||||
|
import RoomList2 from "../views/rooms/RoomList2";
|
||||||
|
|
||||||
|
|
||||||
const LeftPanel = createReactClass({
|
const LeftPanel = createReactClass({
|
||||||
|
@ -273,6 +274,29 @@ const LeftPanel = createReactClass({
|
||||||
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
|
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let roomList = null;
|
||||||
|
if (SettingsStore.isFeatureEnabled("feature_new_room_list")) {
|
||||||
|
roomList = <RoomList2
|
||||||
|
onKeyDown={this._onKeyDown}
|
||||||
|
resizeNotifier={this.props.resizeNotifier}
|
||||||
|
collapsed={this.props.collapsed}
|
||||||
|
searchFilter={this.state.searchFilter}
|
||||||
|
ref={this.collectRoomList}
|
||||||
|
onFocus={this._onFocus}
|
||||||
|
onBlur={this._onBlur}
|
||||||
|
/>;
|
||||||
|
} else {
|
||||||
|
roomList = <RoomList
|
||||||
|
onKeyDown={this._onKeyDown}
|
||||||
|
onFocus={this._onFocus}
|
||||||
|
onBlur={this._onBlur}
|
||||||
|
ref={this.collectRoomList}
|
||||||
|
resizeNotifier={this.props.resizeNotifier}
|
||||||
|
collapsed={this.props.collapsed}
|
||||||
|
searchFilter={this.state.searchFilter}
|
||||||
|
ConferenceHandler={VectorConferenceHandler} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClasses}>
|
<div className={containerClasses}>
|
||||||
{ tagPanelContainer }
|
{ tagPanelContainer }
|
||||||
|
@ -284,15 +308,7 @@ const LeftPanel = createReactClass({
|
||||||
{ exploreButton }
|
{ exploreButton }
|
||||||
{ searchBox }
|
{ searchBox }
|
||||||
</div>
|
</div>
|
||||||
<RoomList
|
{roomList}
|
||||||
onKeyDown={this._onKeyDown}
|
|
||||||
onFocus={this._onFocus}
|
|
||||||
onBlur={this._onBlur}
|
|
||||||
ref={this.collectRoomList}
|
|
||||||
resizeNotifier={this.props.resizeNotifier}
|
|
||||||
collapsed={this.props.collapsed}
|
|
||||||
searchFilter={this.state.searchFilter}
|
|
||||||
ConferenceHandler={VectorConferenceHandler} />
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -31,7 +31,6 @@ import dis from '../../dispatcher/dispatcher';
|
||||||
import sessionStore from '../../stores/SessionStore';
|
import sessionStore from '../../stores/SessionStore';
|
||||||
import {MatrixClientPeg, MatrixClientCreds} from '../../MatrixClientPeg';
|
import {MatrixClientPeg, MatrixClientCreds} from '../../MatrixClientPeg';
|
||||||
import SettingsStore from "../../settings/SettingsStore";
|
import SettingsStore from "../../settings/SettingsStore";
|
||||||
import RoomListStore from "../../stores/RoomListStore";
|
|
||||||
|
|
||||||
import TagOrderActions from '../../actions/TagOrderActions';
|
import TagOrderActions from '../../actions/TagOrderActions';
|
||||||
import RoomListActions from '../../actions/RoomListActions';
|
import RoomListActions from '../../actions/RoomListActions';
|
||||||
|
@ -42,6 +41,8 @@ import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
|
||||||
import HomePage from "./HomePage";
|
import HomePage from "./HomePage";
|
||||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||||
import PlatformPeg from "../../PlatformPeg";
|
import PlatformPeg from "../../PlatformPeg";
|
||||||
|
import { RoomListStoreTempProxy } from "../../stores/room-list/RoomListStoreTempProxy";
|
||||||
|
import { DefaultTagID } from "../../stores/room-list/models";
|
||||||
// We need to fetch each pinned message individually (if we don't already have it)
|
// We need to fetch each pinned message individually (if we don't already have it)
|
||||||
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
||||||
// NB. this is just for server notices rather than pinned messages in general.
|
// NB. this is just for server notices rather than pinned messages in general.
|
||||||
|
@ -297,18 +298,18 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
onRoomStateEvents = (ev, state) => {
|
onRoomStateEvents = (ev, state) => {
|
||||||
const roomLists = RoomListStore.getRoomLists();
|
const roomLists = RoomListStoreTempProxy.getRoomLists();
|
||||||
if (roomLists['m.server_notice'] && roomLists['m.server_notice'].some(r => r.roomId === ev.getRoomId())) {
|
if (roomLists[DefaultTagID.ServerNotice] && roomLists[DefaultTagID.ServerNotice].some(r => r.roomId === ev.getRoomId())) {
|
||||||
this._updateServerNoticeEvents();
|
this._updateServerNoticeEvents();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_updateServerNoticeEvents = async () => {
|
_updateServerNoticeEvents = async () => {
|
||||||
const roomLists = RoomListStore.getRoomLists();
|
const roomLists = RoomListStoreTempProxy.getRoomLists();
|
||||||
if (!roomLists['m.server_notice']) return [];
|
if (!roomLists[DefaultTagID.ServerNotice]) return [];
|
||||||
|
|
||||||
const pinnedEvents = [];
|
const pinnedEvents = [];
|
||||||
for (const room of roomLists['m.server_notice']) {
|
for (const room of roomLists[DefaultTagID.ServerNotice]) {
|
||||||
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
|
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
|
||||||
|
|
||||||
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
|
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
Copyright 2015, 2016 OpenMarket Ltd
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
Copyright 2018 New Vector Ltd
|
Copyright 2018 New Vector Ltd
|
||||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
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.
|
||||||
|
@ -17,220 +17,192 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import createReactClass from 'create-react-class';
|
|
||||||
import * as AvatarLogic from '../../../Avatar';
|
import * as AvatarLogic from '../../../Avatar';
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import AccessibleButton from '../elements/AccessibleButton';
|
import AccessibleButton from '../elements/AccessibleButton';
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
import {useEventEmitter} from "../../../hooks/useEventEmitter";
|
||||||
import {toPx} from "../../../utils/units";
|
import {toPx} from "../../../utils/units";
|
||||||
|
|
||||||
export default createReactClass({
|
const useImageUrl = ({url, urls, idName, name, defaultToInitialLetter}) => {
|
||||||
displayName: 'BaseAvatar',
|
const [imageUrls, setUrls] = useState([]);
|
||||||
|
const [urlsIndex, setIndex] = useState();
|
||||||
|
|
||||||
propTypes: {
|
const onError = () => {
|
||||||
name: PropTypes.string.isRequired, // The name (first initial used as default)
|
const nextIndex = urlsIndex + 1;
|
||||||
idName: PropTypes.string, // ID for generating hash colours
|
if (nextIndex < imageUrls.length) {
|
||||||
title: PropTypes.string, // onHover title text
|
// try the next one
|
||||||
url: PropTypes.string, // highest priority of them all, shortcut to set in urls[0]
|
setIndex(nextIndex);
|
||||||
urls: PropTypes.array, // [highest_priority, ... , lowest_priority]
|
|
||||||
width: PropTypes.number,
|
|
||||||
height: PropTypes.number,
|
|
||||||
// XXX resizeMethod not actually used.
|
|
||||||
resizeMethod: PropTypes.string,
|
|
||||||
defaultToInitialLetter: PropTypes.bool, // true to add default url
|
|
||||||
inputRef: PropTypes.oneOfType([
|
|
||||||
// Either a function
|
|
||||||
PropTypes.func,
|
|
||||||
// Or the instance of a DOM native element
|
|
||||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
|
|
||||||
statics: {
|
|
||||||
contextType: MatrixClientContext,
|
|
||||||
},
|
|
||||||
|
|
||||||
getDefaultProps: function() {
|
|
||||||
return {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
resizeMethod: 'crop',
|
|
||||||
defaultToInitialLetter: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
getInitialState: function() {
|
|
||||||
return this._getState(this.props);
|
|
||||||
},
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.unmounted = false;
|
|
||||||
this.context.on('sync', this.onClientSync);
|
|
||||||
},
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.unmounted = true;
|
|
||||||
this.context.removeListener('sync', this.onClientSync);
|
|
||||||
},
|
|
||||||
|
|
||||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
|
||||||
UNSAFE_componentWillReceiveProps: function(nextProps) {
|
|
||||||
// work out if we need to call setState (if the image URLs array has changed)
|
|
||||||
const newState = this._getState(nextProps);
|
|
||||||
const newImageUrls = newState.imageUrls;
|
|
||||||
const oldImageUrls = this.state.imageUrls;
|
|
||||||
if (newImageUrls.length !== oldImageUrls.length) {
|
|
||||||
this.setState(newState); // detected a new entry
|
|
||||||
} else {
|
|
||||||
// check each one to see if they are the same
|
|
||||||
for (let i = 0; i < newImageUrls.length; i++) {
|
|
||||||
if (oldImageUrls[i] !== newImageUrls[i]) {
|
|
||||||
this.setState(newState); // detected a diff
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
|
||||||
onClientSync: function(syncState, prevState) {
|
const defaultImageUrl = useMemo(() => AvatarLogic.defaultAvatarUrlForString(idName || name), [idName, name]);
|
||||||
if (this.unmounted) return;
|
|
||||||
|
|
||||||
// Consider the client reconnected if there is no error with syncing.
|
useEffect(() => {
|
||||||
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
|
|
||||||
const reconnected = syncState !== "ERROR" && prevState !== syncState;
|
|
||||||
if (reconnected &&
|
|
||||||
// Did we fall back?
|
|
||||||
this.state.urlsIndex > 0
|
|
||||||
) {
|
|
||||||
// Start from the highest priority URL again
|
|
||||||
this.setState({
|
|
||||||
urlsIndex: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
_getState: function(props) {
|
|
||||||
// work out the full set of urls to try to load. This is formed like so:
|
// work out the full set of urls to try to load. This is formed like so:
|
||||||
// imageUrls: [ props.url, props.urls, default image ]
|
// imageUrls: [ props.url, ...props.urls, default image ]
|
||||||
|
|
||||||
let urls = [];
|
let _urls = [];
|
||||||
if (!SettingsStore.getValue("lowBandwidth")) {
|
if (!SettingsStore.getValue("lowBandwidth")) {
|
||||||
urls = props.urls || [];
|
_urls = urls || [];
|
||||||
|
|
||||||
if (props.url) {
|
if (url) {
|
||||||
urls.unshift(props.url); // put in urls[0]
|
_urls.unshift(url); // put in urls[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let defaultImageUrl = null;
|
if (defaultToInitialLetter) {
|
||||||
if (props.defaultToInitialLetter) {
|
_urls.push(defaultImageUrl); // lowest priority
|
||||||
defaultImageUrl = AvatarLogic.defaultAvatarUrlForString(
|
|
||||||
props.idName || props.name,
|
|
||||||
);
|
|
||||||
urls.push(defaultImageUrl); // lowest priority
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// deduplicate URLs
|
// deduplicate URLs
|
||||||
urls = Array.from(new Set(urls));
|
_urls = Array.from(new Set(_urls));
|
||||||
|
|
||||||
return {
|
setIndex(0);
|
||||||
imageUrls: urls,
|
setUrls(_urls);
|
||||||
defaultImageUrl: defaultImageUrl,
|
}, [url, ...(urls || [])]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
urlsIndex: 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
onError: function(ev) {
|
const cli = useContext(MatrixClientContext);
|
||||||
const nextIndex = this.state.urlsIndex + 1;
|
const onClientSync = useCallback((syncState, prevState) => {
|
||||||
if (nextIndex < this.state.imageUrls.length) {
|
// Consider the client reconnected if there is no error with syncing.
|
||||||
// try the next one
|
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
|
||||||
this.setState({
|
const reconnected = syncState !== "ERROR" && prevState !== syncState;
|
||||||
urlsIndex: nextIndex,
|
if (reconnected && urlsIndex > 0 ) { // Did we fall back?
|
||||||
});
|
// Start from the highest priority URL again
|
||||||
|
setIndex(0);
|
||||||
}
|
}
|
||||||
},
|
}, [urlsIndex]);
|
||||||
|
useEventEmitter(cli, "sync", onClientSync);
|
||||||
|
|
||||||
render: function() {
|
const imageUrl = imageUrls[urlsIndex];
|
||||||
const imageUrl = this.state.imageUrls[this.state.urlsIndex];
|
return [imageUrl, imageUrl === defaultImageUrl, onError];
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const BaseAvatar = (props) => {
|
||||||
name, idName, title, url, urls, width, height, resizeMethod,
|
const {
|
||||||
defaultToInitialLetter, onClick, inputRef,
|
name,
|
||||||
...otherProps
|
idName,
|
||||||
} = this.props;
|
title,
|
||||||
|
url,
|
||||||
|
urls,
|
||||||
|
width=40,
|
||||||
|
height=40,
|
||||||
|
resizeMethod="crop", // eslint-disable-line no-unused-vars
|
||||||
|
defaultToInitialLetter=true,
|
||||||
|
onClick,
|
||||||
|
inputRef,
|
||||||
|
...otherProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [imageUrl, isDefault, onError] = useImageUrl({url, urls, idName, name, defaultToInitialLetter});
|
||||||
|
|
||||||
|
if (isDefault) {
|
||||||
|
const initialLetter = AvatarLogic.getInitialLetter(name);
|
||||||
|
const textNode = (
|
||||||
|
<span
|
||||||
|
className="mx_BaseAvatar_initial"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
fontSize: toPx(width * 0.65),
|
||||||
|
width: toPx(width),
|
||||||
|
lineHeight: toPx(height),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ initialLetter }
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const imgNode = (
|
||||||
|
<img
|
||||||
|
className="mx_BaseAvatar_image"
|
||||||
|
src={imageUrl}
|
||||||
|
alt=""
|
||||||
|
title={title}
|
||||||
|
onError={onError}
|
||||||
|
style={{
|
||||||
|
width: toPx(width),
|
||||||
|
height: toPx(height),
|
||||||
|
}}
|
||||||
|
aria-hidden="true" />
|
||||||
|
);
|
||||||
|
|
||||||
if (imageUrl === this.state.defaultImageUrl) {
|
|
||||||
const initialLetter = AvatarLogic.getInitialLetter(name);
|
|
||||||
const textNode = (
|
|
||||||
<span className="mx_BaseAvatar_initial" aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
fontSize: toPx(width * 0.65),
|
|
||||||
width: toPx(width),
|
|
||||||
lineHeight: toPx(height),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ initialLetter }
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
const imgNode = (
|
|
||||||
<img className="mx_BaseAvatar_image" src={imageUrl}
|
|
||||||
alt="" title={title} onError={this.onError}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
width: toPx(width),
|
|
||||||
height: toPx(height)
|
|
||||||
}} />
|
|
||||||
);
|
|
||||||
if (onClick != null) {
|
|
||||||
return (
|
|
||||||
<AccessibleButton element='span' className="mx_BaseAvatar"
|
|
||||||
onClick={onClick} inputRef={inputRef} {...otherProps}
|
|
||||||
>
|
|
||||||
{ textNode }
|
|
||||||
{ imgNode }
|
|
||||||
</AccessibleButton>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<span className="mx_BaseAvatar" ref={inputRef} {...otherProps}>
|
|
||||||
{ textNode }
|
|
||||||
{ imgNode }
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (onClick != null) {
|
if (onClick != null) {
|
||||||
return (
|
return (
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className="mx_BaseAvatar mx_BaseAvatar_image"
|
{...otherProps}
|
||||||
element='img'
|
element="span"
|
||||||
src={imageUrl}
|
className="mx_BaseAvatar"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onError={this.onError}
|
|
||||||
style={{
|
|
||||||
width: toPx(width),
|
|
||||||
height: toPx(height),
|
|
||||||
}}
|
|
||||||
title={title} alt=""
|
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
{...otherProps} />
|
>
|
||||||
|
{ textNode }
|
||||||
|
{ imgNode }
|
||||||
|
</AccessibleButton>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<img
|
<span className="mx_BaseAvatar" ref={inputRef} {...otherProps}>
|
||||||
className="mx_BaseAvatar mx_BaseAvatar_image"
|
{ textNode }
|
||||||
src={imageUrl}
|
{ imgNode }
|
||||||
onError={this.onError}
|
</span>
|
||||||
style={{
|
|
||||||
width: toPx(width),
|
|
||||||
height: toPx(height),
|
|
||||||
}}
|
|
||||||
title={title} alt=""
|
|
||||||
ref={inputRef}
|
|
||||||
{...otherProps} />
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
if (onClick != null) {
|
||||||
|
return (
|
||||||
|
<AccessibleButton
|
||||||
|
className="mx_BaseAvatar mx_BaseAvatar_image"
|
||||||
|
element='img'
|
||||||
|
src={imageUrl}
|
||||||
|
onClick={onClick}
|
||||||
|
onError={onError}
|
||||||
|
style={{
|
||||||
|
width: toPx(width),
|
||||||
|
height: toPx(height),
|
||||||
|
}}
|
||||||
|
title={title} alt=""
|
||||||
|
inputRef={inputRef}
|
||||||
|
{...otherProps} />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className="mx_BaseAvatar mx_BaseAvatar_image"
|
||||||
|
src={imageUrl}
|
||||||
|
onError={onError}
|
||||||
|
style={{
|
||||||
|
width: toPx(width),
|
||||||
|
height: toPx(height),
|
||||||
|
}}
|
||||||
|
title={title} alt=""
|
||||||
|
ref={inputRef}
|
||||||
|
{...otherProps} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
BaseAvatar.displayName = "BaseAvatar";
|
||||||
|
|
||||||
|
BaseAvatar.propTypes = {
|
||||||
|
name: PropTypes.string.isRequired, // The name (first initial used as default)
|
||||||
|
idName: PropTypes.string, // ID for generating hash colours
|
||||||
|
title: PropTypes.string, // onHover title text
|
||||||
|
url: PropTypes.string, // highest priority of them all, shortcut to set in urls[0]
|
||||||
|
urls: PropTypes.array, // [highest_priority, ... , lowest_priority]
|
||||||
|
width: PropTypes.number,
|
||||||
|
height: PropTypes.number,
|
||||||
|
// XXX resizeMethod not actually used.
|
||||||
|
resizeMethod: PropTypes.string,
|
||||||
|
defaultToInitialLetter: PropTypes.bool, // true to add default url
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
inputRef: PropTypes.oneOfType([
|
||||||
|
// Either a function
|
||||||
|
PropTypes.func,
|
||||||
|
// Or the instance of a DOM native element
|
||||||
|
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BaseAvatar;
|
||||||
|
|
|
@ -34,9 +34,10 @@ import {humanizeTime} from "../../../utils/humanize";
|
||||||
import createRoom, {canEncryptToAllUsers} from "../../../createRoom";
|
import createRoom, {canEncryptToAllUsers} from "../../../createRoom";
|
||||||
import {inviteMultipleToRoom} from "../../../RoomInvite";
|
import {inviteMultipleToRoom} from "../../../RoomInvite";
|
||||||
import SettingsStore from '../../../settings/SettingsStore';
|
import SettingsStore from '../../../settings/SettingsStore';
|
||||||
import RoomListStore, {TAG_DM} from "../../../stores/RoomListStore";
|
|
||||||
import {Key} from "../../../Keyboard";
|
import {Key} from "../../../Keyboard";
|
||||||
import {Action} from "../../../dispatcher/actions";
|
import {Action} from "../../../dispatcher/actions";
|
||||||
|
import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy";
|
||||||
|
import {DefaultTagID} from "../../../stores/room-list/models";
|
||||||
|
|
||||||
export const KIND_DM = "dm";
|
export const KIND_DM = "dm";
|
||||||
export const KIND_INVITE = "invite";
|
export const KIND_INVITE = "invite";
|
||||||
|
@ -344,10 +345,10 @@ export default class InviteDialog extends React.PureComponent {
|
||||||
_buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
|
_buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
|
||||||
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
|
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
|
||||||
|
|
||||||
// Also pull in all the rooms tagged as TAG_DM so we don't miss anything. Sometimes the
|
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
|
||||||
// room list doesn't tag the room for the DMRoomMap, but does for the room list.
|
// room list doesn't tag the room for the DMRoomMap, but does for the room list.
|
||||||
const taggedRooms = RoomListStore.getRoomLists();
|
const taggedRooms = RoomListStoreTempProxy.getRoomLists();
|
||||||
const dmTaggedRooms = taggedRooms[TAG_DM];
|
const dmTaggedRooms = taggedRooms[DefaultTagID.DM];
|
||||||
const myUserId = MatrixClientPeg.get().getUserId();
|
const myUserId = MatrixClientPeg.get().getUserId();
|
||||||
for (const dmRoom of dmTaggedRooms) {
|
for (const dmRoom of dmTaggedRooms) {
|
||||||
const otherMembers = dmRoom.getJoinedMembers().filter(u => u.userId !== myUserId);
|
const otherMembers = dmRoom.getJoinedMembers().filter(u => u.userId !== myUserId);
|
||||||
|
|
|
@ -29,7 +29,6 @@ import rate_limited_func from "../../../ratelimitedfunc";
|
||||||
import * as Rooms from '../../../Rooms';
|
import * as Rooms from '../../../Rooms';
|
||||||
import DMRoomMap from '../../../utils/DMRoomMap';
|
import DMRoomMap from '../../../utils/DMRoomMap';
|
||||||
import TagOrderStore from '../../../stores/TagOrderStore';
|
import TagOrderStore from '../../../stores/TagOrderStore';
|
||||||
import RoomListStore, {TAG_DM} from '../../../stores/RoomListStore';
|
|
||||||
import CustomRoomTagStore from '../../../stores/CustomRoomTagStore';
|
import CustomRoomTagStore from '../../../stores/CustomRoomTagStore';
|
||||||
import GroupStore from '../../../stores/GroupStore';
|
import GroupStore from '../../../stores/GroupStore';
|
||||||
import RoomSubList from '../../structures/RoomSubList';
|
import RoomSubList from '../../structures/RoomSubList';
|
||||||
|
@ -41,8 +40,11 @@ import * as Receipt from "../../../utils/Receipt";
|
||||||
import {Resizer} from '../../../resizer';
|
import {Resizer} from '../../../resizer';
|
||||||
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
|
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
|
||||||
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
|
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
|
||||||
|
import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy";
|
||||||
|
import {DefaultTagID} from "../../../stores/room-list/models";
|
||||||
import * as Unread from "../../../Unread";
|
import * as Unread from "../../../Unread";
|
||||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||||
|
import {TAG_DM} from "../../../stores/RoomListStore";
|
||||||
|
|
||||||
const HIDE_CONFERENCE_CHANS = true;
|
const HIDE_CONFERENCE_CHANS = true;
|
||||||
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
||||||
|
@ -161,7 +163,7 @@ export default createReactClass({
|
||||||
this.updateVisibleRooms();
|
this.updateVisibleRooms();
|
||||||
});
|
});
|
||||||
|
|
||||||
this._roomListStoreToken = RoomListStore.addListener(() => {
|
this._roomListStoreToken = RoomListStoreTempProxy.addListener(() => {
|
||||||
this._delayedRefreshRoomList();
|
this._delayedRefreshRoomList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -521,7 +523,7 @@ export default createReactClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
getTagNameForRoomId: function(roomId) {
|
getTagNameForRoomId: function(roomId) {
|
||||||
const lists = RoomListStore.getRoomLists();
|
const lists = RoomListStoreTempProxy.getRoomLists();
|
||||||
for (const tagName of Object.keys(lists)) {
|
for (const tagName of Object.keys(lists)) {
|
||||||
for (const room of lists[tagName]) {
|
for (const room of lists[tagName]) {
|
||||||
// Should be impossible, but guard anyways.
|
// Should be impossible, but guard anyways.
|
||||||
|
@ -541,7 +543,7 @@ export default createReactClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
getRoomLists: function() {
|
getRoomLists: function() {
|
||||||
const lists = RoomListStore.getRoomLists();
|
const lists = RoomListStoreTempProxy.getRoomLists();
|
||||||
|
|
||||||
const filteredLists = {};
|
const filteredLists = {};
|
||||||
|
|
||||||
|
@ -773,10 +775,10 @@ export default createReactClass({
|
||||||
incomingCall: incomingCallIfTaggedAs('m.favourite'),
|
incomingCall: incomingCallIfTaggedAs('m.favourite'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
list: this.state.lists[TAG_DM],
|
list: this.state.lists[DefaultTagID.DM],
|
||||||
label: _t('Direct Messages'),
|
label: _t('Direct Messages'),
|
||||||
tagName: TAG_DM,
|
tagName: DefaultTagID.DM,
|
||||||
incomingCall: incomingCallIfTaggedAs(TAG_DM),
|
incomingCall: incomingCallIfTaggedAs(DefaultTagID.DM),
|
||||||
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});},
|
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});},
|
||||||
addRoomLabel: _t("Start chat"),
|
addRoomLabel: _t("Start chat"),
|
||||||
},
|
},
|
||||||
|
|
246
src/components/views/rooms/RoomList2.tsx
Normal file
246
src/components/views/rooms/RoomList2.tsx
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
Copyright 2017, 2018 Vector Creations Ltd
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { _t, _td } from "../../../languageHandler";
|
||||||
|
import { Layout } from '../../../resizer/distributors/roomsublist2';
|
||||||
|
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
||||||
|
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
|
||||||
|
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore2";
|
||||||
|
import { ITagMap } from "../../../stores/room-list/algorithms/models";
|
||||||
|
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||||
|
import { Dispatcher } from "flux";
|
||||||
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
|
import RoomSublist2 from "./RoomSublist2";
|
||||||
|
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||||
|
|
||||||
|
/*******************************************************************
|
||||||
|
* CAUTION *
|
||||||
|
*******************************************************************
|
||||||
|
* This is a work in progress implementation and isn't complete or *
|
||||||
|
* even useful as a component. Please avoid using it until this *
|
||||||
|
* warning disappears. *
|
||||||
|
*******************************************************************/
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
onKeyDown: (ev: React.KeyboardEvent) => void;
|
||||||
|
onFocus: (ev: React.FocusEvent) => void;
|
||||||
|
onBlur: (ev: React.FocusEvent) => void;
|
||||||
|
resizeNotifier: ResizeNotifier;
|
||||||
|
collapsed: boolean;
|
||||||
|
searchFilter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
sublists: ITagMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_ORDER: TagID[] = [
|
||||||
|
// -- Community Invites Placeholder --
|
||||||
|
|
||||||
|
DefaultTagID.Invite,
|
||||||
|
DefaultTagID.Favourite,
|
||||||
|
DefaultTagID.DM,
|
||||||
|
DefaultTagID.Untagged,
|
||||||
|
|
||||||
|
// -- Custom Tags Placeholder --
|
||||||
|
|
||||||
|
DefaultTagID.LowPriority,
|
||||||
|
DefaultTagID.ServerNotice,
|
||||||
|
DefaultTagID.Archived,
|
||||||
|
];
|
||||||
|
const COMMUNITY_TAGS_BEFORE_TAG = DefaultTagID.Invite;
|
||||||
|
const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority;
|
||||||
|
const ALWAYS_VISIBLE_TAGS: TagID[] = [
|
||||||
|
DefaultTagID.DM,
|
||||||
|
DefaultTagID.Untagged,
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ITagAesthetics {
|
||||||
|
sectionLabel: string;
|
||||||
|
addRoomLabel?: string;
|
||||||
|
onAddRoom?: (dispatcher: Dispatcher<ActionPayload>) => void;
|
||||||
|
isInvite: boolean;
|
||||||
|
defaultHidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_AESTHETICS: {
|
||||||
|
// @ts-ignore - TS wants this to be a string but we know better
|
||||||
|
[tagId: TagID]: ITagAesthetics;
|
||||||
|
} = {
|
||||||
|
[DefaultTagID.Invite]: {
|
||||||
|
sectionLabel: _td("Invites"),
|
||||||
|
isInvite: true,
|
||||||
|
defaultHidden: false,
|
||||||
|
},
|
||||||
|
[DefaultTagID.Favourite]: {
|
||||||
|
sectionLabel: _td("Favourites"),
|
||||||
|
isInvite: false,
|
||||||
|
defaultHidden: false,
|
||||||
|
},
|
||||||
|
[DefaultTagID.DM]: {
|
||||||
|
sectionLabel: _td("Direct Messages"),
|
||||||
|
isInvite: false,
|
||||||
|
defaultHidden: false,
|
||||||
|
addRoomLabel: _td("Start chat"),
|
||||||
|
onAddRoom: (dispatcher: Dispatcher<ActionPayload>) => dispatcher.dispatch({action: 'view_create_chat'}),
|
||||||
|
},
|
||||||
|
[DefaultTagID.Untagged]: {
|
||||||
|
sectionLabel: _td("Rooms"),
|
||||||
|
isInvite: false,
|
||||||
|
defaultHidden: false,
|
||||||
|
addRoomLabel: _td("Create room"),
|
||||||
|
onAddRoom: (dispatcher: Dispatcher<ActionPayload>) => dispatcher.dispatch({action: 'view_create_room'}),
|
||||||
|
},
|
||||||
|
[DefaultTagID.LowPriority]: {
|
||||||
|
sectionLabel: _td("Low priority"),
|
||||||
|
isInvite: false,
|
||||||
|
defaultHidden: false,
|
||||||
|
},
|
||||||
|
[DefaultTagID.ServerNotice]: {
|
||||||
|
sectionLabel: _td("System Alerts"),
|
||||||
|
isInvite: false,
|
||||||
|
defaultHidden: false,
|
||||||
|
},
|
||||||
|
[DefaultTagID.Archived]: {
|
||||||
|
sectionLabel: _td("Historical"),
|
||||||
|
isInvite: false,
|
||||||
|
defaultHidden: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class RoomList2 extends React.Component<IProps, IState> {
|
||||||
|
private sublistRefs: { [tagId: string]: React.RefObject<RoomSublist2> } = {};
|
||||||
|
private sublistSizes: { [tagId: string]: number } = {};
|
||||||
|
private sublistCollapseStates: { [tagId: string]: boolean } = {};
|
||||||
|
private unfilteredLayout: Layout;
|
||||||
|
private filteredLayout: Layout;
|
||||||
|
|
||||||
|
constructor(props: IProps) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {sublists: {}};
|
||||||
|
this.loadSublistSizes();
|
||||||
|
this.prepareLayouts();
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidMount(): void {
|
||||||
|
RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store) => {
|
||||||
|
console.log("new lists", store.orderedLists);
|
||||||
|
this.setState({sublists: store.orderedLists});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadSublistSizes() {
|
||||||
|
const sizesJson = window.localStorage.getItem("mx_roomlist_sizes");
|
||||||
|
if (sizesJson) this.sublistSizes = JSON.parse(sizesJson);
|
||||||
|
|
||||||
|
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
|
||||||
|
if (collapsedJson) this.sublistCollapseStates = JSON.parse(collapsedJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveSublistSizes() {
|
||||||
|
window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.sublistSizes));
|
||||||
|
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.sublistCollapseStates));
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareLayouts() {
|
||||||
|
// TODO: Change layout engine for FTUE support
|
||||||
|
this.unfilteredLayout = new Layout((tagId: string, height: number) => {
|
||||||
|
const sublist = this.sublistRefs[tagId];
|
||||||
|
if (sublist) sublist.current.setHeight(height);
|
||||||
|
|
||||||
|
// TODO: Check overflow (see old impl)
|
||||||
|
|
||||||
|
// Don't store a height for collapsed sublists
|
||||||
|
if (!this.sublistCollapseStates[tagId]) {
|
||||||
|
this.sublistSizes[tagId] = height;
|
||||||
|
this.saveSublistSizes();
|
||||||
|
}
|
||||||
|
}, this.sublistSizes, this.sublistCollapseStates, {
|
||||||
|
allowWhitespace: false,
|
||||||
|
handleHeight: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.filteredLayout = new Layout((tagId: string, height: number) => {
|
||||||
|
const sublist = this.sublistRefs[tagId];
|
||||||
|
if (sublist) sublist.current.setHeight(height);
|
||||||
|
}, null, null, {
|
||||||
|
allowWhitespace: false,
|
||||||
|
handleHeight: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSublists(): React.ReactElement[] {
|
||||||
|
const components: React.ReactElement[] = [];
|
||||||
|
|
||||||
|
for (const orderedTagId of TAG_ORDER) {
|
||||||
|
if (COMMUNITY_TAGS_BEFORE_TAG === orderedTagId) {
|
||||||
|
// Populate community invites if we have the chance
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) {
|
||||||
|
// Populate custom tags if needed
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedRooms = this.state.sublists[orderedTagId] || [];
|
||||||
|
if (orderedRooms.length === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
|
||||||
|
continue; // skip tag - not needed
|
||||||
|
}
|
||||||
|
|
||||||
|
const aesthetics: ITagAesthetics = TAG_AESTHETICS[orderedTagId];
|
||||||
|
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
|
||||||
|
|
||||||
|
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
|
||||||
|
components.push(<RoomSublist2
|
||||||
|
key={`sublist-${orderedTagId}`}
|
||||||
|
forRooms={true}
|
||||||
|
rooms={orderedRooms}
|
||||||
|
startAsHidden={aesthetics.defaultHidden}
|
||||||
|
label={_t(aesthetics.sectionLabel)}
|
||||||
|
onAddRoom={onAddRoomFn}
|
||||||
|
addRoomLabel={aesthetics.addRoomLabel}
|
||||||
|
isInvite={aesthetics.isInvite}
|
||||||
|
/>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return components;
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const sublists = this.renderSublists();
|
||||||
|
return (
|
||||||
|
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={this.props.onKeyDown}>
|
||||||
|
{({onKeyDownHandler}) => (
|
||||||
|
<div
|
||||||
|
onFocus={this.props.onFocus}
|
||||||
|
onBlur={this.props.onBlur}
|
||||||
|
onKeyDown={onKeyDownHandler}
|
||||||
|
className="mx_RoomList"
|
||||||
|
role="tree"
|
||||||
|
aria-label={_t("Rooms")}
|
||||||
|
// Firefox sometimes makes this element focusable due to
|
||||||
|
// overflow:scroll;, so force it out of tab order.
|
||||||
|
tabIndex={-1}
|
||||||
|
>{sublists}</div>
|
||||||
|
)}
|
||||||
|
</RovingTabIndexProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
226
src/components/views/rooms/RoomSublist2.tsx
Normal file
226
src/components/views/rooms/RoomSublist2.tsx
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
Copyright 2017, 2018 Vector Creations Ltd
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { createRef } from "react";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
|
||||||
|
import * as RoomNotifs from '../../../RoomNotifs';
|
||||||
|
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||||
|
import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton";
|
||||||
|
import * as FormattingUtils from '../../../utils/FormattingUtils';
|
||||||
|
import RoomTile2 from "./RoomTile2";
|
||||||
|
|
||||||
|
/*******************************************************************
|
||||||
|
* CAUTION *
|
||||||
|
*******************************************************************
|
||||||
|
* This is a work in progress implementation and isn't complete or *
|
||||||
|
* even useful as a component. Please avoid using it until this *
|
||||||
|
* warning disappears. *
|
||||||
|
*******************************************************************/
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
forRooms: boolean;
|
||||||
|
rooms?: Room[];
|
||||||
|
startAsHidden: boolean;
|
||||||
|
label: string;
|
||||||
|
onAddRoom?: () => void;
|
||||||
|
addRoomLabel: string;
|
||||||
|
isInvite: boolean;
|
||||||
|
|
||||||
|
// TODO: Collapsed state
|
||||||
|
// TODO: Height
|
||||||
|
// TODO: Group invites
|
||||||
|
// TODO: Calls
|
||||||
|
// TODO: forceExpand?
|
||||||
|
// TODO: Header clicking
|
||||||
|
// TODO: Spinner support for historical
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
|
private headerButton = createRef();
|
||||||
|
|
||||||
|
public setHeight(size: number) {
|
||||||
|
// TODO: Do a thing (maybe - height changes are different in FTUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasTiles(): boolean {
|
||||||
|
return this.numTiles > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get numTiles(): number {
|
||||||
|
// TODO: Account for group invites
|
||||||
|
return (this.props.rooms || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onAddRoom = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.props.onAddRoom) this.props.onAddRoom();
|
||||||
|
};
|
||||||
|
|
||||||
|
private renderTiles(): React.ReactElement[] {
|
||||||
|
const tiles: React.ReactElement[] = [];
|
||||||
|
|
||||||
|
if (this.props.rooms) {
|
||||||
|
for (const room of this.props.rooms) {
|
||||||
|
tiles.push(<RoomTile2 room={room} key={`room-${room.roomId}`}/>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderHeader(): React.ReactElement {
|
||||||
|
const notifications = !this.props.isInvite
|
||||||
|
? RoomNotifs.aggregateNotificationCount(this.props.rooms)
|
||||||
|
: {count: 0, highlight: true};
|
||||||
|
const notifCount = notifications.count;
|
||||||
|
const notifHighlight = notifications.highlight;
|
||||||
|
|
||||||
|
// TODO: Title on collapsed
|
||||||
|
// TODO: Incoming call box
|
||||||
|
|
||||||
|
let chevron = null;
|
||||||
|
if (this.hasTiles()) {
|
||||||
|
const chevronClasses = classNames({
|
||||||
|
'mx_RoomSubList_chevron': true,
|
||||||
|
'mx_RoomSubList_chevronRight': false, // isCollapsed
|
||||||
|
'mx_RoomSubList_chevronDown': true, // !isCollapsed
|
||||||
|
});
|
||||||
|
chevron = (<div className={chevronClasses}/>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RovingTabIndexWrapper inputRef={this.headerButton}>
|
||||||
|
{({onFocus, isActive, ref}) => {
|
||||||
|
// TODO: Use onFocus
|
||||||
|
const tabIndex = isActive ? 0 : -1;
|
||||||
|
|
||||||
|
// TODO: Collapsed state
|
||||||
|
let badge;
|
||||||
|
if (true) { // !isCollapsed
|
||||||
|
const badgeClasses = classNames({
|
||||||
|
'mx_RoomSubList_badge': true,
|
||||||
|
'mx_RoomSubList_badgeHighlight': notifHighlight,
|
||||||
|
});
|
||||||
|
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
|
||||||
|
if (notifCount > 0) {
|
||||||
|
badge = (
|
||||||
|
<AccessibleButton
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
className={badgeClasses}
|
||||||
|
aria-label={_t("Jump to first unread room.")}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{FormattingUtils.formatCount(notifCount)}
|
||||||
|
</div>
|
||||||
|
</AccessibleButton>
|
||||||
|
);
|
||||||
|
} else if (this.props.isInvite && this.hasTiles()) {
|
||||||
|
// Render the `!` badge for invites
|
||||||
|
badge = (
|
||||||
|
<AccessibleButton
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
className={badgeClasses}
|
||||||
|
aria-label={_t("Jump to first invite.")}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{FormattingUtils.formatCount(this.numTiles)}
|
||||||
|
</div>
|
||||||
|
</AccessibleButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let addRoomButton = null;
|
||||||
|
if (!!this.props.onAddRoom) {
|
||||||
|
addRoomButton = (
|
||||||
|
<AccessibleTooltipButton
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
onClick={this.onAddRoom}
|
||||||
|
className="mx_RoomSubList_addRoom"
|
||||||
|
title={this.props.addRoomLabel || _t("Add room")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: a11y (see old component)
|
||||||
|
return (
|
||||||
|
<div className={"mx_RoomSubList_labelContainer"}>
|
||||||
|
<AccessibleButton
|
||||||
|
inputRef={ref}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
className={"mx_RoomSubList_label"}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level="1"
|
||||||
|
>
|
||||||
|
{chevron}
|
||||||
|
<span>{this.props.label}</span>
|
||||||
|
</AccessibleButton>
|
||||||
|
{badge}
|
||||||
|
{addRoomButton}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</RovingTabIndexWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): React.ReactElement {
|
||||||
|
// TODO: Proper rendering
|
||||||
|
// TODO: Error boundary
|
||||||
|
|
||||||
|
const tiles = this.renderTiles();
|
||||||
|
|
||||||
|
const classes = classNames({
|
||||||
|
// TODO: Proper collapse support
|
||||||
|
'mx_RoomSubList': true,
|
||||||
|
'mx_RoomSubList_hidden': false, // len && isCollapsed
|
||||||
|
'mx_RoomSubList_nonEmpty': this.hasTiles(), // len && !isCollapsed
|
||||||
|
});
|
||||||
|
|
||||||
|
let content = null;
|
||||||
|
if (tiles.length > 0) {
|
||||||
|
// TODO: Lazy list rendering
|
||||||
|
// TODO: Whatever scrolling magic needs to happen here
|
||||||
|
content = (
|
||||||
|
<IndicatorScrollbar className='mx_RoomSubList_scroll'>
|
||||||
|
{tiles}
|
||||||
|
</IndicatorScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: onKeyDown support
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes}
|
||||||
|
role="group"
|
||||||
|
aria-label={this.props.label}
|
||||||
|
>
|
||||||
|
{this.renderHeader()}
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
255
src/components/views/rooms/RoomTile2.tsx
Normal file
255
src/components/views/rooms/RoomTile2.tsx
Normal file
|
@ -0,0 +1,255 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
Copyright 2017 New Vector Ltd
|
||||||
|
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
|
||||||
|
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createRef } from "react";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||||
|
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||||
|
import RoomAvatar from "../../views/avatars/RoomAvatar";
|
||||||
|
import Tooltip from "../../views/elements/Tooltip";
|
||||||
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
|
import { Key } from "../../../Keyboard";
|
||||||
|
import * as RoomNotifs from '../../../RoomNotifs';
|
||||||
|
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
|
||||||
|
import * as Unread from '../../../Unread';
|
||||||
|
import * as FormattingUtils from "../../../utils/FormattingUtils";
|
||||||
|
|
||||||
|
/*******************************************************************
|
||||||
|
* CAUTION *
|
||||||
|
*******************************************************************
|
||||||
|
* This is a work in progress implementation and isn't complete or *
|
||||||
|
* even useful as a component. Please avoid using it until this *
|
||||||
|
* warning disappears. *
|
||||||
|
*******************************************************************/
|
||||||
|
|
||||||
|
enum NotificationColor {
|
||||||
|
// Inverted (None -> Red) because we do integer comparisons on this
|
||||||
|
None, // nothing special
|
||||||
|
Bold, // no badge, show as unread
|
||||||
|
Grey, // unread notified messages
|
||||||
|
Red, // unread pings
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
room: Room;
|
||||||
|
|
||||||
|
// TODO: Allow falsifying counts (for invites and stuff)
|
||||||
|
// TODO: Transparency? Was this ever used?
|
||||||
|
// TODO: Incoming call boxes?
|
||||||
|
}
|
||||||
|
|
||||||
|
interface INotificationState {
|
||||||
|
symbol: string;
|
||||||
|
color: NotificationColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
hover: boolean;
|
||||||
|
notificationState: INotificationState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
|
private roomTile = createRef();
|
||||||
|
|
||||||
|
// TODO: Custom status
|
||||||
|
// TODO: Lock icon
|
||||||
|
// TODO: Presence indicator
|
||||||
|
// TODO: e2e shields
|
||||||
|
// TODO: Handle changes to room aesthetics (name, join rules, etc)
|
||||||
|
// TODO: scrollIntoView?
|
||||||
|
// TODO: hover, badge, etc
|
||||||
|
// TODO: isSelected for hover effects
|
||||||
|
// TODO: Context menu
|
||||||
|
// TODO: a11y
|
||||||
|
|
||||||
|
constructor(props: IProps) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
hover: false,
|
||||||
|
notificationState: this.getNotificationState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentWillUnmount() {
|
||||||
|
// TODO: Listen for changes to the badge count and update as needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// XXX: This is a bit of an awful-looking hack. We should probably be using state for
|
||||||
|
// this, but instead we're kinda forced to either duplicate the code or thread a variable
|
||||||
|
// through the code paths. This feels like the least evil option.
|
||||||
|
private get roomIsInvite(): boolean {
|
||||||
|
return getEffectiveMembership(this.props.room.getMyMembership()) === EffectiveMembership.Invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Make use of this function when the notification state needs updating.
|
||||||
|
private updateNotificationState() {
|
||||||
|
this.setState({notificationState: this.getNotificationState()});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNotificationState(): INotificationState {
|
||||||
|
const state: INotificationState = {
|
||||||
|
color: NotificationColor.None,
|
||||||
|
symbol: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.roomIsInvite) {
|
||||||
|
state.color = NotificationColor.Red;
|
||||||
|
state.symbol = "!";
|
||||||
|
} else {
|
||||||
|
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'highlight');
|
||||||
|
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'total');
|
||||||
|
|
||||||
|
// For a 'true count' we pick the grey notifications first because they include the
|
||||||
|
// red notifications. If we don't have a grey count for some reason we use the red
|
||||||
|
// count. If that count is broken for some reason, assume zero. This avoids us showing
|
||||||
|
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
|
||||||
|
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
|
||||||
|
|
||||||
|
// Note: we only set the symbol if we have an actual count. We don't want to show
|
||||||
|
// zero on badges.
|
||||||
|
|
||||||
|
if (redNotifs > 0) {
|
||||||
|
state.color = NotificationColor.Red;
|
||||||
|
state.symbol = FormattingUtils.formatCount(trueCount);
|
||||||
|
} else if (greyNotifs > 0) {
|
||||||
|
state.color = NotificationColor.Grey;
|
||||||
|
state.symbol = FormattingUtils.formatCount(trueCount);
|
||||||
|
} else {
|
||||||
|
// We don't have any notified messages, but we might have unread messages. Let's
|
||||||
|
// find out.
|
||||||
|
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.props.room);
|
||||||
|
if (hasUnread) {
|
||||||
|
state.color = NotificationColor.Bold;
|
||||||
|
// no symbol for this state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onTileMouseEnter = () => {
|
||||||
|
this.setState({hover: true});
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTileMouseLeave = () => {
|
||||||
|
this.setState({hover: false});
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTileClick = (ev: React.KeyboardEvent) => {
|
||||||
|
dis.dispatch({
|
||||||
|
action: 'view_room',
|
||||||
|
// TODO: Support show_room_tile in new room list
|
||||||
|
show_room_tile: true, // make sure the room is visible in the list
|
||||||
|
room_id: this.props.room.roomId,
|
||||||
|
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
public render(): React.ReactElement {
|
||||||
|
// TODO: Collapsed state
|
||||||
|
// TODO: Invites
|
||||||
|
// TODO: a11y proper
|
||||||
|
// TODO: Render more than bare minimum
|
||||||
|
|
||||||
|
const hasBadge = this.state.notificationState.color > NotificationColor.Bold;
|
||||||
|
const isUnread = this.state.notificationState.color > NotificationColor.None;
|
||||||
|
const classes = classNames({
|
||||||
|
'mx_RoomTile': true,
|
||||||
|
// 'mx_RoomTile_selected': this.state.selected,
|
||||||
|
'mx_RoomTile_unread': isUnread,
|
||||||
|
'mx_RoomTile_unreadNotify': this.state.notificationState.color >= NotificationColor.Grey,
|
||||||
|
'mx_RoomTile_highlight': this.state.notificationState.color >= NotificationColor.Red,
|
||||||
|
'mx_RoomTile_invited': this.roomIsInvite,
|
||||||
|
// 'mx_RoomTile_menuDisplayed': isMenuDisplayed,
|
||||||
|
'mx_RoomTile_noBadges': !hasBadge,
|
||||||
|
// 'mx_RoomTile_transparent': this.props.transparent,
|
||||||
|
// 'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatarClasses = classNames({
|
||||||
|
'mx_RoomTile_avatar': true,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
let badge;
|
||||||
|
if (hasBadge) {
|
||||||
|
const badgeClasses = classNames({
|
||||||
|
'mx_RoomTile_badge': true,
|
||||||
|
'mx_RoomTile_badgeButton': false, // this.state.badgeHover || isMenuDisplayed
|
||||||
|
});
|
||||||
|
badge = <div className={badgeClasses}>{this.state.notificationState.symbol}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: the original RoomTile uses state for the room name. Do we need to?
|
||||||
|
let name = this.props.room.name;
|
||||||
|
if (typeof name !== 'string') name = '';
|
||||||
|
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||||
|
|
||||||
|
const nameClasses = classNames({
|
||||||
|
'mx_RoomTile_name': true,
|
||||||
|
'mx_RoomTile_invite': this.roomIsInvite,
|
||||||
|
'mx_RoomTile_badgeShown': hasBadge,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Support collapsed state properly
|
||||||
|
let tooltip = null;
|
||||||
|
if (false) { // isCollapsed
|
||||||
|
if (this.state.hover) {
|
||||||
|
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto"/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<RovingTabIndexWrapper inputRef={this.roomTile}>
|
||||||
|
{({onFocus, isActive, ref}) =>
|
||||||
|
<AccessibleButton
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
inputRef={ref}
|
||||||
|
className={classes}
|
||||||
|
onMouseEnter={this.onTileMouseEnter}
|
||||||
|
onMouseLeave={this.onTileMouseLeave}
|
||||||
|
onClick={this.onTileClick}
|
||||||
|
role="treeitem"
|
||||||
|
>
|
||||||
|
<div className={avatarClasses}>
|
||||||
|
<div className="mx_RoomTile_avatar_container">
|
||||||
|
<RoomAvatar room={this.props.room} width={24} height={24}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mx_RoomTile_nameContainer">
|
||||||
|
<div className="mx_RoomTile_labelContainer">
|
||||||
|
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
{tooltip}
|
||||||
|
</AccessibleButton>
|
||||||
|
}
|
||||||
|
</RovingTabIndexWrapper>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -407,6 +407,7 @@
|
||||||
"Render simple counters in room header": "Render simple counters in room header",
|
"Render simple counters in room header": "Render simple counters in room header",
|
||||||
"Multiple integration managers": "Multiple integration managers",
|
"Multiple integration managers": "Multiple integration managers",
|
||||||
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
|
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
|
||||||
|
"Use the improved room list (in development - refresh to apply changes)": "Use the improved room list (in development - refresh to apply changes)",
|
||||||
"Support adding custom themes": "Support adding custom themes",
|
"Support adding custom themes": "Support adding custom themes",
|
||||||
"Use IRC layout": "Use IRC layout",
|
"Use IRC layout": "Use IRC layout",
|
||||||
"Enable cross-signing to verify per-user instead of per-session": "Enable cross-signing to verify per-user instead of per-session",
|
"Enable cross-signing to verify per-user instead of per-session": "Enable cross-signing to verify per-user instead of per-session",
|
||||||
|
@ -1169,6 +1170,9 @@
|
||||||
"Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>",
|
"Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>",
|
||||||
"Not now": "Not now",
|
"Not now": "Not now",
|
||||||
"Don't ask me again": "Don't ask me again",
|
"Don't ask me again": "Don't ask me again",
|
||||||
|
"Jump to first unread room.": "Jump to first unread room.",
|
||||||
|
"Jump to first invite.": "Jump to first invite.",
|
||||||
|
"Add room": "Add room",
|
||||||
"Options": "Options",
|
"Options": "Options",
|
||||||
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
|
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
|
||||||
"%(count)s unread messages including mentions.|one": "1 unread mention.",
|
"%(count)s unread messages including mentions.|one": "1 unread mention.",
|
||||||
|
@ -2060,9 +2064,6 @@
|
||||||
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
|
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
|
||||||
"Active call": "Active call",
|
"Active call": "Active call",
|
||||||
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
|
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
|
||||||
"Jump to first unread room.": "Jump to first unread room.",
|
|
||||||
"Jump to first invite.": "Jump to first invite.",
|
|
||||||
"Add room": "Add room",
|
|
||||||
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
|
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
|
||||||
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
|
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
|
||||||
"Search failed": "Search failed",
|
"Search failed": "Search failed",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
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.
|
||||||
|
@ -20,7 +20,7 @@ export interface MatrixEvent {
|
||||||
content: {};
|
content: {};
|
||||||
event_id: string;
|
event_id: string;
|
||||||
origin_server_ts: number;
|
origin_server_ts: number;
|
||||||
unsigned: ?{};
|
unsigned?: {};
|
||||||
room_id: string;
|
room_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ export interface SearchArgs {
|
||||||
before_limit: number;
|
before_limit: number;
|
||||||
after_limit: number;
|
after_limit: number;
|
||||||
order_by_recency: boolean;
|
order_by_recency: boolean;
|
||||||
room_id: ?string;
|
room_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventAndProfile {
|
export interface EventAndProfile {
|
||||||
|
@ -85,7 +85,7 @@ export interface IndexStats {
|
||||||
*
|
*
|
||||||
* Instances of this class are provided by the application.
|
* Instances of this class are provided by the application.
|
||||||
*/
|
*/
|
||||||
export default class BaseEventIndexManager {
|
export default abstract class BaseEventIndexManager {
|
||||||
/**
|
/**
|
||||||
* Does our EventIndexManager support event indexing.
|
* Does our EventIndexManager support event indexing.
|
||||||
*
|
*
|
||||||
|
@ -119,7 +119,7 @@ export default class BaseEventIndexManager {
|
||||||
* @return {Promise} A promise that will resolve when the was queued up for
|
* @return {Promise} A promise that will resolve when the was queued up for
|
||||||
* addition.
|
* addition.
|
||||||
*/
|
*/
|
||||||
async addEventToIndex(ev: MatrixEvent, profile: MatrixProfile): Promise<> {
|
async addEventToIndex(ev: MatrixEvent, profile: MatrixProfile): Promise<void> {
|
||||||
throw new Error("Unimplemented");
|
throw new Error("Unimplemented");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,7 +188,7 @@ export default class BaseEventIndexManager {
|
||||||
events: [EventAndProfile],
|
events: [EventAndProfile],
|
||||||
checkpoint: CrawlerCheckpoint | null,
|
checkpoint: CrawlerCheckpoint | null,
|
||||||
oldCheckpoint: CrawlerCheckpoint | null,
|
oldCheckpoint: CrawlerCheckpoint | null,
|
||||||
): Promise<bool> {
|
): Promise<boolean> {
|
||||||
throw new Error("Unimplemented");
|
throw new Error("Unimplemented");
|
||||||
}
|
}
|
||||||
|
|
|
@ -138,6 +138,12 @@ export const SETTINGS = {
|
||||||
supportedLevels: LEVELS_FEATURE,
|
supportedLevels: LEVELS_FEATURE,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
"feature_new_room_list": {
|
||||||
|
isFeature: true,
|
||||||
|
displayName: _td("Use the improved room list (in development - refresh to apply changes)"),
|
||||||
|
supportedLevels: LEVELS_FEATURE,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
"feature_custom_themes": {
|
"feature_custom_themes": {
|
||||||
isFeature: true,
|
isFeature: true,
|
||||||
displayName: _td("Support adding custom themes"),
|
displayName: _td("Support adding custom themes"),
|
||||||
|
|
107
src/stores/AsyncStore.ts
Normal file
107
src/stores/AsyncStore.ts
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import AwaitLock from 'await-lock';
|
||||||
|
import { Dispatcher } from "flux";
|
||||||
|
import { ActionPayload } from "../dispatcher/payloads";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event/channel to listen for in an AsyncStore.
|
||||||
|
*/
|
||||||
|
export const UPDATE_EVENT = "update";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a minimal store which works similar to Flux stores. Instead
|
||||||
|
* of everything needing to happen in a dispatch cycle, everything can
|
||||||
|
* happen async to that cycle.
|
||||||
|
*
|
||||||
|
* The store operates by using Object.assign() to mutate state - it sends the
|
||||||
|
* state objects (current and new) through the function onto a new empty
|
||||||
|
* object. Because of this, it is recommended to break out your state to be as
|
||||||
|
* safe as possible. The state mutations are also locked, preventing concurrent
|
||||||
|
* writes.
|
||||||
|
*
|
||||||
|
* All updates to the store happen on the UPDATE_EVENT event channel with the
|
||||||
|
* one argument being the instance of the store.
|
||||||
|
*
|
||||||
|
* To update the state, use updateState() and preferably await the result to
|
||||||
|
* help prevent lock conflicts.
|
||||||
|
*/
|
||||||
|
export abstract class AsyncStore<T extends Object> extends EventEmitter {
|
||||||
|
private storeState: T = <T>{};
|
||||||
|
private lock = new AwaitLock();
|
||||||
|
private readonly dispatcherRef: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new AsyncStore using the given dispatcher.
|
||||||
|
* @param {Dispatcher<ActionPayload>} dispatcher The dispatcher to rely upon.
|
||||||
|
*/
|
||||||
|
protected constructor(private dispatcher: Dispatcher<ActionPayload>) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.dispatcherRef = dispatcher.register(this.onDispatch.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current state of the store. Cannot be mutated.
|
||||||
|
*/
|
||||||
|
protected get state(): T {
|
||||||
|
return Object.freeze(this.storeState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the store's listening functions, such as the listener to the dispatcher.
|
||||||
|
*/
|
||||||
|
protected stop() {
|
||||||
|
if (this.dispatcherRef) this.dispatcher.unregister(this.dispatcherRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the state of the store.
|
||||||
|
* @param {T|*} newState The state to update in the store using Object.assign()
|
||||||
|
*/
|
||||||
|
protected async updateState(newState: T | Object) {
|
||||||
|
await this.lock.acquireAsync();
|
||||||
|
try {
|
||||||
|
this.storeState = Object.assign(<T>{}, this.storeState, newState);
|
||||||
|
this.emit(UPDATE_EVENT, this);
|
||||||
|
} finally {
|
||||||
|
await this.lock.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the store's to the provided state or an empty object.
|
||||||
|
* @param {T|*} newState The new state of the store.
|
||||||
|
* @param {boolean} quiet If true, the function will not raise an UPDATE_EVENT.
|
||||||
|
*/
|
||||||
|
protected async reset(newState: T | Object = null, quiet = false) {
|
||||||
|
await this.lock.acquireAsync();
|
||||||
|
try {
|
||||||
|
this.storeState = <T>(newState || {});
|
||||||
|
if (!quiet) this.emit(UPDATE_EVENT, this);
|
||||||
|
} finally {
|
||||||
|
await this.lock.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the dispatcher broadcasts a dispatch event.
|
||||||
|
* @param {ActionPayload} payload The event being dispatched.
|
||||||
|
*/
|
||||||
|
protected abstract onDispatch(payload: ActionPayload);
|
||||||
|
}
|
|
@ -15,10 +15,10 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
import dis from '../dispatcher/dispatcher';
|
import dis from '../dispatcher/dispatcher';
|
||||||
import * as RoomNotifs from '../RoomNotifs';
|
import * as RoomNotifs from '../RoomNotifs';
|
||||||
import RoomListStore from './RoomListStore';
|
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
import { throttle } from "lodash";
|
import { throttle } from "lodash";
|
||||||
import SettingsStore from "../settings/SettingsStore";
|
import SettingsStore from "../settings/SettingsStore";
|
||||||
|
import {RoomListStoreTempProxy} from "./room-list/RoomListStoreTempProxy";
|
||||||
|
|
||||||
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ class CustomRoomTagStore extends EventEmitter {
|
||||||
trailing: true,
|
trailing: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this._roomListStoreToken = RoomListStore.addListener(() => {
|
this._roomListStoreToken = RoomListStoreTempProxy.addListener(() => {
|
||||||
this._setState({tags: this._getUpdatedTags()});
|
this._setState({tags: this._getUpdatedTags()});
|
||||||
});
|
});
|
||||||
dis.register(payload => this._onDispatch(payload));
|
dis.register(payload => this._onDispatch(payload));
|
||||||
|
@ -85,7 +85,7 @@ class CustomRoomTagStore extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
getSortedTags() {
|
getSortedTags() {
|
||||||
const roomLists = RoomListStore.getRoomLists();
|
const roomLists = RoomListStoreTempProxy.getRoomLists();
|
||||||
|
|
||||||
const tagNames = Object.keys(this._state.tags).sort();
|
const tagNames = Object.keys(this._state.tags).sort();
|
||||||
const prefixes = tagNames.map((name, i) => {
|
const prefixes = tagNames.map((name, i) => {
|
||||||
|
@ -140,7 +140,7 @@ class CustomRoomTagStore extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTagNames = Object.keys(RoomListStore.getRoomLists())
|
const newTagNames = Object.keys(RoomListStoreTempProxy.getRoomLists())
|
||||||
.filter((tagName) => {
|
.filter((tagName) => {
|
||||||
return !tagName.match(STANDARD_TAGS_REGEX);
|
return !tagName.match(STANDARD_TAGS_REGEX);
|
||||||
}).sort();
|
}).sort();
|
||||||
|
|
|
@ -92,11 +92,19 @@ class RoomListStore extends Store {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(dis);
|
super(dis);
|
||||||
|
|
||||||
|
this._checkDisabled();
|
||||||
this._init();
|
this._init();
|
||||||
this._getManualComparator = this._getManualComparator.bind(this);
|
this._getManualComparator = this._getManualComparator.bind(this);
|
||||||
this._recentsComparator = this._recentsComparator.bind(this);
|
this._recentsComparator = this._recentsComparator.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_checkDisabled() {
|
||||||
|
this.disabled = SettingsStore.isFeatureEnabled("feature_new_room_list");
|
||||||
|
if (this.disabled) {
|
||||||
|
console.warn("👋 legacy room list store has been disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes the sorting algorithm used by the RoomListStore.
|
* Changes the sorting algorithm used by the RoomListStore.
|
||||||
* @param {string} algorithm The new algorithm to use. Should be one of the ALGO_* constants.
|
* @param {string} algorithm The new algorithm to use. Should be one of the ALGO_* constants.
|
||||||
|
@ -113,6 +121,8 @@ class RoomListStore extends Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
_init() {
|
_init() {
|
||||||
|
if (this.disabled) return;
|
||||||
|
|
||||||
// Initialise state
|
// Initialise state
|
||||||
const defaultLists = {
|
const defaultLists = {
|
||||||
"m.server_notice": [/* { room: js-sdk room, category: string } */],
|
"m.server_notice": [/* { room: js-sdk room, category: string } */],
|
||||||
|
@ -140,6 +150,8 @@ class RoomListStore extends Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
_setState(newState) {
|
_setState(newState) {
|
||||||
|
if (this.disabled) return;
|
||||||
|
|
||||||
// If we're changing the lists, transparently change the presentation lists (which
|
// If we're changing the lists, transparently change the presentation lists (which
|
||||||
// is given to requesting components). This dramatically simplifies our code elsewhere
|
// 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
|
// while also ensuring we don't need to update all the calling components to support
|
||||||
|
@ -156,6 +168,8 @@ class RoomListStore extends Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
__onDispatch(payload) {
|
__onDispatch(payload) {
|
||||||
|
if (this.disabled) return;
|
||||||
|
|
||||||
const logicallyReady = this._matrixClient && this._state.ready;
|
const logicallyReady = this._matrixClient && this._state.ready;
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case 'setting_updated': {
|
case 'setting_updated': {
|
||||||
|
@ -182,6 +196,9 @@ class RoomListStore extends Store {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._checkDisabled();
|
||||||
|
if (this.disabled) return;
|
||||||
|
|
||||||
// Always ensure that we set any state needed for settings here. It is possible that
|
// Always ensure that we set any state needed for settings here. It is possible that
|
||||||
// setting updates trigger on startup before we are ready to sync, so we want to make
|
// setting updates trigger on startup before we are ready to sync, so we want to make
|
||||||
// sure that the right state is in place before we actually react to those changes.
|
// sure that the right state is in place before we actually react to those changes.
|
||||||
|
|
125
src/stores/room-list/README.md
Normal file
125
src/stores/room-list/README.md
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
# Room list sorting
|
||||||
|
|
||||||
|
It's so complicated it needs its own README.
|
||||||
|
|
||||||
|
## Algorithms involved
|
||||||
|
|
||||||
|
There's two main kinds of algorithms involved in the room list store: list ordering and tag sorting.
|
||||||
|
Throughout the code an intentional decision has been made to call them the List Algorithm and Sorting
|
||||||
|
Algorithm respectively. The list algorithm determines the behaviour of the room list whereas the sorting
|
||||||
|
algorithm determines how rooms get ordered within tags affected by the list algorithm.
|
||||||
|
|
||||||
|
Behaviour of the room list takes the shape of determining what features the room list supports, as well
|
||||||
|
as determining where and when to apply the sorting algorithm in a tag. The importance algorithm, which
|
||||||
|
is described later in this doc, is an example of an algorithm which makes heavy behavioural changes
|
||||||
|
to the room list.
|
||||||
|
|
||||||
|
Tag sorting is effectively the comparator supplied to the list algorithm. This gives the list algorithm
|
||||||
|
the power to decide when and how to apply the tag sorting, if at all.
|
||||||
|
|
||||||
|
### Tag sorting algorithm: Alphabetical
|
||||||
|
|
||||||
|
When used, rooms in a given tag will be sorted alphabetically, where the alphabet's order is a problem
|
||||||
|
for the browser. All we do is a simple string comparison and expect the browser to return something
|
||||||
|
useful.
|
||||||
|
|
||||||
|
### Tag sorting algorithm: Manual
|
||||||
|
|
||||||
|
Manual sorting makes use of the `order` property present on all tags for a room, per the
|
||||||
|
[Matrix specification](https://matrix.org/docs/spec/client_server/r0.6.0#room-tagging). Smaller values
|
||||||
|
of `order` cause rooms to appear closer to the top of the list.
|
||||||
|
|
||||||
|
### Tag sorting algorithm: Recent
|
||||||
|
|
||||||
|
Rooms get ordered by the timestamp of the most recent useful message. Usefulness is yet another algorithm
|
||||||
|
in the room list system which determines whether an event type is capable of bubbling up in the room list.
|
||||||
|
Normally events like room messages, stickers, and room security changes will be considered useful enough
|
||||||
|
to cause a shift in time.
|
||||||
|
|
||||||
|
Note that this is reliant on the event timestamps of the most recent message. Because Matrix is eventually
|
||||||
|
consistent this means that from time to time a room might plummet or skyrocket across the tag due to the
|
||||||
|
timestamp contained within the event (generated server-side by the sender's server).
|
||||||
|
|
||||||
|
### List ordering algorithm: Natural
|
||||||
|
|
||||||
|
This is the easiest of the algorithms to understand because it does essentially nothing. It imposes no
|
||||||
|
behavioural changes over the tag sorting algorithm and is by far the simplest way to order a room list.
|
||||||
|
Historically, it's been the only option in Riot and extremely common in most chat applications due to
|
||||||
|
its relative deterministic behaviour.
|
||||||
|
|
||||||
|
### List ordering algorithm: Importance
|
||||||
|
|
||||||
|
On the other end of the spectrum, this is the most complicated algorithm which exists. There's major
|
||||||
|
behavioural changes, and the tag sorting algorithm gets selectively applied depending on circumstances.
|
||||||
|
|
||||||
|
Each tag which is not manually ordered gets split into 4 sections or "categories". Manually ordered tags
|
||||||
|
simply get the manual sorting algorithm applied to them with no further involvement from the importance
|
||||||
|
algorithm. There are 4 categories: Red, Grey, Bold, and Idle. Each has their own definition based off
|
||||||
|
relative (perceived) importance to the user:
|
||||||
|
|
||||||
|
* **Red**: The room has unread mentions waiting for the user.
|
||||||
|
* **Grey**: The room has unread notifications waiting for the user. Notifications are simply unread
|
||||||
|
messages which cause a push notification or badge count. Typically, this is the default as rooms get
|
||||||
|
set to 'All Messages'.
|
||||||
|
* **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without
|
||||||
|
a badge/notification count (or 'Mentions Only'/'Muted').
|
||||||
|
* **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user
|
||||||
|
last read it.
|
||||||
|
|
||||||
|
Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey
|
||||||
|
above bold, etc.
|
||||||
|
|
||||||
|
Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm
|
||||||
|
gets applied to each category in a sub-sub-list fashion. This should result in the red rooms (for example)
|
||||||
|
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
|
||||||
|
collectively the tag will be sorted into categories with red being at the top.
|
||||||
|
|
||||||
|
<!-- TODO: Implement sticky rooms as described below -->
|
||||||
|
|
||||||
|
The algorithm also has a concept of a 'sticky' room which is the room the user is currently viewing.
|
||||||
|
The sticky room will remain in position on the room list regardless of other factors going on as typically
|
||||||
|
clicking on a room will cause it to change categories into 'idle'. This is done by preserving N rooms
|
||||||
|
above the selected room at all times, where N is the number of rooms above the selected rooms when it was
|
||||||
|
selected.
|
||||||
|
|
||||||
|
For example, if the user has 3 red rooms and selects the middle room, they will always see exactly one
|
||||||
|
room above their selection at all times. If they receive another notification, and the tag ordering is
|
||||||
|
specified as Recent, they'll see the new notification go to the top position, and the one that was previously
|
||||||
|
there fall behind the sticky room.
|
||||||
|
|
||||||
|
The sticky room's category is technically 'idle' while being viewed and is explicitly pulled out of the
|
||||||
|
tag sorting algorithm's input as it must maintain its position in the list. When the user moves to another
|
||||||
|
room, the previous sticky room gets recalculated to determine which category it needs to be in as the user
|
||||||
|
could have been scrolled up while new messages were received.
|
||||||
|
|
||||||
|
Further, the sticky room is not aware of category boundaries and thus the user can see a shift in what
|
||||||
|
kinds of rooms move around their selection. An example would be the user having 4 red rooms, the user
|
||||||
|
selecting the third room (leaving 2 above it), and then having the rooms above it read on another device.
|
||||||
|
This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain
|
||||||
|
2 rooms above the sticky room.
|
||||||
|
|
||||||
|
An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement
|
||||||
|
exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain
|
||||||
|
the N required rooms above the sticky room. In this case, the sticky room will simply decrease N as needed.
|
||||||
|
The N value will never increase while selection remains unchanged: adding a bunch of rooms after having
|
||||||
|
put the sticky room in a position where it's had to decrease N will not increase N.
|
||||||
|
|
||||||
|
## Responsibilities of the store
|
||||||
|
|
||||||
|
The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets
|
||||||
|
an object containing the tags it needs to worry about and the rooms within. The room list component will
|
||||||
|
decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with
|
||||||
|
all kinds of filtering.
|
||||||
|
|
||||||
|
## Class breakdowns
|
||||||
|
|
||||||
|
The `RoomListStore` is the major coordinator of various `Algorithm` implementations, which take care
|
||||||
|
of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` superclass is also
|
||||||
|
responsible for figuring out which tags get which rooms, as Matrix specifies them as a reverse map:
|
||||||
|
tags get defined on rooms and are not defined as a collection of rooms (unlike how they are presented
|
||||||
|
to the user). Various list-specific utilities are also included, though they are expected to move
|
||||||
|
somewhere more general when needed. For example, the `membership` utilities could easily be moved
|
||||||
|
elsewhere as needed.
|
||||||
|
|
||||||
|
The various bits throughout the room list store should also have jsdoc of some kind to help describe
|
||||||
|
what they do and how they work.
|
253
src/stores/room-list/RoomListStore2.ts
Normal file
253
src/stores/room-list/RoomListStore2.ts
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
/*
|
||||||
|
Copyright 2018, 2019 New Vector Ltd
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
|
import SettingsStore from "../../settings/SettingsStore";
|
||||||
|
import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
|
||||||
|
import { Algorithm } from "./algorithms/list-ordering/Algorithm";
|
||||||
|
import TagOrderStore from "../TagOrderStore";
|
||||||
|
import { AsyncStore } from "../AsyncStore";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
|
||||||
|
import { getListAlgorithmInstance } from "./algorithms/list-ordering";
|
||||||
|
import { ActionPayload } from "../../dispatcher/payloads";
|
||||||
|
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
tagsEnabled?: boolean;
|
||||||
|
|
||||||
|
preferredSort?: SortAlgorithm;
|
||||||
|
preferredAlgorithm?: ListAlgorithm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event/channel which is called when the room lists have been changed. Raised
|
||||||
|
* with one argument: the instance of the store.
|
||||||
|
*/
|
||||||
|
export const LISTS_UPDATE_EVENT = "lists_update";
|
||||||
|
|
||||||
|
class _RoomListStore extends AsyncStore<ActionPayload> {
|
||||||
|
private matrixClient: MatrixClient;
|
||||||
|
private initialListsGenerated = false;
|
||||||
|
private enabled = false;
|
||||||
|
private algorithm: Algorithm;
|
||||||
|
|
||||||
|
private readonly watchedSettings = [
|
||||||
|
'RoomList.orderAlphabetically',
|
||||||
|
'RoomList.orderByImportance',
|
||||||
|
'feature_custom_tags',
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(defaultDispatcher);
|
||||||
|
|
||||||
|
this.checkEnabled();
|
||||||
|
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get orderedLists(): ITagMap {
|
||||||
|
if (!this.algorithm) return {}; // No tags yet.
|
||||||
|
return this.algorithm.getOrderedRooms();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Remove enabled flag when the old RoomListStore goes away
|
||||||
|
private checkEnabled() {
|
||||||
|
this.enabled = SettingsStore.isFeatureEnabled("feature_new_room_list");
|
||||||
|
if (this.enabled) {
|
||||||
|
console.log("⚡ new room list store engaged");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readAndCacheSettingsFromStore() {
|
||||||
|
const tagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
|
||||||
|
const orderByImportance = SettingsStore.getValue("RoomList.orderByImportance");
|
||||||
|
const orderAlphabetically = SettingsStore.getValue("RoomList.orderAlphabetically");
|
||||||
|
await this.updateState({
|
||||||
|
tagsEnabled,
|
||||||
|
preferredSort: orderAlphabetically ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent,
|
||||||
|
preferredAlgorithm: orderByImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural,
|
||||||
|
});
|
||||||
|
this.setAlgorithmClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onDispatch(payload: ActionPayload) {
|
||||||
|
if (payload.action === 'MatrixActions.sync') {
|
||||||
|
// Filter out anything that isn't the first PREPARED sync.
|
||||||
|
if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Remove this once the RoomListStore becomes default
|
||||||
|
this.checkEnabled();
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
this.matrixClient = payload.matrixClient;
|
||||||
|
|
||||||
|
// Update any settings here, as some may have happened before we were logically ready.
|
||||||
|
console.log("Regenerating room lists: Startup");
|
||||||
|
await this.readAndCacheSettingsFromStore();
|
||||||
|
await this.regenerateAllLists();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Remove this once the RoomListStore becomes default
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
|
||||||
|
// Reset state without causing updates as the client will have been destroyed
|
||||||
|
// and downstream code will throw NPE errors.
|
||||||
|
this.reset(null, true);
|
||||||
|
this.matrixClient = null;
|
||||||
|
this.initialListsGenerated = false; // we'll want to regenerate them
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything below here requires a MatrixClient or some sort of logical readiness.
|
||||||
|
const logicallyReady = this.matrixClient && this.initialListsGenerated;
|
||||||
|
if (!logicallyReady) return;
|
||||||
|
|
||||||
|
if (payload.action === 'setting_updated') {
|
||||||
|
if (this.watchedSettings.includes(payload.settingName)) {
|
||||||
|
console.log("Regenerating room lists: Settings changed");
|
||||||
|
await this.readAndCacheSettingsFromStore();
|
||||||
|
|
||||||
|
await this.regenerateAllLists(); // regenerate the lists now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.algorithm) {
|
||||||
|
// This shouldn't happen because `initialListsGenerated` implies we have an algorithm.
|
||||||
|
throw new Error("Room list store has no algorithm to process dispatcher update with");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.action === 'MatrixActions.Room.receipt') {
|
||||||
|
// 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).
|
||||||
|
// noinspection JSObjectNullOrUndefined - this.matrixClient can't be null by this point in the lifecycle
|
||||||
|
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)) {
|
||||||
|
// TODO: Update room now that it's been read
|
||||||
|
console.log(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (payload.action === 'MatrixActions.Room.tags') {
|
||||||
|
// TODO: Update room from tags
|
||||||
|
console.log(payload);
|
||||||
|
} else if (payload.action === 'MatrixActions.Room.timeline') {
|
||||||
|
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||||
|
|
||||||
|
// Ignore non-live events (backfill)
|
||||||
|
if (!eventPayload.isLiveEvent || !payload.isLiveUnfilteredRoomTimelineEvent) return;
|
||||||
|
|
||||||
|
const roomId = eventPayload.event.getRoomId();
|
||||||
|
const room = this.matrixClient.getRoom(roomId);
|
||||||
|
console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()} in ${roomId}`);
|
||||||
|
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
|
||||||
|
} else if (payload.action === 'MatrixActions.Event.decrypted') {
|
||||||
|
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||||
|
const roomId = eventPayload.event.getRoomId();
|
||||||
|
const room = this.matrixClient.getRoom(roomId);
|
||||||
|
if (!room) {
|
||||||
|
console.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`);
|
||||||
|
// TODO: Check that e2e rooms are calculated correctly on initial load.
|
||||||
|
// It seems like when viewing the room the timeline is decrypted, rather than at startup. This could
|
||||||
|
// cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :(
|
||||||
|
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
|
||||||
|
} else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
|
||||||
|
// TODO: Update DMs
|
||||||
|
console.log(payload);
|
||||||
|
} else if (payload.action === 'MatrixActions.Room.myMembership') {
|
||||||
|
// TODO: Update room from membership change
|
||||||
|
console.log(payload);
|
||||||
|
} else if (payload.action === 'MatrixActions.Room') {
|
||||||
|
// TODO: Update room from creation/join
|
||||||
|
console.log(payload);
|
||||||
|
} else if (payload.action === 'view_room') {
|
||||||
|
// TODO: Update sticky room
|
||||||
|
console.log(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> {
|
||||||
|
const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause);
|
||||||
|
if (shouldUpdate) {
|
||||||
|
console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`);
|
||||||
|
this.emit(LISTS_UPDATE_EVENT, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSortAlgorithmFor(tagId: TagID): SortAlgorithm {
|
||||||
|
switch (tagId) {
|
||||||
|
case DefaultTagID.Invite:
|
||||||
|
case DefaultTagID.Untagged:
|
||||||
|
case DefaultTagID.Archived:
|
||||||
|
case DefaultTagID.LowPriority:
|
||||||
|
case DefaultTagID.DM:
|
||||||
|
return this.state.preferredSort;
|
||||||
|
case DefaultTagID.Favourite:
|
||||||
|
default:
|
||||||
|
return SortAlgorithm.Manual;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async updateState(newState: IState) {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
await super.updateState(newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAlgorithmClass() {
|
||||||
|
this.algorithm = getListAlgorithmInstance(this.state.preferredAlgorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async regenerateAllLists() {
|
||||||
|
console.warn("Regenerating all room lists");
|
||||||
|
const tags: ITagSortingMap = {};
|
||||||
|
for (const tagId of OrderedDefaultTagIDs) {
|
||||||
|
tags[tagId] = this.getSortAlgorithmFor(tagId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.tagsEnabled) {
|
||||||
|
// TODO: Find a more reliable way to get tags (this doesn't work)
|
||||||
|
const roomTags = TagOrderStore.getOrderedTags() || [];
|
||||||
|
console.log("rtags", roomTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.algorithm.populateTags(tags);
|
||||||
|
await this.algorithm.setKnownRooms(this.matrixClient.getRooms());
|
||||||
|
|
||||||
|
this.initialListsGenerated = true;
|
||||||
|
|
||||||
|
this.emit(LISTS_UPDATE_EVENT, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class RoomListStore {
|
||||||
|
private static internalInstance: _RoomListStore;
|
||||||
|
|
||||||
|
public static get instance(): _RoomListStore {
|
||||||
|
if (!RoomListStore.internalInstance) {
|
||||||
|
RoomListStore.internalInstance = new _RoomListStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
return RoomListStore.internalInstance;
|
||||||
|
}
|
||||||
|
}
|
49
src/stores/room-list/RoomListStoreTempProxy.ts
Normal file
49
src/stores/room-list/RoomListStoreTempProxy.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SettingsStore from "../../settings/SettingsStore";
|
||||||
|
import RoomListStore from "./RoomListStore2";
|
||||||
|
import OldRoomListStore from "../RoomListStore";
|
||||||
|
import { UPDATE_EVENT } from "../AsyncStore";
|
||||||
|
import { ITagMap } from "./algorithms/models";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary RoomListStore proxy. Should be replaced with RoomListStore2 when
|
||||||
|
* it is available to everyone.
|
||||||
|
*
|
||||||
|
* TODO: Remove this when RoomListStore gets fully replaced.
|
||||||
|
*/
|
||||||
|
export class RoomListStoreTempProxy {
|
||||||
|
public static isUsingNewStore(): boolean {
|
||||||
|
return SettingsStore.isFeatureEnabled("feature_new_room_list");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static addListener(handler: () => void) {
|
||||||
|
if (RoomListStoreTempProxy.isUsingNewStore()) {
|
||||||
|
return RoomListStore.instance.on(UPDATE_EVENT, handler);
|
||||||
|
} else {
|
||||||
|
return OldRoomListStore.addListener(handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getRoomLists(): ITagMap {
|
||||||
|
if (RoomListStoreTempProxy.isUsingNewStore()) {
|
||||||
|
return RoomListStore.instance.orderedLists;
|
||||||
|
} else {
|
||||||
|
return OldRoomListStore.getRoomLists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
177
src/stores/room-list/algorithms/list-ordering/Algorithm.ts
Normal file
177
src/stores/room-list/algorithms/list-ordering/Algorithm.ts
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DefaultTagID, RoomUpdateCause, TagID } from "../../models";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
|
||||||
|
import { EffectiveMembership, splitRoomsByMembership } from "../../membership";
|
||||||
|
import { ITagMap, ITagSortingMap } from "../models";
|
||||||
|
import DMRoomMap from "../../../../utils/DMRoomMap";
|
||||||
|
|
||||||
|
// TODO: Add locking support to avoid concurrent writes?
|
||||||
|
// TODO: EventEmitter support? Might not be needed.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a list ordering algorithm. This class will take care of tag
|
||||||
|
* management (which rooms go in which tags) and ask the implementation to
|
||||||
|
* deal with ordering mechanics.
|
||||||
|
*/
|
||||||
|
export abstract class Algorithm {
|
||||||
|
protected cached: ITagMap = {};
|
||||||
|
protected sortAlgorithms: ITagSortingMap;
|
||||||
|
protected rooms: Room[] = [];
|
||||||
|
protected roomIdsToTags: {
|
||||||
|
[roomId: string]: TagID[];
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
protected constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the Algorithm to regenerate all lists, using the tags given
|
||||||
|
* as reference for which lists to generate and which way to generate
|
||||||
|
* them.
|
||||||
|
* @param {ITagSortingMap} tagSortingMap The tags to generate.
|
||||||
|
* @returns {Promise<*>} A promise which resolves when complete.
|
||||||
|
*/
|
||||||
|
public async populateTags(tagSortingMap: ITagSortingMap): Promise<any> {
|
||||||
|
if (!tagSortingMap) throw new Error(`Map cannot be null or empty`);
|
||||||
|
this.sortAlgorithms = tagSortingMap;
|
||||||
|
return this.setKnownRooms(this.rooms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an ordered set of rooms for the all known tags.
|
||||||
|
* @returns {ITagMap} The cached list of rooms, ordered,
|
||||||
|
* for each tag. May be empty, but never null/undefined.
|
||||||
|
*/
|
||||||
|
public getOrderedRooms(): ITagMap {
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the Algorithm with a set of rooms. The algorithm will discard all
|
||||||
|
* previously known information and instead use these rooms instead.
|
||||||
|
* @param {Room[]} rooms The rooms to force the algorithm to use.
|
||||||
|
* @returns {Promise<*>} A promise which resolves when complete.
|
||||||
|
*/
|
||||||
|
public async setKnownRooms(rooms: Room[]): Promise<any> {
|
||||||
|
if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`);
|
||||||
|
if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`);
|
||||||
|
|
||||||
|
this.rooms = rooms;
|
||||||
|
|
||||||
|
const newTags: ITagMap = {};
|
||||||
|
for (const tagId in this.sortAlgorithms) {
|
||||||
|
// noinspection JSUnfilteredForInLoop
|
||||||
|
newTags[tagId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can avoid doing work, do so.
|
||||||
|
if (!rooms.length) {
|
||||||
|
await this.generateFreshTags(newTags); // just in case it wants to do something
|
||||||
|
this.cached = newTags;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split out the easy rooms first (leave and invite)
|
||||||
|
const memberships = splitRoomsByMembership(rooms);
|
||||||
|
for (const room of memberships[EffectiveMembership.Invite]) {
|
||||||
|
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is an Invite`);
|
||||||
|
newTags[DefaultTagID.Invite].push(room);
|
||||||
|
}
|
||||||
|
for (const room of memberships[EffectiveMembership.Leave]) {
|
||||||
|
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Historical`);
|
||||||
|
newTags[DefaultTagID.Archived].push(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now process all the joined rooms. This is a bit more complicated
|
||||||
|
for (const room of memberships[EffectiveMembership.Join]) {
|
||||||
|
let tags = Object.keys(room.tags || {});
|
||||||
|
|
||||||
|
if (tags.length === 0) {
|
||||||
|
// Check to see if it's a DM if it isn't anything else
|
||||||
|
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
|
||||||
|
tags = [DefaultTagID.DM];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let inTag = false;
|
||||||
|
if (tags.length > 0) {
|
||||||
|
for (const tag of tags) {
|
||||||
|
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged as ${tag}`);
|
||||||
|
if (!isNullOrUndefined(newTags[tag])) {
|
||||||
|
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged with VALID tag ${tag}`);
|
||||||
|
newTags[tag].push(room);
|
||||||
|
inTag = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inTag) {
|
||||||
|
// TODO: Determine if DM and push there instead
|
||||||
|
newTags[DefaultTagID.Untagged].push(room);
|
||||||
|
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.generateFreshTags(newTags);
|
||||||
|
|
||||||
|
this.cached = newTags;
|
||||||
|
this.updateTagsFromCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the roomsToTags map
|
||||||
|
*/
|
||||||
|
protected updateTagsFromCache() {
|
||||||
|
const newMap = {};
|
||||||
|
|
||||||
|
const tags = Object.keys(this.cached);
|
||||||
|
for (const tagId of tags) {
|
||||||
|
const rooms = this.cached[tagId];
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (!newMap[room.roomId]) newMap[room.roomId] = [];
|
||||||
|
newMap[room.roomId].push(tagId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.roomIdsToTags = newMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the Algorithm believes a complete regeneration of the existing
|
||||||
|
* lists is needed.
|
||||||
|
* @param {ITagMap} updatedTagMap The tag map which needs populating. Each tag
|
||||||
|
* will already have the rooms which belong to it - they just need ordering. Must
|
||||||
|
* be mutated in place.
|
||||||
|
* @returns {Promise<*>} A promise which resolves when complete.
|
||||||
|
*/
|
||||||
|
protected abstract generateFreshTags(updatedTagMap: ITagMap): Promise<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the Algorithm to update its knowledge of a room. For example, when
|
||||||
|
* a user tags a room, joins/creates a room, or leaves a room the Algorithm
|
||||||
|
* should be told that the room's info might have changed. The Algorithm
|
||||||
|
* may no-op this request if no changes are required.
|
||||||
|
* @param {Room} room The room which might have affected sorting.
|
||||||
|
* @param {RoomUpdateCause} cause The reason for the update being triggered.
|
||||||
|
* @returns {Promise<boolean>} A promise which resolve to true or false
|
||||||
|
* depending on whether or not getOrderedRooms() should be called after
|
||||||
|
* processing.
|
||||||
|
*/
|
||||||
|
public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean>;
|
||||||
|
}
|
|
@ -0,0 +1,298 @@
|
||||||
|
/*
|
||||||
|
Copyright 2018, 2019 New Vector Ltd
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Algorithm } from "./Algorithm";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { RoomUpdateCause, TagID } from "../../models";
|
||||||
|
import { ITagMap, SortAlgorithm } from "../models";
|
||||||
|
import { sortRoomsWithAlgorithm } from "../tag-sorting";
|
||||||
|
import * as Unread from '../../../../Unread';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The determined category of a room.
|
||||||
|
*/
|
||||||
|
export enum Category {
|
||||||
|
/**
|
||||||
|
* The room has unread mentions within.
|
||||||
|
*/
|
||||||
|
Red = "RED",
|
||||||
|
/**
|
||||||
|
* The room has unread notifications within. Note that these are not unread
|
||||||
|
* mentions - they are simply messages which the user has asked to cause a
|
||||||
|
* badge count update or push notification.
|
||||||
|
*/
|
||||||
|
Grey = "GREY",
|
||||||
|
/**
|
||||||
|
* The room has unread messages within (grey without the badge).
|
||||||
|
*/
|
||||||
|
Bold = "BOLD",
|
||||||
|
/**
|
||||||
|
* The room has no relevant unread messages within.
|
||||||
|
*/
|
||||||
|
Idle = "IDLE",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICategorizedRoomMap {
|
||||||
|
// @ts-ignore - TS wants this to be a string, but we know better
|
||||||
|
[category: Category]: Room[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICategoryIndex {
|
||||||
|
// @ts-ignore - TS wants this to be a string, but we know better
|
||||||
|
[category: Category]: number; // integer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caution: changing this means you'll need to update a bunch of assumptions and
|
||||||
|
// comments! Check the usage of Category carefully to figure out what needs changing
|
||||||
|
// if you're going to change this array's order.
|
||||||
|
const CATEGORY_ORDER = [Category.Red, Category.Grey, Category.Bold, Category.Idle];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "importance" algorithm for room list sorting. Where
|
||||||
|
* the tag sorting algorithm does not interfere, rooms will be ordered into
|
||||||
|
* categories of varying importance to the user. Alphabetical sorting does not
|
||||||
|
* interfere with this algorithm, however manual ordering does.
|
||||||
|
*
|
||||||
|
* The importance of a room is defined by the kind of notifications, if any, are
|
||||||
|
* present on the room. These are classified internally as Red, Grey, Bold, and
|
||||||
|
* Idle. Red rooms have mentions, grey have unread messages, bold is a less noisy
|
||||||
|
* version of grey, and idle means all activity has been seen by the user.
|
||||||
|
*
|
||||||
|
* The algorithm works by monitoring all room changes, including new messages in
|
||||||
|
* tracked rooms, to determine if it needs a new category or different placement
|
||||||
|
* within the same category. For more information, see the comments contained
|
||||||
|
* within the class.
|
||||||
|
*/
|
||||||
|
export class ImportanceAlgorithm extends Algorithm {
|
||||||
|
|
||||||
|
// HOW THIS WORKS
|
||||||
|
// --------------
|
||||||
|
//
|
||||||
|
// This block of comments assumes you've read the README one level higher.
|
||||||
|
// You should do that if you haven't already.
|
||||||
|
//
|
||||||
|
// Tags are fed into the algorithmic functions from the Algorithm superclass,
|
||||||
|
// which cause subsequent updates to the room list itself. Categories within
|
||||||
|
// those tags are tracked as index numbers within the array (zero = top), with
|
||||||
|
// each sticky room being tracked separately. Internally, the category index
|
||||||
|
// can be found from `this.indices[tag][category]` and the sticky room information
|
||||||
|
// from `this.stickyRoom`.
|
||||||
|
//
|
||||||
|
// The room list store is always provided with the `this.cached` results, which are
|
||||||
|
// updated as needed and not recalculated often. For example, when a room needs to
|
||||||
|
// move within a tag, the array in `this.cached` will be spliced instead of iterated.
|
||||||
|
// The `indices` help track the positions of each category to make splicing easier.
|
||||||
|
|
||||||
|
private indices: {
|
||||||
|
// @ts-ignore - TS wants this to be a string but we know better than it
|
||||||
|
[tag: TagID]: ICategoryIndex;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
// TODO: Use this (see docs above)
|
||||||
|
private stickyRoom: {
|
||||||
|
roomId: string;
|
||||||
|
tag: TagID;
|
||||||
|
fromTop: number;
|
||||||
|
} = {
|
||||||
|
roomId: null,
|
||||||
|
tag: null,
|
||||||
|
fromTop: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
console.log("Constructed an ImportanceAlgorithm");
|
||||||
|
}
|
||||||
|
|
||||||
|
// noinspection JSMethodCanBeStatic
|
||||||
|
private categorizeRooms(rooms: Room[]): ICategorizedRoomMap {
|
||||||
|
const map: ICategorizedRoomMap = {
|
||||||
|
[Category.Red]: [],
|
||||||
|
[Category.Grey]: [],
|
||||||
|
[Category.Bold]: [],
|
||||||
|
[Category.Idle]: [],
|
||||||
|
};
|
||||||
|
for (const room of rooms) {
|
||||||
|
const category = this.getRoomCategory(room);
|
||||||
|
map[category].push(room);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// noinspection JSMethodCanBeStatic
|
||||||
|
private getRoomCategory(room: Room): Category {
|
||||||
|
// Function implementation borrowed from old RoomListStore
|
||||||
|
|
||||||
|
const mentions = room.getUnreadNotificationCount('highlight') > 0;
|
||||||
|
if (mentions) {
|
||||||
|
return Category.Red;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unread = room.getUnreadNotificationCount() > 0;
|
||||||
|
if (unread) {
|
||||||
|
return Category.Grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
unread = Unread.doesRoomHaveUnreadMessages(room);
|
||||||
|
if (unread) {
|
||||||
|
return Category.Bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Category.Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async generateFreshTags(updatedTagMap: ITagMap): Promise<any> {
|
||||||
|
for (const tagId of Object.keys(updatedTagMap)) {
|
||||||
|
const unorderedRooms = updatedTagMap[tagId];
|
||||||
|
|
||||||
|
const sortBy = this.sortAlgorithms[tagId];
|
||||||
|
if (!sortBy) throw new Error(`${tagId} does not have a sorting algorithm`);
|
||||||
|
|
||||||
|
if (sortBy === SortAlgorithm.Manual) {
|
||||||
|
// Manual tags essentially ignore the importance algorithm, so don't do anything
|
||||||
|
// special about them.
|
||||||
|
updatedTagMap[tagId] = await sortRoomsWithAlgorithm(unorderedRooms, tagId, sortBy);
|
||||||
|
} else {
|
||||||
|
// Every other sorting type affects the categories, not the whole tag.
|
||||||
|
const categorized = this.categorizeRooms(unorderedRooms);
|
||||||
|
for (const category of Object.keys(categorized)) {
|
||||||
|
const roomsToOrder = categorized[category];
|
||||||
|
categorized[category] = await sortRoomsWithAlgorithm(roomsToOrder, tagId, sortBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newlyOrganized: Room[] = [];
|
||||||
|
const newIndices: ICategoryIndex = {};
|
||||||
|
|
||||||
|
for (const category of CATEGORY_ORDER) {
|
||||||
|
newIndices[category] = newlyOrganized.length;
|
||||||
|
newlyOrganized.push(...categorized[category]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.indices[tagId] = newIndices;
|
||||||
|
updatedTagMap[tagId] = newlyOrganized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
|
||||||
|
const tags = this.roomIdsToTags[room.roomId];
|
||||||
|
if (!tags) {
|
||||||
|
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const category = this.getRoomCategory(room);
|
||||||
|
let changed = false;
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (this.sortAlgorithms[tag] === SortAlgorithm.Manual) {
|
||||||
|
continue; // Nothing to do here.
|
||||||
|
}
|
||||||
|
|
||||||
|
const taggedRooms = this.cached[tag];
|
||||||
|
const indices = this.indices[tag];
|
||||||
|
let roomIdx = taggedRooms.indexOf(room);
|
||||||
|
if (roomIdx === -1) {
|
||||||
|
console.warn(`Degrading performance to find missing room in "${tag}": ${room.roomId}`);
|
||||||
|
roomIdx = taggedRooms.findIndex(r => r.roomId === room.roomId);
|
||||||
|
}
|
||||||
|
if (roomIdx === -1) {
|
||||||
|
throw new Error(`Room ${room.roomId} has no index in ${tag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to avoid doing array operations if we don't have to: only move rooms within
|
||||||
|
// the categories if we're jumping categories
|
||||||
|
const oldCategory = this.getCategoryFromIndices(roomIdx, indices);
|
||||||
|
if (oldCategory !== category) {
|
||||||
|
// Move the room and update the indices
|
||||||
|
this.moveRoomIndexes(1, oldCategory, category, indices);
|
||||||
|
taggedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
|
||||||
|
taggedRooms.splice(indices[category], 0, room); // splice in the new room (pre-adjusted)
|
||||||
|
// Note: if moveRoomIndexes() is called after the splice then the insert operation
|
||||||
|
// will happen in the wrong place. Because we would have already adjusted the index
|
||||||
|
// for the category, we don't need to determine how the room is moving in the list.
|
||||||
|
// If we instead tried to insert before updating the indices, we'd have to determine
|
||||||
|
// whether the room was moving later (towards IDLE) or earlier (towards RED) from its
|
||||||
|
// current position, as it'll affect the category's start index after we remove the
|
||||||
|
// room from the array.
|
||||||
|
}
|
||||||
|
|
||||||
|
// The room received an update, so take out the slice and sort it. This should be relatively
|
||||||
|
// quick because the room is inserted at the top of the category, and most popular sorting
|
||||||
|
// algorithms will deal with trying to keep the active room at the top/start of the category.
|
||||||
|
// For the few algorithms that will have to move the thing quite far (alphabetic with a Z room
|
||||||
|
// for example), the list should already be sorted well enough that it can rip through the
|
||||||
|
// array and slot the changed room in quickly.
|
||||||
|
const nextCategoryStartIdx = category === CATEGORY_ORDER[CATEGORY_ORDER.length - 1]
|
||||||
|
? Number.MAX_SAFE_INTEGER
|
||||||
|
: indices[CATEGORY_ORDER[CATEGORY_ORDER.indexOf(category) + 1]];
|
||||||
|
const startIdx = indices[category];
|
||||||
|
const numSort = nextCategoryStartIdx - startIdx; // splice() returns up to the max, so MAX_SAFE_INT is fine
|
||||||
|
const unsortedSlice = taggedRooms.splice(startIdx, numSort);
|
||||||
|
const sorted = await sortRoomsWithAlgorithm(unsortedSlice, tag, this.sortAlgorithms[tag]);
|
||||||
|
taggedRooms.splice(startIdx, 0, ...sorted);
|
||||||
|
|
||||||
|
// Finally, flag that we've done something
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCategoryFromIndices(index: number, indices: ICategoryIndex): Category {
|
||||||
|
for (let i = 0; i < CATEGORY_ORDER.length; i++) {
|
||||||
|
const category = CATEGORY_ORDER[i];
|
||||||
|
const isLast = i === (CATEGORY_ORDER.length - 1);
|
||||||
|
const startIdx = indices[category];
|
||||||
|
const endIdx = isLast ? Number.MAX_SAFE_INTEGER : indices[CATEGORY_ORDER[i + 1]];
|
||||||
|
if (index >= startIdx && index < endIdx) {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Should never happen" disclaimer goes here
|
||||||
|
throw new Error("Programming error: somehow you've ended up with an index that isn't in a category");
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveRoomIndexes(nRooms: number, fromCategory: Category, toCategory: Category, indices: ICategoryIndex) {
|
||||||
|
// We have to update the index of the category *after* the from/toCategory variables
|
||||||
|
// in order to update the indices correctly. Because the room is moving from/to those
|
||||||
|
// categories, the next category's index will change - not the category we're modifying.
|
||||||
|
// We also need to update subsequent categories as they'll all shift by nRooms, so we
|
||||||
|
// loop over the order to achieve that.
|
||||||
|
|
||||||
|
for (let i = CATEGORY_ORDER.indexOf(fromCategory) + 1; i < CATEGORY_ORDER.length; i++) {
|
||||||
|
const nextCategory = CATEGORY_ORDER[i];
|
||||||
|
indices[nextCategory] -= nRooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = CATEGORY_ORDER.indexOf(toCategory) + 1; i < CATEGORY_ORDER.length; i++) {
|
||||||
|
const nextCategory = CATEGORY_ORDER[i];
|
||||||
|
indices[nextCategory] += nRooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do a quick check to see if we've completely broken the index
|
||||||
|
for (let i = 1; i <= CATEGORY_ORDER.length; i++) {
|
||||||
|
const lastCat = CATEGORY_ORDER[i - 1];
|
||||||
|
const thisCat = CATEGORY_ORDER[i];
|
||||||
|
|
||||||
|
if (indices[lastCat] > indices[thisCat]) {
|
||||||
|
// "should never happen" disclaimer goes here
|
||||||
|
console.warn(`!! Room list index corruption: ${lastCat} (i:${indices[lastCat]}) is greater than ${thisCat} (i:${indices[thisCat]}) - category indices are likely desynced from reality`);
|
||||||
|
|
||||||
|
// TODO: Regenerate index when this happens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Algorithm } from "./Algorithm";
|
||||||
|
import { ITagMap } from "../models";
|
||||||
|
import { sortRoomsWithAlgorithm } from "../tag-sorting";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the natural tag sorting algorithm order to determine tag ordering. No
|
||||||
|
* additional behavioural changes are present.
|
||||||
|
*/
|
||||||
|
export class NaturalAlgorithm extends Algorithm {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
console.log("Constructed a NaturalAlgorithm");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async generateFreshTags(updatedTagMap: ITagMap): Promise<any> {
|
||||||
|
for (const tagId of Object.keys(updatedTagMap)) {
|
||||||
|
const unorderedRooms = updatedTagMap[tagId];
|
||||||
|
|
||||||
|
const sortBy = this.sortAlgorithms[tagId];
|
||||||
|
if (!sortBy) throw new Error(`${tagId} does not have a sorting algorithm`);
|
||||||
|
|
||||||
|
updatedTagMap[tagId] = await sortRoomsWithAlgorithm(unorderedRooms, tagId, sortBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleRoomUpdate(room, cause): Promise<boolean> {
|
||||||
|
const tags = this.roomIdsToTags[room.roomId];
|
||||||
|
if (!tags) {
|
||||||
|
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const tag of tags) {
|
||||||
|
// TODO: Optimize this loop to avoid useless operations
|
||||||
|
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
|
||||||
|
this.cached[tag] = await sortRoomsWithAlgorithm(this.cached[tag], tag, this.sortAlgorithms[tag]);
|
||||||
|
}
|
||||||
|
return true; // assume we changed something
|
||||||
|
}
|
||||||
|
}
|
38
src/stores/room-list/algorithms/list-ordering/index.ts
Normal file
38
src/stores/room-list/algorithms/list-ordering/index.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Algorithm } from "./Algorithm";
|
||||||
|
import { ImportanceAlgorithm } from "./ImportanceAlgorithm";
|
||||||
|
import { ListAlgorithm } from "../models";
|
||||||
|
import { NaturalAlgorithm } from "./NaturalAlgorithm";
|
||||||
|
|
||||||
|
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: () => Algorithm } = {
|
||||||
|
[ListAlgorithm.Natural]: () => new NaturalAlgorithm(),
|
||||||
|
[ListAlgorithm.Importance]: () => new ImportanceAlgorithm(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an instance of the defined algorithm
|
||||||
|
* @param {ListAlgorithm} algorithm The algorithm to get an instance of.
|
||||||
|
* @returns {Algorithm} The algorithm instance.
|
||||||
|
*/
|
||||||
|
export function getListAlgorithmInstance(algorithm: ListAlgorithm): Algorithm {
|
||||||
|
if (!ALGORITHM_FACTORIES[algorithm]) {
|
||||||
|
throw new Error(`${algorithm} is not a known algorithm`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ALGORITHM_FACTORIES[algorithm]();
|
||||||
|
}
|
42
src/stores/room-list/algorithms/models.ts
Normal file
42
src/stores/room-list/algorithms/models.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TagID } from "../models";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
|
||||||
|
export enum SortAlgorithm {
|
||||||
|
Manual = "MANUAL",
|
||||||
|
Alphabetic = "ALPHABETIC",
|
||||||
|
Recent = "RECENT",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ListAlgorithm {
|
||||||
|
// Orders Red > Grey > Bold > Idle
|
||||||
|
Importance = "IMPORTANCE",
|
||||||
|
|
||||||
|
// Orders however the SortAlgorithm decides
|
||||||
|
Natural = "NATURAL",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITagSortingMap {
|
||||||
|
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
|
||||||
|
[tagId: TagID]: SortAlgorithm;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITagMap {
|
||||||
|
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
|
||||||
|
[tagId: TagID]: Room[];
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { TagID } from "../../models";
|
||||||
|
import { IAlgorithm } from "./IAlgorithm";
|
||||||
|
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
|
||||||
|
import * as Unread from "../../../../Unread";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts rooms according to the browser's determination of alphabetic.
|
||||||
|
*/
|
||||||
|
export class AlphabeticAlgorithm implements IAlgorithm {
|
||||||
|
public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> {
|
||||||
|
return rooms.sort((a, b) => {
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
31
src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts
Normal file
31
src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { TagID } from "../../models";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a tag sorting algorithm.
|
||||||
|
*/
|
||||||
|
export interface IAlgorithm {
|
||||||
|
/**
|
||||||
|
* Sorts the given rooms according to the sorting rules of the algorithm.
|
||||||
|
* @param {Room[]} rooms The rooms to sort.
|
||||||
|
* @param {TagID} tagId The tag ID in which the rooms are being sorted.
|
||||||
|
* @returns {Promise<Room[]>} Resolves to the sorted rooms.
|
||||||
|
*/
|
||||||
|
sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]>;
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { TagID } from "../../models";
|
||||||
|
import { IAlgorithm } from "./IAlgorithm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts rooms according to the tag's `order` property on the room.
|
||||||
|
*/
|
||||||
|
export class ManualAlgorithm implements IAlgorithm {
|
||||||
|
public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> {
|
||||||
|
const getOrderProp = (r: Room) => r.tags[tagId].order || 0;
|
||||||
|
return rooms.sort((a, b) => {
|
||||||
|
return getOrderProp(a) - getOrderProp(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,81 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { TagID } from "../../models";
|
||||||
|
import { IAlgorithm } from "./IAlgorithm";
|
||||||
|
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
|
||||||
|
import * as Unread from "../../../../Unread";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts rooms according to the last event's timestamp in each room that seems
|
||||||
|
* useful to the user.
|
||||||
|
*/
|
||||||
|
export class RecentAlgorithm implements IAlgorithm {
|
||||||
|
public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> {
|
||||||
|
// We cache the timestamp lookup to avoid iterating forever on the timeline
|
||||||
|
// of events. This cache only survives a single sort though.
|
||||||
|
// We wouldn't need this if `.sort()` didn't constantly try and compare all
|
||||||
|
// of the rooms to each other.
|
||||||
|
|
||||||
|
// TODO: We could probably improve the sorting algorithm here by finding changes.
|
||||||
|
// For example, if we spent a little bit of time to determine which elements have
|
||||||
|
// actually changed (probably needs to be done higher up?) then we could do an
|
||||||
|
// insertion sort or similar on the limited set of changes.
|
||||||
|
|
||||||
|
const tsCache: { [roomId: string]: number } = {};
|
||||||
|
const getLastTs = (r: Room) => {
|
||||||
|
if (tsCache[r.roomId]) {
|
||||||
|
return tsCache[r.roomId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = (() => {
|
||||||
|
// Apparently we can have rooms without timelines, at least under testing
|
||||||
|
// environments. Just return MAX_INT when this happens.
|
||||||
|
if (!r || !r.timeline) {
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = r.timeline.length - 1; i >= 0; --i) {
|
||||||
|
const ev = r.timeline[i];
|
||||||
|
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
|
||||||
|
|
||||||
|
// TODO: Don't assume we're using the same client as the peg
|
||||||
|
if (ev.getSender() === MatrixClientPeg.get().getUserId()
|
||||||
|
|| Unread.eventTriggersUnreadCount(ev)) {
|
||||||
|
return ev.getTs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we might only have events that don't trigger the unread indicator,
|
||||||
|
// in which case use the oldest event even if normally it wouldn't count.
|
||||||
|
// This is better than just assuming the last event was forever ago.
|
||||||
|
if (r.timeline.length && r.timeline[0].getTs()) {
|
||||||
|
return r.timeline[0].getTs();
|
||||||
|
} else {
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
tsCache[r.roomId] = ts;
|
||||||
|
return ts;
|
||||||
|
};
|
||||||
|
|
||||||
|
return rooms.sort((a, b) => {
|
||||||
|
return getLastTs(a) - getLastTs(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
53
src/stores/room-list/algorithms/tag-sorting/index.ts
Normal file
53
src/stores/room-list/algorithms/tag-sorting/index.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SortAlgorithm } from "../models";
|
||||||
|
import { ManualAlgorithm } from "./ManualAlgorithm";
|
||||||
|
import { IAlgorithm } from "./IAlgorithm";
|
||||||
|
import { TagID } from "../../models";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { RecentAlgorithm } from "./RecentAlgorithm";
|
||||||
|
import { AlphabeticAlgorithm } from "./AlphabeticAlgorithm";
|
||||||
|
|
||||||
|
const ALGORITHM_INSTANCES: { [algorithm in SortAlgorithm]: IAlgorithm } = {
|
||||||
|
[SortAlgorithm.Recent]: new RecentAlgorithm(),
|
||||||
|
[SortAlgorithm.Alphabetic]: new AlphabeticAlgorithm(),
|
||||||
|
[SortAlgorithm.Manual]: new ManualAlgorithm(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an instance of the defined algorithm
|
||||||
|
* @param {SortAlgorithm} algorithm The algorithm to get an instance of.
|
||||||
|
* @returns {IAlgorithm} The algorithm instance.
|
||||||
|
*/
|
||||||
|
export function getSortingAlgorithmInstance(algorithm: SortAlgorithm): IAlgorithm {
|
||||||
|
if (!ALGORITHM_INSTANCES[algorithm]) {
|
||||||
|
throw new Error(`${algorithm} is not a known algorithm`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ALGORITHM_INSTANCES[algorithm];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts rooms in a given tag according to the algorithm given.
|
||||||
|
* @param {Room[]} rooms The rooms to sort.
|
||||||
|
* @param {TagID} tagId The tag in which the sorting is occurring.
|
||||||
|
* @param {SortAlgorithm} algorithm The algorithm to use for sorting.
|
||||||
|
* @returns {Promise<Room[]>} Resolves to the sorted rooms.
|
||||||
|
*/
|
||||||
|
export function sortRoomsWithAlgorithm(rooms: Room[], tagId: TagID, algorithm: SortAlgorithm): Promise<Room[]> {
|
||||||
|
return getSortingAlgorithmInstance(algorithm).sortRooms(rooms, tagId);
|
||||||
|
}
|
72
src/stores/room-list/membership.ts
Normal file
72
src/stores/room-list/membership.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approximation of a membership status for a given room.
|
||||||
|
*/
|
||||||
|
export enum EffectiveMembership {
|
||||||
|
/**
|
||||||
|
* The user is effectively joined to the room. For example, actually joined
|
||||||
|
* or knocking on the room (when that becomes possible).
|
||||||
|
*/
|
||||||
|
Join = "JOIN",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user is effectively invited to the room. Currently this is a direct map
|
||||||
|
* to the invite membership as no other membership states are effectively
|
||||||
|
* invites.
|
||||||
|
*/
|
||||||
|
Invite = "INVITE",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user is effectively no longer in the room. For example, kicked,
|
||||||
|
* banned, or voluntarily left.
|
||||||
|
*/
|
||||||
|
Leave = "LEAVE",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MembershipSplit {
|
||||||
|
// @ts-ignore - TS wants this to be a string key, but we know better.
|
||||||
|
[state: EffectiveMembership]: Room[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitRoomsByMembership(rooms: Room[]): MembershipSplit {
|
||||||
|
const split: MembershipSplit = {
|
||||||
|
[EffectiveMembership.Invite]: [],
|
||||||
|
[EffectiveMembership.Join]: [],
|
||||||
|
[EffectiveMembership.Leave]: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
split[getEffectiveMembership(room.getMyMembership())].push(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
return split;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveMembership(membership: string): EffectiveMembership {
|
||||||
|
if (membership === 'invite') {
|
||||||
|
return EffectiveMembership.Invite;
|
||||||
|
} else if (membership === 'join') {
|
||||||
|
// TODO: Do the same for knock? Update docs as needed in the enum.
|
||||||
|
return EffectiveMembership.Join;
|
||||||
|
} else {
|
||||||
|
// Probably a leave, kick, or ban
|
||||||
|
return EffectiveMembership.Leave;
|
||||||
|
}
|
||||||
|
}
|
42
src/stores/room-list/models.ts
Normal file
42
src/stores/room-list/models.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum DefaultTagID {
|
||||||
|
Invite = "im.vector.fake.invite",
|
||||||
|
Untagged = "im.vector.fake.recent", // legacy: used to just be 'recent rooms' but now it's all untagged rooms
|
||||||
|
Archived = "im.vector.fake.archived",
|
||||||
|
LowPriority = "m.lowpriority",
|
||||||
|
Favourite = "m.favourite",
|
||||||
|
DM = "im.vector.fake.direct",
|
||||||
|
ServerNotice = "m.server_notice",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrderedDefaultTagIDs = [
|
||||||
|
DefaultTagID.Invite,
|
||||||
|
DefaultTagID.Favourite,
|
||||||
|
DefaultTagID.DM,
|
||||||
|
DefaultTagID.Untagged,
|
||||||
|
DefaultTagID.LowPriority,
|
||||||
|
DefaultTagID.ServerNotice,
|
||||||
|
DefaultTagID.Archived,
|
||||||
|
];
|
||||||
|
|
||||||
|
export type TagID = string | DefaultTagID;
|
||||||
|
|
||||||
|
export enum RoomUpdateCause {
|
||||||
|
Timeline = "TIMELINE",
|
||||||
|
RoomRead = "ROOM_READ", // TODO: Use this.
|
||||||
|
}
|
|
@ -205,9 +205,9 @@ describe("<TextualBody />", () => {
|
||||||
expect(content.html()).toBe('<span class="mx_EventTile_body markdown-body" dir="auto">' +
|
expect(content.html()).toBe('<span class="mx_EventTile_body markdown-body" dir="auto">' +
|
||||||
'Hey <span>' +
|
'Hey <span>' +
|
||||||
'<a class="mx_Pill mx_UserPill" title="@user:server">' +
|
'<a class="mx_Pill mx_UserPill" title="@user:server">' +
|
||||||
'<img class="mx_BaseAvatar mx_BaseAvatar_image" src="mxc://avatar.url/image.png" ' +
|
'<img class="mx_BaseAvatar mx_BaseAvatar_image" ' +
|
||||||
'style="width: 16px; height: 16px;" ' +
|
'style="width: 16px; height: 16px;" ' +
|
||||||
'title="@member:domain.bla" alt="" aria-hidden="true">Member</a>' +
|
'title="@member:domain.bla" alt="" aria-hidden="true" src="mxc://avatar.url/image.png">Member</a>' +
|
||||||
'</span></span>');
|
'</span></span>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,7 +14,7 @@ import DMRoomMap from '../../../../src/utils/DMRoomMap.js';
|
||||||
import GroupStore from '../../../../src/stores/GroupStore.js';
|
import GroupStore from '../../../../src/stores/GroupStore.js';
|
||||||
|
|
||||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||||
import {TAG_DM} from "../../../../src/stores/RoomListStore";
|
import {DefaultTagID} from "../../../../src/stores/room-list/models";
|
||||||
|
|
||||||
function generateRoomId() {
|
function generateRoomId() {
|
||||||
return '!' + Math.random().toString().slice(2, 10) + ':domain';
|
return '!' + Math.random().toString().slice(2, 10) + ':domain';
|
||||||
|
@ -153,7 +153,7 @@ describe('RoomList', () => {
|
||||||
// Set up the room that will be moved such that it has the correct state for a room in
|
// Set up the room that will be moved such that it has the correct state for a room in
|
||||||
// the section for oldTag
|
// the section for oldTag
|
||||||
if (['m.favourite', 'm.lowpriority'].includes(oldTag)) movingRoom.tags = {[oldTag]: {}};
|
if (['m.favourite', 'm.lowpriority'].includes(oldTag)) movingRoom.tags = {[oldTag]: {}};
|
||||||
if (oldTag === TAG_DM) {
|
if (oldTag === DefaultTagID.DM) {
|
||||||
// Mock inverse m.direct
|
// Mock inverse m.direct
|
||||||
DMRoomMap.shared().roomToUser = {
|
DMRoomMap.shared().roomToUser = {
|
||||||
[movingRoom.roomId]: '@someotheruser:domain',
|
[movingRoom.roomId]: '@someotheruser:domain',
|
||||||
|
@ -180,7 +180,7 @@ describe('RoomList', () => {
|
||||||
// TODO: Re-enable dragging tests when we support dragging again.
|
// TODO: Re-enable dragging tests when we support dragging again.
|
||||||
describe.skip('does correct optimistic update when dragging from', () => {
|
describe.skip('does correct optimistic update when dragging from', () => {
|
||||||
it('rooms to people', () => {
|
it('rooms to people', () => {
|
||||||
expectCorrectMove(undefined, TAG_DM);
|
expectCorrectMove(undefined, DefaultTagID.DM);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rooms to favourites', () => {
|
it('rooms to favourites', () => {
|
||||||
|
@ -195,15 +195,15 @@ describe('RoomList', () => {
|
||||||
// Whe running the app live, it updates when some other event occurs (likely the
|
// Whe running the app live, it updates when some other event occurs (likely the
|
||||||
// m.direct arriving) that these tests do not fire.
|
// m.direct arriving) that these tests do not fire.
|
||||||
xit('people to rooms', () => {
|
xit('people to rooms', () => {
|
||||||
expectCorrectMove(TAG_DM, undefined);
|
expectCorrectMove(DefaultTagID.DM, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('people to favourites', () => {
|
it('people to favourites', () => {
|
||||||
expectCorrectMove(TAG_DM, 'm.favourite');
|
expectCorrectMove(DefaultTagID.DM, 'm.favourite');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('people to lowpriority', () => {
|
it('people to lowpriority', () => {
|
||||||
expectCorrectMove(TAG_DM, 'm.lowpriority');
|
expectCorrectMove(DefaultTagID.DM, 'm.lowpriority');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('low priority to rooms', () => {
|
it('low priority to rooms', () => {
|
||||||
|
@ -211,7 +211,7 @@ describe('RoomList', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('low priority to people', () => {
|
it('low priority to people', () => {
|
||||||
expectCorrectMove('m.lowpriority', TAG_DM);
|
expectCorrectMove('m.lowpriority', DefaultTagID.DM);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('low priority to low priority', () => {
|
it('low priority to low priority', () => {
|
||||||
|
@ -223,7 +223,7 @@ describe('RoomList', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('favourites to people', () => {
|
it('favourites to people', () => {
|
||||||
expectCorrectMove('m.favourite', TAG_DM);
|
expectCorrectMove('m.favourite', DefaultTagID.DM);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('favourites to low priority', () => {
|
it('favourites to low priority', () => {
|
||||||
|
|
32
yarn.lock
32
yarn.lock
|
@ -1865,6 +1865,11 @@ autoprefixer@^9.0.0:
|
||||||
postcss "^7.0.30"
|
postcss "^7.0.30"
|
||||||
postcss-value-parser "^4.1.0"
|
postcss-value-parser "^4.1.0"
|
||||||
|
|
||||||
|
await-lock@^2.0.1:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.0.1.tgz#b3f65fdf66e08f7538260f79b46c15bcfc18cadd"
|
||||||
|
integrity sha512-ntLi9fzlMT/vWjC1wwVI11/cSRJ3nTS35qVekNc9WnaoMOP2eWH0RvIqwLQkDjX4a4YynsKEv+Ere2VONp9wxg==
|
||||||
|
|
||||||
aws-sign2@~0.7.0:
|
aws-sign2@~0.7.0:
|
||||||
version "0.7.0"
|
version "0.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
|
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
|
||||||
|
@ -2521,6 +2526,11 @@ color-convert@^1.9.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name "1.1.3"
|
color-name "1.1.3"
|
||||||
|
|
||||||
|
color-convert@~0.5.0:
|
||||||
|
version "0.5.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd"
|
||||||
|
integrity sha1-vbbGnOZg+t/+CwAHzER+G59ygr0=
|
||||||
|
|
||||||
color-name@1.1.3:
|
color-name@1.1.3:
|
||||||
version "1.1.3"
|
version "1.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
|
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
|
||||||
|
@ -2774,6 +2784,11 @@ cssesc@^3.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||||
|
|
||||||
|
cssfontparser@^1.2.1:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3"
|
||||||
|
integrity sha1-9AIvyPlwDGgCnVQghK+69CWj8+M=
|
||||||
|
|
||||||
cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0":
|
cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0":
|
||||||
version "0.3.8"
|
version "0.3.8"
|
||||||
resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a"
|
resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a"
|
||||||
|
@ -4993,6 +5008,14 @@ istanbul-reports@^2.2.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
html-escaper "^2.0.0"
|
html-escaper "^2.0.0"
|
||||||
|
|
||||||
|
jest-canvas-mock@^2.2.0:
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.2.0.tgz#45fbc58589c6ce9df50dc90bd8adce747cbdada7"
|
||||||
|
integrity sha512-DcJdchb7eWFZkt6pvyceWWnu3lsp5QWbUeXiKgEMhwB3sMm5qHM1GQhDajvJgBeiYpgKcojbzZ53d/nz6tXvJw==
|
||||||
|
dependencies:
|
||||||
|
cssfontparser "^1.2.1"
|
||||||
|
parse-color "^1.0.0"
|
||||||
|
|
||||||
jest-changed-files@^24.9.0:
|
jest-changed-files@^24.9.0:
|
||||||
version "24.9.0"
|
version "24.9.0"
|
||||||
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.9.0.tgz#08d8c15eb79a7fa3fc98269bc14b451ee82f8039"
|
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.9.0.tgz#08d8c15eb79a7fa3fc98269bc14b451ee82f8039"
|
||||||
|
@ -5775,7 +5798,7 @@ mathml-tag-names@^2.0.1:
|
||||||
|
|
||||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
|
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
|
||||||
version "6.1.0"
|
version "6.1.0"
|
||||||
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/ff0d91979bfa34775c9e8c6383ef31490c675041"
|
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a4a7097c103da42075f2c70e070fd01fa6fb0d48"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.8.3"
|
"@babel/runtime" "^7.8.3"
|
||||||
another-json "^0.2.0"
|
another-json "^0.2.0"
|
||||||
|
@ -6452,6 +6475,13 @@ parse-asn1@^5.0.0, parse-asn1@^5.1.5:
|
||||||
pbkdf2 "^3.0.3"
|
pbkdf2 "^3.0.3"
|
||||||
safe-buffer "^5.1.1"
|
safe-buffer "^5.1.1"
|
||||||
|
|
||||||
|
parse-color@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/parse-color/-/parse-color-1.0.0.tgz#7b748b95a83f03f16a94f535e52d7f3d94658619"
|
||||||
|
integrity sha1-e3SLlag/A/FqlPU15S1/PZRlhhk=
|
||||||
|
dependencies:
|
||||||
|
color-convert "~0.5.0"
|
||||||
|
|
||||||
parse-entities@^1.0.2, parse-entities@^1.1.0:
|
parse-entities@^1.0.2, parse-entities@^1.1.0:
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50"
|
resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50"
|
||||||
|
|
Loading…
Reference in a new issue