Merge branch 'develop' of https://github.com/matrix-org/matrix-react-sdk into t3chguy/room-list/13981

 Conflicts:
	src/@types/global.d.ts
	src/stores/RoomViewStore.tsx
This commit is contained in:
Michael Telatynski 2020-08-04 21:33:27 +01:00
commit 80dff8706c
342 changed files with 9433 additions and 4222 deletions

View file

@ -42,7 +42,7 @@ export const UPDATE_EVENT = "update";
* help prevent lock conflicts.
*/
export abstract class AsyncStore<T extends Object> extends EventEmitter {
private storeState: T;
private storeState: Readonly<T>;
private lock = new AwaitLock();
private readonly dispatcherRef: string;
@ -62,7 +62,7 @@ export abstract class AsyncStore<T extends Object> extends EventEmitter {
* The current state of the store. Cannot be mutated.
*/
protected get state(): T {
return Object.freeze(this.storeState);
return this.storeState;
}
/**
@ -79,7 +79,7 @@ export abstract class AsyncStore<T extends Object> extends EventEmitter {
protected async updateState(newState: T | Object) {
await this.lock.acquireAsync();
try {
this.storeState = Object.assign(<T>{}, this.storeState, newState);
this.storeState = Object.freeze(Object.assign(<T>{}, this.storeState, newState));
this.emit(UPDATE_EVENT, this);
} finally {
await this.lock.release();
@ -94,7 +94,7 @@ export abstract class AsyncStore<T extends Object> extends EventEmitter {
protected async reset(newState: T | Object = null, quiet = false) {
await this.lock.acquireAsync();
try {
this.storeState = <T>(newState || {});
this.storeState = Object.freeze(<T>(newState || {}));
if (!quiet) this.emit(UPDATE_EVENT, this);
} finally {
await this.lock.release();

View file

@ -17,12 +17,25 @@ limitations under the License.
import { MatrixClient } from "matrix-js-sdk/src/client";
import { AsyncStore } from "./AsyncStore";
import { ActionPayload } from "../dispatcher/payloads";
import { Dispatcher } from "flux";
import { MatrixClientPeg } from "../MatrixClientPeg";
export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<T> {
protected matrixClient: MatrixClient;
protected abstract async onAction(payload: ActionPayload);
protected constructor(dispatcher: Dispatcher<ActionPayload>, initialState: T = <T>{}) {
super(dispatcher, initialState);
if (MatrixClientPeg.get()) {
this.matrixClient = MatrixClientPeg.get();
// noinspection JSIgnoredPromiseFromCall
this.onReady();
}
}
protected async onReady() {
// Default implementation is to do nothing.
}
@ -35,13 +48,21 @@ export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<
await this.onAction(payload);
if (payload.action === 'MatrixActions.sync') {
// Filter out anything that isn't the first PREPARED sync.
// Only set the client on the transition into the PREPARED state.
// Everything after this is unnecessary (we only need to know once we have a client)
// and we intentionally don't set the client before this point to avoid stores
// updating for every event emitted during the cached sync.
if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
return;
}
this.matrixClient = payload.matrixClient;
await this.onReady();
if (this.matrixClient !== payload.matrixClient) {
if (this.matrixClient) {
await this.onNotReady();
}
this.matrixClient = payload.matrixClient;
await this.onReady();
}
} else if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
if (this.matrixClient) {
await this.onNotReady();

View file

@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsStore, { SettingLevel } from "../settings/SettingsStore";
import SettingsStore from "../settings/SettingsStore";
import { Room } from "matrix-js-sdk/src/models/room";
import { ActionPayload } from "../dispatcher/payloads";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import { arrayHasDiff } from "../utils/arrays";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { SettingLevel } from "../settings/SettingLevel";
const MAX_ROOMS = 20; // arbitrary
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up
@ -55,7 +56,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
}
private get meetsRoomRequirement(): boolean {
return this.matrixClient.getVisibleRooms().length >= 20;
return this.matrixClient && this.matrixClient.getVisibleRooms().length >= 20;
}
protected async onAction(payload: ActionPayload) {

View file

@ -22,6 +22,7 @@ import SettingsStore from "../settings/SettingsStore";
import RoomListStore, {LISTS_UPDATE_EVENT} from "./room-list/RoomListStore";
import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateStore";
import {isCustomTag} from "./room-list/models";
import {objectHasDiff} from "../utils/objects";
function commonPrefix(a, b) {
const len = Math.min(a.length, b.length);
@ -107,7 +108,10 @@ class CustomRoomTagStore extends EventEmitter {
}
_onListsUpdated = () => {
this._setState({tags: this._getUpdatedTags()});
const newTags = this._getUpdatedTags();
if (!this._state.tags || objectHasDiff(this._state.tags, newTags)) {
this._setState({tags: newTags});
}
};
_onDispatch(payload) {
@ -134,7 +138,7 @@ class CustomRoomTagStore extends EventEmitter {
_getUpdatedTags() {
if (!SettingsStore.isFeatureEnabled("feature_custom_tags")) {
return;
return {}; // none
}
const newTagNames = Object.keys(RoomListStore.instance.orderedLists).filter(t => isCustomTag(t)).sort();

View file

@ -0,0 +1,50 @@
/*
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 { ComponentClass } from "../@types/common";
import { UPDATE_EVENT } from "./AsyncStore";
export type ToastReference = symbol;
export default class NonUrgentToastStore extends EventEmitter {
private static _instance: NonUrgentToastStore;
private toasts = new Map<ToastReference, ComponentClass>();
public static get instance(): NonUrgentToastStore {
if (!NonUrgentToastStore._instance) {
NonUrgentToastStore._instance = new NonUrgentToastStore();
}
return NonUrgentToastStore._instance;
}
public get components(): ComponentClass[] {
return Array.from(this.toasts.values());
}
public addToast(c: ComponentClass): ToastReference {
const ref: ToastReference = Symbol();
this.toasts.set(ref, c);
this.emit(UPDATE_EVENT);
return ref;
}
public removeToast(ref: ToastReference) {
this.toasts.delete(ref);
this.emit(UPDATE_EVENT);
}
}

View file

@ -111,8 +111,6 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> {
await this.updateState({displayName: profileInfo.displayname, avatarUrl: profileInfo.avatar_url});
};
// TSLint wants this to be a member, but we don't want that.
// tslint:disable-next-line
private onStateEvents = throttle(async (ev: MatrixEvent) => {
const myUserId = MatrixClientPeg.get().getUserId();
if (ev.getType() === 'm.room.member' && ev.getSender() === myUserId && ev.getStateKey() === myUserId) {

View file

@ -17,162 +17,179 @@ limitations under the License.
import dis from '../dispatcher/dispatcher';
import {pendingVerificationRequestForUser} from '../verification';
import {Store} from 'flux/utils';
import SettingsStore, {SettingLevel} from "../settings/SettingsStore";
import {RIGHT_PANEL_PHASES, RIGHT_PANEL_PHASES_NO_ARGS} from "./RightPanelStorePhases";
import SettingsStore from "../settings/SettingsStore";
import {RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS} from "./RightPanelStorePhases";
import {ActionPayload} from "../dispatcher/payloads";
import {Action} from '../dispatcher/actions';
import { SettingLevel } from "../settings/SettingLevel";
const INITIAL_STATE = {
interface RightPanelStoreState {
// Whether or not to show the right panel at all. We split out rooms and groups
// because they're different flows for the user to follow.
showRoomPanel: SettingsStore.getValue("showRightPanelInRoom"),
showGroupPanel: SettingsStore.getValue("showRightPanelInGroup"),
showRoomPanel: boolean;
showGroupPanel: boolean;
// The last phase (screen) the right panel was showing
lastRoomPhase: SettingsStore.getValue("lastRightPanelPhaseForRoom"),
lastGroupPhase: SettingsStore.getValue("lastRightPanelPhaseForGroup"),
lastRoomPhase: RightPanelPhases;
lastGroupPhase: RightPanelPhases;
// Extra information about the last phase
lastRoomPhaseParams: {[key: string]: any};
}
const INITIAL_STATE: RightPanelStoreState = {
showRoomPanel: SettingsStore.getValue("showRightPanelInRoom"),
showGroupPanel: SettingsStore.getValue("showRightPanelInGroup"),
lastRoomPhase: SettingsStore.getValue("lastRightPanelPhaseForRoom"),
lastGroupPhase: SettingsStore.getValue("lastRightPanelPhaseForGroup"),
lastRoomPhaseParams: {},
};
const GROUP_PHASES = Object.keys(RIGHT_PANEL_PHASES).filter(k => k.startsWith("Group"));
const GROUP_PHASES = [
RightPanelPhases.GroupMemberList,
RightPanelPhases.GroupRoomList,
RightPanelPhases.GroupRoomInfo,
RightPanelPhases.GroupMemberInfo,
];
const MEMBER_INFO_PHASES = [
RIGHT_PANEL_PHASES.RoomMemberInfo,
RIGHT_PANEL_PHASES.Room3pidMemberInfo,
RIGHT_PANEL_PHASES.EncryptionPanel,
RightPanelPhases.RoomMemberInfo,
RightPanelPhases.Room3pidMemberInfo,
RightPanelPhases.EncryptionPanel,
];
/**
* A class for tracking the state of the right panel between layouts and
* sessions.
*/
export default class RightPanelStore extends Store {
static _instance;
export default class RightPanelStore extends Store<ActionPayload> {
private static instance: RightPanelStore;
private state: RightPanelStoreState;
constructor() {
super(dis);
// Initialise state
this._state = INITIAL_STATE;
this.state = INITIAL_STATE;
}
get isOpenForRoom(): boolean {
return this._state.showRoomPanel;
return this.state.showRoomPanel;
}
get isOpenForGroup(): boolean {
return this._state.showGroupPanel;
return this.state.showGroupPanel;
}
get roomPanelPhase(): string {
return this._state.lastRoomPhase;
get roomPanelPhase(): RightPanelPhases {
return this.state.lastRoomPhase;
}
get groupPanelPhase(): string {
return this._state.lastGroupPhase;
get groupPanelPhase(): RightPanelPhases {
return this.state.lastGroupPhase;
}
get visibleRoomPanelPhase(): string {
get visibleRoomPanelPhase(): RightPanelPhases {
return this.isOpenForRoom ? this.roomPanelPhase : null;
}
get visibleGroupPanelPhase(): string {
get visibleGroupPanelPhase(): RightPanelPhases {
return this.isOpenForGroup ? this.groupPanelPhase : null;
}
get roomPanelPhaseParams(): any {
return this._state.lastRoomPhaseParams || {};
return this.state.lastRoomPhaseParams || {};
}
_setState(newState) {
this._state = Object.assign(this._state, newState);
private setState(newState: Partial<RightPanelStoreState>) {
this.state = Object.assign(this.state, newState);
SettingsStore.setValue(
"showRightPanelInRoom",
null,
SettingLevel.DEVICE,
this._state.showRoomPanel,
this.state.showRoomPanel,
);
SettingsStore.setValue(
"showRightPanelInGroup",
null,
SettingLevel.DEVICE,
this._state.showGroupPanel,
this.state.showGroupPanel,
);
if (RIGHT_PANEL_PHASES_NO_ARGS.includes(this._state.lastRoomPhase)) {
if (RIGHT_PANEL_PHASES_NO_ARGS.includes(this.state.lastRoomPhase)) {
SettingsStore.setValue(
"lastRightPanelPhaseForRoom",
null,
SettingLevel.DEVICE,
this._state.lastRoomPhase,
this.state.lastRoomPhase,
);
}
if (RIGHT_PANEL_PHASES_NO_ARGS.includes(this._state.lastGroupPhase)) {
if (RIGHT_PANEL_PHASES_NO_ARGS.includes(this.state.lastGroupPhase)) {
SettingsStore.setValue(
"lastRightPanelPhaseForGroup",
null,
SettingLevel.DEVICE,
this._state.lastGroupPhase,
this.state.lastGroupPhase,
);
}
this.__emitChange();
}
__onDispatch(payload) {
__onDispatch(payload: ActionPayload) {
switch (payload.action) {
case 'view_room':
case 'view_group':
// Reset to the member list if we're viewing member info
if (MEMBER_INFO_PHASES.includes(this._state.lastRoomPhase)) {
this._setState({lastRoomPhase: RIGHT_PANEL_PHASES.RoomMemberList, lastRoomPhaseParams: {}});
if (MEMBER_INFO_PHASES.includes(this.state.lastRoomPhase)) {
this.setState({lastRoomPhase: RightPanelPhases.RoomMemberList, lastRoomPhaseParams: {}});
}
// Do the same for groups
if (this._state.lastGroupPhase === RIGHT_PANEL_PHASES.GroupMemberInfo) {
this._setState({lastGroupPhase: RIGHT_PANEL_PHASES.GroupMemberList});
if (this.state.lastGroupPhase === RightPanelPhases.GroupMemberInfo) {
this.setState({lastGroupPhase: RightPanelPhases.GroupMemberList});
}
break;
case 'set_right_panel_phase': {
case Action.SetRightPanelPhase: {
let targetPhase = payload.phase;
let refireParams = payload.refireParams;
// redirect to EncryptionPanel if there is an ongoing verification request
if (targetPhase === RIGHT_PANEL_PHASES.RoomMemberInfo && payload.refireParams) {
if (targetPhase === RightPanelPhases.RoomMemberInfo && payload.refireParams) {
const {member} = payload.refireParams;
const pendingRequest = pendingVerificationRequestForUser(member);
if (pendingRequest) {
targetPhase = RIGHT_PANEL_PHASES.EncryptionPanel;
targetPhase = RightPanelPhases.EncryptionPanel;
refireParams = {
verificationRequest: pendingRequest,
member,
};
}
}
if (!RIGHT_PANEL_PHASES[targetPhase]) {
if (!RightPanelPhases[targetPhase]) {
console.warn(`Tried to switch right panel to unknown phase: ${targetPhase}`);
return;
}
if (GROUP_PHASES.includes(targetPhase)) {
if (targetPhase === this._state.lastGroupPhase) {
this._setState({
showGroupPanel: !this._state.showGroupPanel,
if (targetPhase === this.state.lastGroupPhase) {
this.setState({
showGroupPanel: !this.state.showGroupPanel,
});
} else {
this._setState({
this.setState({
lastGroupPhase: targetPhase,
showGroupPanel: true,
});
}
} else {
if (targetPhase === this._state.lastRoomPhase && !refireParams) {
this._setState({
showRoomPanel: !this._state.showRoomPanel,
if (targetPhase === this.state.lastRoomPhase && !refireParams) {
this.setState({
showRoomPanel: !this.state.showRoomPanel,
});
} else {
this._setState({
this.setState({
lastRoomPhase: targetPhase,
showRoomPanel: true,
lastRoomPhaseParams: refireParams || {},
@ -182,27 +199,27 @@ export default class RightPanelStore extends Store {
// Let things like the member info panel actually open to the right member.
dis.dispatch({
action: 'after_right_panel_phase_change',
action: Action.AfterRightPanelPhaseChange,
phase: targetPhase,
...(refireParams || {}),
});
break;
}
case 'toggle_right_panel':
case Action.ToggleRightPanel:
if (payload.type === "room") {
this._setState({ showRoomPanel: !this._state.showRoomPanel });
this.setState({ showRoomPanel: !this.state.showRoomPanel });
} else { // group
this._setState({ showGroupPanel: !this._state.showGroupPanel });
this.setState({ showGroupPanel: !this.state.showGroupPanel });
}
break;
}
}
static getSharedInstance(): RightPanelStore {
if (!RightPanelStore._instance) {
RightPanelStore._instance = new RightPanelStore();
if (!RightPanelStore.instance) {
RightPanelStore.instance = new RightPanelStore();
}
return RightPanelStore._instance;
return RightPanelStore.instance;
}
}

View file

@ -15,28 +15,28 @@ limitations under the License.
*/
// These are in their own file because of circular imports being a problem.
export const RIGHT_PANEL_PHASES = Object.freeze({
export enum RightPanelPhases {
// Room stuff
RoomMemberList: 'RoomMemberList',
FilePanel: 'FilePanel',
NotificationPanel: 'NotificationPanel',
RoomMemberInfo: 'RoomMemberInfo',
EncryptionPanel: 'EncryptionPanel',
RoomMemberList = 'RoomMemberList',
FilePanel = 'FilePanel',
NotificationPanel = 'NotificationPanel',
RoomMemberInfo = 'RoomMemberInfo',
EncryptionPanel = 'EncryptionPanel',
Room3pidMemberInfo: 'Room3pidMemberInfo',
Room3pidMemberInfo = 'Room3pidMemberInfo',
// Group stuff
GroupMemberList: 'GroupMemberList',
GroupRoomList: 'GroupRoomList',
GroupRoomInfo: 'GroupRoomInfo',
GroupMemberInfo: 'GroupMemberInfo',
});
GroupMemberList = 'GroupMemberList',
GroupRoomList = 'GroupRoomList',
GroupRoomInfo = 'GroupRoomInfo',
GroupMemberInfo = 'GroupMemberInfo',
}
// These are the phases that are safe to persist (the ones that don't require additional
// arguments).
export const RIGHT_PANEL_PHASES_NO_ARGS = [
RIGHT_PANEL_PHASES.NotificationPanel,
RIGHT_PANEL_PHASES.FilePanel,
RIGHT_PANEL_PHASES.RoomMemberList,
RIGHT_PANEL_PHASES.GroupMemberList,
RIGHT_PANEL_PHASES.GroupRoomList,
RightPanelPhases.NotificationPanel,
RightPanelPhases.FilePanel,
RightPanelPhases.RoomMemberList,
RightPanelPhases.GroupMemberList,
RightPanelPhases.GroupRoomList,
];

View file

@ -279,12 +279,22 @@ class RoomViewStore extends Store<ActionPayload> {
console.log("Failed to join room:", msg);
if (err.name === "ConnectionError") {
msg = _t("There was an error joining the room");
}
if (err.errcode === 'M_INCOMPATIBLE_ROOM_VERSION') {
} else if (err.errcode === 'M_INCOMPATIBLE_ROOM_VERSION') {
msg = <div>
{_t("Sorry, your homeserver is too old to participate in this room.")}<br />
{_t("Please contact your homeserver administrator.")}
</div>;
} else if (err.httpStatus === 404) {
const invitingUserId = this.getInvitingUserId(this._state.roomId);
// only provide a better error message for invites
if (invitingUserId) {
// if the inviting user is on the same HS, there can only be one cause: they left.
if (invitingUserId.endsWith(`:${MatrixClientPeg.get().getDomain()}`)) {
msg = _t("The person who invited you already left the room.");
} else {
msg = _t("The person who invited you already left the room, or their server is offline.");
}
}
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to join room', '', ErrorDialog, {
@ -294,6 +304,16 @@ class RoomViewStore extends Store<ActionPayload> {
});
}
private getInvitingUserId(roomId: string): string {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (room && room.getMyMembership() === "invite") {
const myMember = room.getMember(cli.getUserId());
const inviteEvent = myMember ? myMember.events.member : null;
return inviteEvent && inviteEvent.getSender();
}
}
private joinRoomError(payload: ActionPayload) {
this.setState({
joining: false,

View file

@ -15,9 +15,10 @@ limitations under the License.
*/
import EventEmitter from "events";
import React, {JSXElementConstructor} from "react";
import React from "react";
import { ComponentClass } from "../@types/common";
export interface IToast<C extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> {
export interface IToast<C extends ComponentClass> {
key: string;
// higher priority number will be shown on top of lower priority
priority: number;
@ -55,7 +56,7 @@ export default class ToastStore extends EventEmitter {
*
* @param {object} newToast The new toast
*/
addOrReplaceToast<C extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>>(newToast: IToast<C>) {
addOrReplaceToast<C extends ComponentClass>(newToast: IToast<C>) {
const oldIndex = this.toasts.findIndex(t => t.key === newToast.key);
if (oldIndex === -1) {
let newIndex = this.toasts.length;

View 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 { RoomEchoChamber } from "./RoomEchoChamber";
import { Room } from "matrix-js-sdk/src/models/room";
import { EchoStore } from "./EchoStore";
/**
* Semantic access to local echo
*/
export class EchoChamber {
private constructor() {
}
public static forRoom(room: Room): RoomEchoChamber {
return EchoStore.instance.getOrCreateChamberForRoom(room);
}
}

View file

@ -0,0 +1,87 @@
/*
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 { EchoTransaction, RunFn, TransactionStatus } from "./EchoTransaction";
import { arrayFastClone } from "../../utils/arrays";
import { IDestroyable } from "../../utils/IDestroyable";
import { Whenable } from "../../utils/Whenable";
export enum ContextTransactionState {
NotStarted,
PendingErrors,
AllSuccessful
}
export abstract class EchoContext extends Whenable<ContextTransactionState> implements IDestroyable {
private _transactions: EchoTransaction[] = [];
private _state = ContextTransactionState.NotStarted;
public get transactions(): EchoTransaction[] {
return arrayFastClone(this._transactions);
}
public get state(): ContextTransactionState {
return this._state;
}
public get firstFailedTime(): Date {
const failedTxn = this.transactions.find(t => t.didPreviouslyFail || t.status === TransactionStatus.Error);
if (failedTxn) return failedTxn.startTime;
return null;
}
public disownTransaction(txn: EchoTransaction) {
const idx = this._transactions.indexOf(txn);
if (idx >= 0) this._transactions.splice(idx, 1);
txn.destroy();
this.checkTransactions();
}
public beginTransaction(auditName: string, runFn: RunFn): EchoTransaction {
const txn = new EchoTransaction(auditName, runFn);
this._transactions.push(txn);
txn.whenAnything(this.checkTransactions);
// We have no intent to call the transaction again if it succeeds (in fact, it'll
// be really angry at us if we do), so call that the end of the road for the events.
txn.when(TransactionStatus.Success, () => txn.destroy());
return txn;
}
private checkTransactions = () => {
let status = ContextTransactionState.AllSuccessful;
for (const txn of this.transactions) {
if (txn.status === TransactionStatus.Error || txn.didPreviouslyFail) {
status = ContextTransactionState.PendingErrors;
break;
} else if (txn.status === TransactionStatus.Pending) {
status = ContextTransactionState.NotStarted;
// no break as we might hit something which broke
}
}
this._state = status;
this.notifyCondition(status);
};
public destroy() {
for (const txn of this.transactions) {
txn.destroy();
}
this._transactions = [];
super.destroy();
}
}

View file

@ -0,0 +1,104 @@
/*
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 { GenericEchoChamber } from "./GenericEchoChamber";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomEchoChamber } from "./RoomEchoChamber";
import { RoomEchoContext } from "./RoomEchoContext";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { ContextTransactionState, EchoContext } from "./EchoContext";
import NonUrgentToastStore, { ToastReference } from "../NonUrgentToastStore";
import NonUrgentEchoFailureToast from "../../components/views/toasts/NonUrgentEchoFailureToast";
interface IState {
toastRef: ToastReference;
}
type ContextKey = string;
const roomContextKey = (room: Room): ContextKey => `room-${room.roomId}`;
export class EchoStore extends AsyncStoreWithClient<IState> {
private static _instance: EchoStore;
private caches = new Map<ContextKey, GenericEchoChamber<any, any, any>>();
constructor() {
super(defaultDispatcher);
}
public static get instance(): EchoStore {
if (!EchoStore._instance) {
EchoStore._instance = new EchoStore();
}
return EchoStore._instance;
}
public get contexts(): EchoContext[] {
return Array.from(this.caches.values()).map(e => e.context);
}
public getOrCreateChamberForRoom(room: Room): RoomEchoChamber {
if (this.caches.has(roomContextKey(room))) {
return this.caches.get(roomContextKey(room)) as RoomEchoChamber;
}
const context = new RoomEchoContext(room);
context.whenAnything(() => this.checkContexts());
const echo = new RoomEchoChamber(context);
echo.setClient(this.matrixClient);
this.caches.set(roomContextKey(room), echo);
return echo;
}
private async checkContexts() {
let hasOrHadError = false;
for (const echo of this.caches.values()) {
hasOrHadError = echo.context.state === ContextTransactionState.PendingErrors;
if (hasOrHadError) break;
}
if (hasOrHadError && !this.state.toastRef) {
const ref = NonUrgentToastStore.instance.addToast(NonUrgentEchoFailureToast);
await this.updateState({toastRef: ref});
} else if (!hasOrHadError && this.state.toastRef) {
NonUrgentToastStore.instance.removeToast(this.state.toastRef);
await this.updateState({toastRef: null});
}
}
protected async onReady(): Promise<any> {
if (!this.caches) return; // can only happen during initialization
for (const echo of this.caches.values()) {
echo.setClient(this.matrixClient);
}
}
protected async onNotReady(): Promise<any> {
for (const echo of this.caches.values()) {
echo.setClient(null);
}
}
protected async onAction(payload: ActionPayload): Promise<any> {
// We have nothing to actually listen for
return Promise.resolve();
}
}

View 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 { Whenable } from "../../utils/Whenable";
export type RunFn = () => Promise<void>;
export enum TransactionStatus {
Pending,
Success,
Error,
}
export class EchoTransaction extends Whenable<TransactionStatus> {
private _status = TransactionStatus.Pending;
private didFail = false;
public readonly startTime = new Date();
public constructor(
public readonly auditName,
public runFn: RunFn,
) {
super();
}
public get didPreviouslyFail(): boolean {
return this.didFail;
}
public get status(): TransactionStatus {
return this._status;
}
public run() {
if (this.status === TransactionStatus.Success) {
throw new Error("Cannot re-run a successful echo transaction");
}
this.setStatus(TransactionStatus.Pending);
this.runFn()
.then(() => this.setStatus(TransactionStatus.Success))
.catch(() => this.setStatus(TransactionStatus.Error));
}
public cancel() {
// Success basically means "done"
this.setStatus(TransactionStatus.Success);
}
private setStatus(status: TransactionStatus) {
this._status = status;
if (status === TransactionStatus.Error) {
this.didFail = true;
} else if (status === TransactionStatus.Success) {
this.didFail = false;
}
this.notifyCondition(status);
}
}

View file

@ -0,0 +1,91 @@
/*
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 { EchoContext } from "./EchoContext";
import { EchoTransaction, RunFn, TransactionStatus } from "./EchoTransaction";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventEmitter } from "events";
export async function implicitlyReverted() {
// do nothing :D
}
export const PROPERTY_UPDATED = "property_updated";
export abstract class GenericEchoChamber<C extends EchoContext, K, V> extends EventEmitter {
private cache = new Map<K, {txn: EchoTransaction, val: V}>();
protected matrixClient: MatrixClient;
protected constructor(public readonly context: C, private lookupFn: (key: K) => V) {
super();
}
public setClient(client: MatrixClient) {
const oldClient = this.matrixClient;
this.matrixClient = client;
this.onClientChanged(oldClient, client);
}
protected abstract onClientChanged(oldClient: MatrixClient, newClient: MatrixClient);
/**
* Gets a value. If the key is in flight, the cached value will be returned. If
* the key is not in flight then the lookupFn provided to this class will be
* called instead.
* @param key The key to look up.
* @returns The value for the key.
*/
public getValue(key: K): V {
return this.cache.has(key) ? this.cache.get(key).val : this.lookupFn(key);
}
private cacheVal(key: K, val: V, txn: EchoTransaction) {
this.cache.set(key, {txn, val});
this.emit(PROPERTY_UPDATED, key);
}
private decacheKey(key: K) {
if (this.cache.has(key)) {
this.context.disownTransaction(this.cache.get(key).txn);
this.cache.delete(key);
this.emit(PROPERTY_UPDATED, key);
}
}
protected markEchoReceived(key: K) {
if (this.cache.has(key)) {
const txn = this.cache.get(key).txn;
this.context.disownTransaction(txn);
txn.cancel();
}
this.decacheKey(key);
}
public setValue(auditName: string, key: K, targetVal: V, runFn: RunFn, revertFn: RunFn) {
// Cancel any pending transactions for the same key
if (this.cache.has(key)) {
this.cache.get(key).txn.cancel();
}
const ctxn = this.context.beginTransaction(auditName, runFn);
this.cacheVal(key, targetVal, ctxn); // set the cache now as it won't be updated by the .when() ladder below.
ctxn.when(TransactionStatus.Pending, () => this.cacheVal(key, targetVal, ctxn))
.when(TransactionStatus.Error, () => revertFn());
ctxn.run();
}
}

View file

@ -0,0 +1,78 @@
/*
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 { GenericEchoChamber, implicitlyReverted, PROPERTY_UPDATED } from "./GenericEchoChamber";
import { getRoomNotifsState, setRoomNotifsState } from "../../RoomNotifs";
import { RoomEchoContext } from "./RoomEchoContext";
import { _t } from "../../languageHandler";
import { Volume } from "../../RoomNotifsTypes";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
export type CachedRoomValues = Volume;
export enum CachedRoomKey {
NotificationVolume,
}
export class RoomEchoChamber extends GenericEchoChamber<RoomEchoContext, CachedRoomKey, CachedRoomValues> {
private properties = new Map<CachedRoomKey, CachedRoomValues>();
public constructor(context: RoomEchoContext) {
super(context, (k) => this.properties.get(k));
}
protected onClientChanged(oldClient, newClient) {
this.properties.clear();
if (oldClient) {
oldClient.removeListener("accountData", this.onAccountData);
}
if (newClient) {
// Register the listeners first
newClient.on("accountData", this.onAccountData);
// Then populate the properties map
this.updateNotificationVolume();
}
}
private onAccountData = (event: MatrixEvent) => {
if (event.getType() === "m.push_rules") {
const currentVolume = this.properties.get(CachedRoomKey.NotificationVolume) as Volume;
const newVolume = getRoomNotifsState(this.context.room.roomId) as Volume;
if (currentVolume !== newVolume) {
this.updateNotificationVolume();
}
}
};
private updateNotificationVolume() {
this.properties.set(CachedRoomKey.NotificationVolume, getRoomNotifsState(this.context.room.roomId));
this.markEchoReceived(CachedRoomKey.NotificationVolume);
this.emit(PROPERTY_UPDATED, CachedRoomKey.NotificationVolume);
}
// ---- helpers below here ----
public get notificationVolume(): Volume {
return this.getValue(CachedRoomKey.NotificationVolume);
}
public set notificationVolume(v: Volume) {
this.setValue(_t("Change notification settings"), CachedRoomKey.NotificationVolume, v, async () => {
return setRoomNotifsState(this.context.room.roomId, v);
}, implicitlyReverted);
}
}

View file

@ -0,0 +1,24 @@
/*
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 { EchoContext } from "./EchoContext";
import { Room } from "matrix-js-sdk/src/models/room";
export class RoomEchoContext extends EchoContext {
constructor(public readonly room: Room) {
super();
}
}

View file

@ -31,6 +31,7 @@ export class RoomNotificationState extends NotificationState implements IDestroy
this.room.on("Room.receipt", this.handleReadReceipt);
this.room.on("Room.timeline", this.handleRoomEventUpdate);
this.room.on("Room.redaction", this.handleRoomEventUpdate);
this.room.on("Room.myMembership", this.handleMembershipUpdate);
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
MatrixClientPeg.get().on("accountData", this.handleAccountDataUpdate);
this.updateNotificationState();
@ -45,6 +46,7 @@ export class RoomNotificationState extends NotificationState implements IDestroy
this.room.removeListener("Room.receipt", this.handleReadReceipt);
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
this.room.removeListener("Room.myMembership", this.handleMembershipUpdate);
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
MatrixClientPeg.get().removeListener("accountData", this.handleAccountDataUpdate);
@ -57,6 +59,10 @@ export class RoomNotificationState extends NotificationState implements IDestroy
this.updateNotificationState();
};
private handleMembershipUpdate = () => {
this.updateNotificationState();
};
private handleRoomEventUpdate = (event: MatrixEvent) => {
const roomId = event.getRoomId();

View file

@ -21,37 +21,55 @@ import { DefaultTagID, TagID } from "../room-list/models";
import { FetchRoomFn, ListNotificationState } from "./ListNotificationState";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomNotificationState } from "./RoomNotificationState";
const INSPECIFIC_TAG = "INSPECIFIC_TAG";
type INSPECIFIC_TAG = "INSPECIFIC_TAG";
import { SummarizedNotificationState } from "./SummarizedNotificationState";
interface IState {}
export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new RoomNotificationStateStore();
private roomMap = new Map<Room, Map<TagID | INSPECIFIC_TAG, RoomNotificationState>>();
private roomMap = new Map<Room, RoomNotificationState>();
private listMap = new Map<TagID, ListNotificationState>();
private constructor() {
super(defaultDispatcher, {});
}
/**
* Creates a new list notification state. The consumer is expected to set the rooms
* on the notification state, and destroy the state when it no longer needs it.
* @param tagId The tag to create the notification state for.
* Gets a snapshot of notification state for all visible rooms. The number of states recorded
* on the SummarizedNotificationState is equivalent to rooms.
*/
public get globalState(): SummarizedNotificationState {
// If we're not ready yet, just return an empty state
if (!this.matrixClient) return new SummarizedNotificationState();
// Only count visible rooms to not torment the user with notification counts in rooms they can't see.
// This will include highlights from the previous version of the room internally
const globalState = new SummarizedNotificationState();
for (const room of this.matrixClient.getVisibleRooms()) {
globalState.add(this.getRoomState(room));
}
return globalState;
}
/**
* Gets an instance of the list state class for the given tag.
* @param tagId The tag to get the notification state for.
* @returns The notification state for the tag.
*/
public getListState(tagId: TagID): ListNotificationState {
// Note: we don't cache these notification states as the consumer is expected to call
// .setRooms() on the returned object, which could confuse other consumers.
if (this.listMap.has(tagId)) {
return this.listMap.get(tagId);
}
// TODO: Update if/when invites move out of the room list.
const useTileCount = tagId === DefaultTagID.Invite;
const getRoomFn: FetchRoomFn = (room: Room) => {
return this.getRoomState(room, tagId);
return this.getRoomState(room);
};
return new ListNotificationState(useTileCount, tagId, getRoomFn);
const state = new ListNotificationState(useTileCount, tagId, getRoomFn);
this.listMap.set(tagId, state);
return state;
}
/**
@ -59,22 +77,13 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
* attempt to destroy the returned state as it may be shared with other
* consumers.
* @param room The room to get the notification state for.
* @param inTagId Optional tag ID to scope the notification state to.
* @returns The room's notification state.
*/
public getRoomState(room: Room, inTagId?: TagID): RoomNotificationState {
public getRoomState(room: Room): RoomNotificationState {
if (!this.roomMap.has(room)) {
this.roomMap.set(room, new Map<TagID | INSPECIFIC_TAG, RoomNotificationState>());
this.roomMap.set(room, new RoomNotificationState(room));
}
const targetTag = inTagId ? inTagId : INSPECIFIC_TAG;
const forRoomMap = this.roomMap.get(room);
if (!forRoomMap.has(targetTag)) {
forRoomMap.set(inTagId ? inTagId : INSPECIFIC_TAG, new RoomNotificationState(room));
}
return forRoomMap.get(targetTag);
return this.roomMap.get(room);
}
public static get instance(): RoomNotificationStateStore {
@ -82,10 +91,8 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
}
protected async onNotReady(): Promise<any> {
for (const roomMap of this.roomMap.values()) {
for (const roomState of roomMap.values()) {
roomState.destroy();
}
for (const roomState of this.roomMap.values()) {
roomState.destroy();
}
}

View file

@ -0,0 +1,62 @@
/*
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 { NotificationColor } from "./NotificationColor";
import { NotificationState } from "./NotificationState";
/**
* Summarizes a number of states into a unique snapshot. To populate, call
* the add() function with the notification states to be included.
*
* Useful for community notification counts, global notification counts, etc.
*/
export class SummarizedNotificationState extends NotificationState {
private totalStatesWithUnread = 0;
constructor() {
super();
this._symbol = null;
this._count = 0;
this._color = NotificationColor.None;
}
public get numUnreadStates(): number {
return this.totalStatesWithUnread;
}
/**
* Append a notification state to this snapshot, taking the loudest NotificationColor
* of the two. By default this will not adopt the symbol of the other notification
* state to prevent the count from being lost in typical usage.
* @param other The other notification state to append.
* @param includeSymbol If true, the notification state's symbol will be taken if one
* is present.
*/
public add(other: NotificationState, includeSymbol = false) {
if (other.symbol && includeSymbol) {
this._symbol = other.symbol;
}
if (other.count) {
this._count += other.count;
}
if (other.color > this.color) {
this._color = other.color;
}
if (other.hasUnreadCount) {
this.totalStatesWithUnread++;
}
}
}

View file

@ -19,42 +19,24 @@ import { ActionPayload } from "../../dispatcher/payloads";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { MessageEventPreview } from "./previews/MessageEventPreview";
import { NameEventPreview } from "./previews/NameEventPreview";
import { TagID } from "./models";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { TopicEventPreview } from "./previews/TopicEventPreview";
import { MembershipEventPreview } from "./previews/MembershipEventPreview";
import { HistoryVisibilityEventPreview } from "./previews/HistoryVisibilityEventPreview";
import { CallInviteEventPreview } from "./previews/CallInviteEventPreview";
import { CallAnswerEventPreview } from "./previews/CallAnswerEventPreview";
import { CallHangupEvent } from "./previews/CallHangupEvent";
import { EncryptionEventPreview } from "./previews/EncryptionEventPreview";
import { ThirdPartyInviteEventPreview } from "./previews/ThirdPartyInviteEventPreview";
import { StickerEventPreview } from "./previews/StickerEventPreview";
import { ReactionEventPreview } from "./previews/ReactionEventPreview";
import { CreationEventPreview } from "./previews/CreationEventPreview";
import { UPDATE_EVENT } from "../AsyncStore";
// Emitted event for when a room's preview has changed. First argument will the room for which
// the change happened.
export const ROOM_PREVIEW_CHANGED = "room_preview_changed";
const PREVIEWS = {
'm.room.message': {
isState: false,
previewer: new MessageEventPreview(),
},
'm.room.name': {
isState: true,
previewer: new NameEventPreview(),
},
'm.room.topic': {
isState: true,
previewer: new TopicEventPreview(),
},
'm.room.member': {
isState: true,
previewer: new MembershipEventPreview(),
},
'm.room.history_visibility': {
isState: true,
previewer: new HistoryVisibilityEventPreview(),
},
'm.call.invite': {
isState: false,
previewer: new CallInviteEventPreview(),
@ -67,14 +49,6 @@ const PREVIEWS = {
isState: false,
previewer: new CallHangupEvent(),
},
'm.room.encryption': {
isState: true,
previewer: new EncryptionEventPreview(),
},
'm.room.third_party_invite': {
isState: true,
previewer: new ThirdPartyInviteEventPreview(),
},
'm.sticker': {
isState: false,
previewer: new StickerEventPreview(),
@ -83,10 +57,6 @@ const PREVIEWS = {
isState: false,
previewer: new ReactionEventPreview(),
},
'm.room.create': {
isState: true,
previewer: new CreationEventPreview(),
},
};
// The maximum number of events we're willing to look back on to get a preview.
@ -97,12 +67,15 @@ type TAG_ANY = "im.vector.any";
const TAG_ANY: TAG_ANY = "im.vector.any";
interface IState {
[roomId: string]: Map<TagID | TAG_ANY, string | null>; // null indicates the preview is empty / irrelevant
// Empty because we don't actually use the state
}
export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new MessagePreviewStore();
// null indicates the preview is empty / irrelevant
private previews = new Map<string, Map<TagID|TAG_ANY, string|null>>();
private constructor() {
super(defaultDispatcher, {});
}
@ -120,10 +93,9 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
public getPreviewForRoom(room: Room, inTagId: TagID): string {
if (!room) return null; // invalid room, just return nothing
const val = this.state[room.roomId];
if (!val) this.generatePreview(room, inTagId);
if (!this.previews.has(room.roomId)) this.generatePreview(room, inTagId);
const previews = this.state[room.roomId];
const previews = this.previews.get(room.roomId);
if (!previews) return null;
if (!previews.has(inTagId)) {
@ -136,11 +108,10 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
const events = room.timeline;
if (!events) return; // should only happen in tests
let map = this.state[room.roomId];
let map = this.previews.get(room.roomId);
if (!map) {
map = new Map<TagID | TAG_ANY, string | null>();
// We set the state later with the map, so no need to send an update now
this.previews.set(room.roomId, map);
}
// Set the tags so we know what to generate
@ -176,16 +147,18 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
}
if (changed) {
// Update state for good measure - causes emit for update
// noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls
this.updateState({[room.roomId]: map});
// We've muted the underlying Map, so just emit that we've changed.
this.previews.set(room.roomId, map);
this.emit(UPDATE_EVENT, this);
this.emit(ROOM_PREVIEW_CHANGED, room);
}
return; // we're done
}
// At this point, we didn't generate a preview so clear it
// noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls
this.updateState({[room.roomId]: null});
this.previews.set(room.roomId, new Map<TagID|TAG_ANY, string|null>());
this.emit(UPDATE_EVENT, this);
this.emit(ROOM_PREVIEW_CHANGED, room);
}
protected async onAction(payload: ActionPayload) {
@ -193,7 +166,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') {
const event = payload.event; // TODO: Type out the dispatcher
if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important
if (!this.previews.has(event.getRoomId())) return; // not important
this.generatePreview(this.matrixClient.getRoom(event.getRoomId()), TAG_ANY);
}
}

View file

@ -32,6 +32,8 @@ import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import RoomListLayoutStore from "./RoomListLayoutStore";
import { MarkedExecution } from "../../utils/MarkedExecution";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { NameFilterCondition } from "./filters/NameFilterCondition";
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
interface IState {
tagsEnabled?: boolean;
@ -54,7 +56,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
private algorithm = new Algorithm();
private filterConditions: IFilterCondition[] = [];
private tagWatcher = new TagWatcher(this);
private updateFn = new MarkedExecution(() => this.emit(LISTS_UPDATE_EVENT));
private updateFn = new MarkedExecution(() => {
for (const tagId of Object.keys(this.unfilteredLists)) {
RoomNotificationStateStore.instance.getListState(tagId).setRooms(this.unfilteredLists[tagId]);
}
this.emit(LISTS_UPDATE_EVENT);
});
private readonly watchedSettings = [
'feature_custom_tags',
@ -71,6 +78,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated);
}
public get unfilteredLists(): ITagMap {
if (!this.algorithm) return {}; // No tags yet.
return this.algorithm.getUnfilteredRooms();
}
public get orderedLists(): ITagMap {
if (!this.algorithm) return {}; // No tags yet.
return this.algorithm.getOrderedRooms();
@ -168,6 +180,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
}
protected async onAction(payload: ActionPayload) {
// If we're not remotely ready, don't even bother scheduling the dispatch handling.
// This is repeated in the handler just in case things change between a decision here and
// when the timer fires.
const logicallyReady = this.matrixClient && this.initialListsGenerated;
if (!logicallyReady) return;
// When we're running tests we can't reliably use setImmediate out of timing concerns.
// As such, we use a more synchronous model.
if (RoomListStoreClass.TEST_MODE) {
@ -582,6 +600,20 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
this.updateFn.trigger();
}
/**
* Gets the first (and ideally only) name filter condition. If one isn't present,
* this returns null.
* @returns The first name filter condition, or null if none.
*/
public getFirstNameFilterCondition(): NameFilterCondition | null {
for (const filter of this.filterConditions) {
if (filter instanceof NameFilterCondition) {
return filter;
}
}
return null;
}
/**
* Gets the tags for a room identified by the store. The returned set
* should never be empty, and will contain DefaultTagID.Untagged if

View file

@ -212,7 +212,18 @@ export class Algorithm extends EventEmitter {
// We specifically do NOT use the ordered rooms set as it contains the sticky room, which
// means we'll be off by 1 when the user is switching rooms. This leads to visual jumping
// when the user is moving south in the list (not north, because of math).
let position = this.getOrderedRoomsWithoutSticky()[tag].indexOf(val);
const tagList = this.getOrderedRoomsWithoutSticky()[tag] || []; // can be null if filtering
let position = tagList.indexOf(val);
// We do want to see if a tag change happened though - if this did happen then we'll want
// to force the position to zero (top) to ensure we can properly handle it.
const wasSticky = this._lastStickyRoom.room ? this._lastStickyRoom.room.roomId === val.roomId : false;
if (this._lastStickyRoom.tag && tag !== this._lastStickyRoom.tag && wasSticky && position < 0) {
console.warn(`Sticky room ${val.roomId} changed tags during sticky room handling`);
position = 0;
}
// Sanity check the position to make sure the room is qualified for being sticky
if (position < 0) throw new Error(`${val.roomId} does not appear to be known and cannot be sticky`);
// 🐉 Here be dragons.
@ -465,6 +476,10 @@ export class Algorithm extends EventEmitter {
return this.filteredRooms;
}
public getUnfilteredRooms(): ITagMap {
return this._cachedStickyRooms || this.cachedRooms;
}
/**
* This returns the same as getOrderedRooms(), but without the sticky room
* map as it causes issues for sticky room handling (see sticky room handling
@ -711,7 +726,9 @@ export class Algorithm extends EventEmitter {
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
this.cachedRooms[rmTag] = algorithm.orderedRooms;
this._cachedRooms[rmTag] = algorithm.orderedRooms;
this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list
this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed
}
for (const addTag of diff.added) {
if (SettingsStore.getValue("advancedRoomListLogging")) {
@ -721,7 +738,7 @@ export class Algorithm extends EventEmitter {
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
this.cachedRooms[addTag] = algorithm.orderedRooms;
this._cachedRooms[addTag] = algorithm.orderedRooms;
}
// Update the tag map so we don't regen it in a moment
@ -817,7 +834,7 @@ export class Algorithm extends EventEmitter {
if (!algorithm) throw new Error(`No algorithm for ${tag}`);
await algorithm.handleRoomUpdate(room, cause);
this.cachedRooms[tag] = algorithm.orderedRooms;
this._cachedRooms[tag] = algorithm.orderedRooms;
// Flag that we've done something
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list

View file

@ -90,7 +90,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
private getRoomCategory(room: Room): NotificationColor {
// It's fine for us to call this a lot because it's cached, and we shouldn't be
// wasting anything by doing so as the store holds single references
const state = RoomNotificationStateStore.instance.getRoomState(room, this.tagId);
const state = RoomNotificationStateStore.instance.getRoomState(room);
return state.color;
}
@ -123,6 +123,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
const category = this.getRoomCategory(room);
this.alterCategoryPositionBy(category, 1, this.indices);
this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
await this.sortCategory(category);
} else if (cause === RoomUpdateCause.RoomRemoved) {
const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) {
@ -135,6 +136,9 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
} else {
throw new Error(`Unhandled splice: ${cause}`);
}
// changes have been made if we made it here, so say so
return true;
}
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {

View file

@ -18,6 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
import { EventEmitter } from "events";
import { removeHiddenChars } from "matrix-js-sdk/src/utils";
import { throttle } from "lodash";
/**
* A filter condition for the room list which reveals rooms of a particular
@ -41,9 +42,13 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
public set search(val: string) {
this._search = val;
this.emit(FILTER_CHANGED);
this.callUpdate();
}
private callUpdate = throttle(() => {
this.emit(FILTER_CHANGED);
}, 200, {trailing: true, leading: true});
public isVisible(room: Room): boolean {
const lcFilter = this.search.toLowerCase();
if (this.search[0] === '#') {

View file

@ -1,31 +0,0 @@
/*
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 { IPreview } from "./IPreview";
import { TagID } from "../models";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { getSenderName, isSelf } from "./utils";
import { _t } from "../../../languageHandler";
export class CreationEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (isSelf(event)) {
return _t("You created the room");
} else {
return _t("%(senderName)s created the room", {senderName: getSenderName(event)});
}
}
}

View file

@ -1,31 +0,0 @@
/*
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 { IPreview } from "./IPreview";
import { TagID } from "../models";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { getSenderName, isSelf } from "./utils";
import { _t } from "../../../languageHandler";
export class EncryptionEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (isSelf(event)) {
return _t("You made the chat encrypted");
} else {
return _t("%(senderName)s made the chat encrypted", {senderName: getSenderName(event)});
}
}
}

View file

@ -1,42 +0,0 @@
/*
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 { IPreview } from "./IPreview";
import { TagID } from "../models";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { getSenderName, isSelf } from "./utils";
import { _t } from "../../../languageHandler";
export class HistoryVisibilityEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
const visibility = event.getContent()['history_visibility'];
const isUs = isSelf(event);
if (visibility === 'invited' || visibility === 'joined') {
return isUs
? _t("You made history visible to new members")
: _t("%(senderName)s made history visible to new members", {senderName: getSenderName(event)});
} else if (visibility === 'world_readable') {
return isUs
? _t("You made history visible to anyone")
: _t("%(senderName)s made history visible to anyone", {senderName: getSenderName(event)});
} else { // shared, default
return isUs
? _t("You made history visible to future members")
: _t("%(senderName)s made history visible to future members", {senderName: getSenderName(event)});
}
}
}

View file

@ -1,90 +0,0 @@
/*
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 { IPreview } from "./IPreview";
import { TagID } from "../models";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { getTargetName, isSelfTarget } from "./utils";
import { _t } from "../../../languageHandler";
export class MembershipEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
const newMembership = event.getContent()['membership'];
const oldMembership = event.getPrevContent()['membership'];
const reason = event.getContent()['reason'];
const isUs = isSelfTarget(event);
if (newMembership === 'invite') {
return isUs
? _t("You were invited")
: _t("%(targetName)s was invited", {targetName: getTargetName(event)});
} else if (newMembership === 'leave' && oldMembership !== 'invite') {
if (event.getSender() === event.getStateKey()) {
return isUs
? _t("You left")
: _t("%(targetName)s left", {targetName: getTargetName(event)});
} else {
if (reason) {
return isUs
? _t("You were kicked (%(reason)s)", {reason})
: _t("%(targetName)s was kicked (%(reason)s)", {targetName: getTargetName(event), reason});
} else {
return isUs
? _t("You were kicked")
: _t("%(targetName)s was kicked", {targetName: getTargetName(event)});
}
}
} else if (newMembership === 'leave' && oldMembership === 'invite') {
if (event.getSender() === event.getStateKey()) {
return isUs
? _t("You rejected the invite")
: _t("%(targetName)s rejected the invite", {targetName: getTargetName(event)});
} else {
return isUs
? _t("You were uninvited")
: _t("%(targetName)s was uninvited", {targetName: getTargetName(event)});
}
} else if (newMembership === 'ban') {
if (reason) {
return isUs
? _t("You were banned (%(reason)s)", {reason})
: _t("%(targetName)s was banned (%(reason)s)", {targetName: getTargetName(event), reason});
} else {
return isUs
? _t("You were banned")
: _t("%(targetName)s was banned", {targetName: getTargetName(event)});
}
} else if (newMembership === 'join' && oldMembership !== 'join') {
return isUs
? _t("You joined")
: _t("%(targetName)s joined", {targetName: getTargetName(event)});
} else {
const isDisplayNameChange = event.getContent()['displayname'] !== event.getPrevContent()['displayname'];
const isAvatarChange = event.getContent()['avatar_url'] !== event.getPrevContent()['avatar_url'];
if (isDisplayNameChange) {
return isUs
? _t("You changed your name")
: _t("%(targetName)s changed their name", {targetName: getTargetName(event)});
} else if (isAvatarChange) {
return isUs
? _t("You changed your avatar")
: _t("%(targetName)s changed their avatar", {targetName: getTargetName(event)});
} else {
return null; // no change
}
}
}
}

View file

@ -59,7 +59,7 @@ export class MessageEventPreview implements IPreview {
}
if (msgtype === 'm.emote') {
return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body});
return _t("* %(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body});
}
if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) {

View file

@ -1,31 +0,0 @@
/*
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 { IPreview } from "./IPreview";
import { TagID } from "../models";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { getSenderName, isSelf } from "./utils";
import { _t } from "../../../languageHandler";
export class NameEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (isSelf(event)) {
return _t("You changed the room name");
} else {
return _t("%(senderName)s changed the room name", {senderName: getSenderName(event)});
}
}
}

View file

@ -1,42 +0,0 @@
/*
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 { IPreview } from "./IPreview";
import { TagID } from "../models";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { getSenderName, isSelf } from "./utils";
import { _t } from "../../../languageHandler";
import { isValid3pidInvite } from "../../../RoomInvite";
export class ThirdPartyInviteEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (!isValid3pidInvite(event)) {
const targetName = event.getPrevContent().display_name || _t("Someone");
if (isSelf(event)) {
return _t("You uninvited %(targetName)s", {targetName});
} else {
return _t("%(senderName)s uninvited %(targetName)s", {senderName: getSenderName(event), targetName});
}
} else {
const targetName = event.getContent().display_name;
if (isSelf(event)) {
return _t("You invited %(targetName)s", {targetName});
} else {
return _t("%(senderName)s invited %(targetName)s", {senderName: getSenderName(event), targetName});
}
}
}
}

View file

@ -1,31 +0,0 @@
/*
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 { IPreview } from "./IPreview";
import { TagID } from "../models";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { getSenderName, isSelf } from "./utils";
import { _t } from "../../../languageHandler";
export class TopicEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (isSelf(event)) {
return _t("You changed the room topic");
} else {
return _t("%(senderName)s changed the room topic", {senderName: getSenderName(event)});
}
}
}