Merge remote-tracking branch 'upstream/develop' into feature-surround-with

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-06-20 08:14:12 +02:00
commit 686e7d18c3
No known key found for this signature in database
GPG key ID: 9760693FDD98A790
832 changed files with 48885 additions and 13652 deletions

26
src/settings/Layout.ts Normal file
View file

@ -0,0 +1,26 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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 PropTypes from 'prop-types';
/* TODO: This should be later reworked into something more generic */
export enum Layout {
IRC = "irc",
Group = "group"
}
/* We need this because multiple components are still using JavaScript */
export const LayoutPropType = PropTypes.oneOf(Object.values(Layout));

View file

@ -16,8 +16,9 @@ limitations under the License.
*/
import { MatrixClient } from 'matrix-js-sdk/src/client';
import React, { ReactNode } from "react";
import { _td } from '../languageHandler';
import { _t, _td } from '../languageHandler';
import {
NotificationBodyEnabledController,
NotificationsEnabledController,
@ -36,6 +37,10 @@ import { isMac } from '../Keyboard';
import UIFeatureController from "./controllers/UIFeatureController";
import { UIFeature } from "./UIFeature";
import { OrderedMultiController } from "./controllers/OrderedMultiController";
import { Layout } from "./Layout";
import ReducedMotionController from './controllers/ReducedMotionController';
import IncompatibleController from "./controllers/IncompatibleController";
import SdkConfig from "../SdkConfig";
// These are just a bunch of helper arrays to avoid copy/pasting a bunch of times
const LEVELS_ROOM_SETTINGS = [
@ -114,9 +119,75 @@ export interface ISetting {
// historical settings which we don't want existing user's values be wiped. Do
// not use this for new settings.
invertedSettingName?: string;
betaInfo?: {
title: string; // _td
caption: string; // _td
disclaimer?: (enabled: boolean) => ReactNode;
image: string; // require(...)
feedbackSubheading?: string;
feedbackLabel?: string;
};
}
export const SETTINGS: {[setting: string]: ISetting} = {
"feature_report_to_moderators": {
isFeature: true,
displayName: _td("Report to moderators prototype. " +
"In rooms that support moderation, the `report` button will let you report abuse to room moderators"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_spaces": {
isFeature: true,
displayName: _td("Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. " +
"Requires compatible homeserver for some features."),
supportedLevels: LEVELS_FEATURE,
default: false,
controller: new ReloadOnChangeController(),
betaInfo: {
title: _td("Spaces"),
caption: _td("Spaces are a new way to group rooms and people."),
disclaimer: (enabled) => {
if (enabled) {
return <>
<p>{ _t("If you leave, %(brand)s will reload with Spaces disabled. " +
"Communities and custom tags will be visible again.", {
brand: SdkConfig.get().brand,
}) }</p>
<p>{ _t("Beta available for web, desktop and Android. Thank you for trying the beta.") }</p>
</>;
}
return <>
<p>{ _t("%(brand)s will reload with Spaces enabled. " +
"Communities and custom tags will be hidden.", {
brand: SdkConfig.get().brand,
}) }</p>
<b>{ _t("You can leave the beta any time from settings or tapping on a beta badge, " +
"like the one above.") }</b>
<p>{ _t("Beta available for web, desktop and Android. " +
"Some features may be unavailable on your homeserver.") }</p>
</>;
},
image: require("../../res/img/betas/spaces.png"),
feedbackSubheading: _td("Your feedback will help make spaces better. " +
"The more detail you can go into, the better."),
feedbackLabel: "spaces-feedback",
},
},
"feature_dnd": {
isFeature: true,
displayName: _td("Show options to enable 'Do not disturb' mode"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_voice_messages": {
isFeature: true,
displayName: _td("Send and receive voice messages"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_latex_maths": {
isFeature: true,
displayName: _td("Render LaTeX maths in messages"),
@ -131,12 +202,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_new_spinner": {
isFeature: true,
displayName: _td("New spinner design"),
supportedLevels: LEVELS_FEATURE,
default: false,
controller: new IncompatibleController("feature_spaces"),
},
"feature_pinning": {
isFeature: true,
@ -156,6 +222,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td("Group & filter rooms by custom tags (refresh to apply changes)"),
supportedLevels: LEVELS_FEATURE,
default: false,
controller: new IncompatibleController("feature_spaces"),
},
"feature_state_counters": {
isFeature: true,
@ -186,6 +253,8 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td("Show message previews for reactions in DMs"),
supportedLevels: LEVELS_FEATURE,
default: false,
// this option is a subset of `feature_roomlist_preview_reactions_all` so disable it when that one is enabled
controller: new IncompatibleController("feature_roomlist_preview_reactions_all"),
},
"feature_roomlist_preview_reactions_all": {
isFeature: true,
@ -205,6 +274,10 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: false,
},
"doNotDisturb": {
supportedLevels: [SettingLevel.DEVICE],
default: false,
},
"mjolnirRooms": {
supportedLevels: [SettingLevel.ACCOUNT],
default: [],
@ -315,6 +388,11 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td('Show line numbers in code blocks'),
default: true,
},
"scrollToBottomOnMessageSent": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Jump to the bottom of the timeline when you send a message'),
default: true,
},
"Pill.shouldShowPillAvatar": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Show avatars in user and room mentions'),
@ -407,7 +485,10 @@ export const SETTINGS: {[setting: string]: ISetting} = {
},
"webRtcAllowPeerToPeer": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
displayName: _td('Allow Peer-to-Peer for 1:1 calls'),
displayName: _td(
"Allow Peer-to-Peer for 1:1 calls " +
"(if you enable this, the other party might be able to see your IP address)",
),
default: true,
invertedSettingName: 'webRtcForceTURN',
},
@ -532,10 +613,6 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td('Enable widget screenshots on supported widgets'),
default: false,
},
"PinnedEvents.isOpen": {
supportedLevels: [SettingLevel.ROOM_DEVICE],
default: false,
},
"promptBeforeInviteUnknownUsers": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Prompt before sending invites to potentially invalid matrix IDs'),
@ -622,6 +699,8 @@ export const SETTINGS: {[setting: string]: ISetting} = {
default: 3000,
},
"showCallButtonsInComposer": {
// Dev note: This is no longer "in composer" but is instead "in room header".
// TODO: Rename with settings v3
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
default: true,
controller: new UIFeatureController(UIFeature.Voip),
@ -648,15 +727,15 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td("IRC display name width"),
default: 80,
},
"useIRCLayout": {
"layout": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Enable experimental, compact IRC style layout"),
default: false,
default: Layout.Group,
},
"showChatEffects": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Show chat effects"),
supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM,
displayName: _td("Show chat effects (animations when receiving e.g. confetti)"),
default: true,
controller: new ReducedMotionController(),
},
"Widgets.pinned": { // deprecated
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
@ -733,6 +812,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
[UIFeature.Communities]: {
supportedLevels: LEVELS_UI_FEATURE,
default: true,
controller: new IncompatibleController("feature_spaces"),
},
[UIFeature.AdvancedSettings]: {
supportedLevels: LEVELS_UI_FEATURE,

View file

@ -26,7 +26,7 @@ import { _t } from '../languageHandler';
import dis from '../dispatcher/dispatcher';
import { ISetting, SETTINGS } from "./Settings";
import LocalEchoWrapper from "./handlers/LocalEchoWrapper";
import { WatchManager } from "./WatchManager";
import { WatchManager, CallbackFn as WatchCallbackFn } from "./WatchManager";
import { SettingLevel } from "./SettingLevel";
import SettingsHandler from "./handlers/SettingsHandler";
@ -61,7 +61,7 @@ for (const key of Object.keys(LEVEL_HANDLERS)) {
LEVEL_HANDLERS[key] = new LocalEchoWrapper(LEVEL_HANDLERS[key]);
}
const LEVEL_ORDER = [
export const LEVEL_ORDER = [
SettingLevel.DEVICE,
SettingLevel.ROOM_DEVICE,
SettingLevel.ROOM_ACCOUNT,
@ -117,8 +117,8 @@ export default class SettingsStore {
// We also maintain a list of monitors which are special watchers: they cause dispatches
// when the setting changes. We track which rooms we're monitoring though to ensure we
// don't duplicate updates on the bus.
private static watchers = {}; // { callbackRef => { callbackFn } }
private static monitors = {}; // { settingName => { roomId => callbackRef } }
private static watchers = new Map<string, WatchCallbackFn>();
private static monitors = new Map<string, Map<string, string>>(); // { settingName => { roomId => callbackRef } }
// Counter used for generation of watcher IDs
private static watcherCount = 1;
@ -163,7 +163,7 @@ export default class SettingsStore {
callbackFn(originalSettingName, changedInRoomId, atLevel, newValAtLevel, newValue);
};
SettingsStore.watchers[watcherId] = localizedCallback;
SettingsStore.watchers.set(watcherId, localizedCallback);
defaultWatchManager.watchSetting(settingName, roomId, localizedCallback);
return watcherId;
@ -176,13 +176,13 @@ export default class SettingsStore {
* to cancel.
*/
public static unwatchSetting(watcherReference: string) {
if (!SettingsStore.watchers[watcherReference]) {
if (!SettingsStore.watchers.has(watcherReference)) {
console.warn(`Ending non-existent watcher ID ${watcherReference}`);
return;
}
defaultWatchManager.unwatchSetting(SettingsStore.watchers[watcherReference]);
delete SettingsStore.watchers[watcherReference];
defaultWatchManager.unwatchSetting(SettingsStore.watchers.get(watcherReference));
SettingsStore.watchers.delete(watcherReference);
}
/**
@ -196,10 +196,10 @@ export default class SettingsStore {
public static monitorSetting(settingName: string, roomId: string) {
roomId = roomId || null; // the thing wants null specifically to work, so appease it.
if (!this.monitors[settingName]) this.monitors[settingName] = {};
if (!this.monitors.has(settingName)) this.monitors.set(settingName, new Map());
const registerWatcher = () => {
this.monitors[settingName][roomId] = SettingsStore.watchSetting(
this.monitors.get(settingName).set(roomId, SettingsStore.watchSetting(
settingName, roomId, (settingName, inRoomId, level, newValueAtLevel, newValue) => {
dis.dispatch({
action: 'setting_updated',
@ -210,19 +210,20 @@ export default class SettingsStore {
newValue,
});
},
);
));
};
const hasRoom = Object.keys(this.monitors[settingName]).find((r) => r === roomId || r === null);
const rooms = Array.from(this.monitors.get(settingName).keys());
const hasRoom = rooms.find((r) => r === roomId || r === null);
if (!hasRoom) {
registerWatcher();
} else {
if (roomId === null) {
// Unregister all existing watchers and register the new one
for (const roomId of Object.keys(this.monitors[settingName])) {
SettingsStore.unwatchSetting(this.monitors[settingName][roomId]);
}
this.monitors[settingName] = {};
rooms.forEach(roomId => {
SettingsStore.unwatchSetting(this.monitors.get(settingName).get(roomId));
});
this.monitors.get(settingName).clear();
registerWatcher();
} // else a watcher is already registered for the room, so don't bother registering it again
}
@ -257,6 +258,15 @@ export default class SettingsStore {
return SETTINGS[settingName].isFeature;
}
public static getBetaInfo(settingName: string) {
// consider a beta disabled if the config is explicitly set to false, in which case treat as normal Labs flag
if (SettingsStore.isFeature(settingName)
&& SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, null, true, true) !== false
) {
return SETTINGS[settingName]?.betaInfo;
}
}
/**
* Determines if a setting is enabled.
* If a setting is disabled then it should be hidden from the user.
@ -445,8 +455,8 @@ export default class SettingsStore {
throw new Error("Setting '" + settingName + "' does not appear to be a setting.");
}
// When features are specified in the config.json, we force them as enabled or disabled.
if (SettingsStore.isFeature(settingName)) {
// When non-beta features are specified in the config.json, we force them as enabled or disabled.
if (SettingsStore.isFeature(settingName) && !SETTINGS[settingName]?.betaInfo) {
const configVal = SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, roomId, true, true);
if (configVal === true || configVal === false) return false;
}

View file

@ -18,11 +18,7 @@ import { SettingLevel } from "./SettingLevel";
export type CallbackFn = (changedInRoomId: string, atLevel: SettingLevel, newValAtLevel: any) => void;
const IRRELEVANT_ROOM: string = null;
interface RoomWatcherMap {
[roomId: string]: CallbackFn[];
}
const IRRELEVANT_ROOM = Symbol("irrelevant-room");
/**
* Generalized management class for dealing with watchers on a per-handler (per-level)
@ -30,25 +26,25 @@ interface RoomWatcherMap {
* class, which are then proxied outwards to any applicable watchers.
*/
export class WatchManager {
private watchers: {[settingName: string]: RoomWatcherMap} = {};
private watchers = new Map<string, Map<string | symbol, CallbackFn[]>>(); // settingName -> roomId -> CallbackFn[]
// Proxy for handlers to delegate changes to this manager
public watchSetting(settingName: string, roomId: string | null, cb: CallbackFn) {
if (!this.watchers[settingName]) this.watchers[settingName] = {};
if (!this.watchers[settingName][roomId]) this.watchers[settingName][roomId] = [];
this.watchers[settingName][roomId].push(cb);
if (!this.watchers.has(settingName)) this.watchers.set(settingName, new Map());
if (!this.watchers.get(settingName).has(roomId)) this.watchers.get(settingName).set(roomId, []);
this.watchers.get(settingName).get(roomId).push(cb);
}
// Proxy for handlers to delegate changes to this manager
public unwatchSetting(cb: CallbackFn) {
for (const settingName of Object.keys(this.watchers)) {
for (const roomId of Object.keys(this.watchers[settingName])) {
this.watchers.forEach((map) => {
map.forEach((callbacks) => {
let idx;
while ((idx = this.watchers[settingName][roomId].indexOf(cb)) !== -1) {
this.watchers[settingName][roomId].splice(idx, 1);
while ((idx = callbacks.indexOf(cb)) !== -1) {
callbacks.splice(idx, 1);
}
}
}
});
});
}
public notifyUpdate(settingName: string, inRoomId: string | null, atLevel: SettingLevel, newValueAtLevel: any) {
@ -56,21 +52,20 @@ export class WatchManager {
// we also don't have a reliable way to get the old value of a setting. Instead, we'll just
// let it fall through regardless and let the receiver dedupe if they want to.
if (!this.watchers[settingName]) return;
if (!this.watchers.has(settingName)) return;
const roomWatchers = this.watchers[settingName];
const roomWatchers = this.watchers.get(settingName);
const callbacks = [];
if (inRoomId !== null && roomWatchers[inRoomId]) {
callbacks.push(...roomWatchers[inRoomId]);
if (inRoomId !== null && roomWatchers.has(inRoomId)) {
callbacks.push(...roomWatchers.get(inRoomId));
}
if (!inRoomId) {
// Fire updates to all the individual room watchers too, as they probably
// care about the change higher up.
callbacks.push(...Object.values(roomWatchers).flat(1));
} else if (roomWatchers[IRRELEVANT_ROOM]) {
callbacks.push(...roomWatchers[IRRELEVANT_ROOM]);
// Fire updates to all the individual room watchers too, as they probably care about the change higher up.
callbacks.push(...Array.from(roomWatchers.values()).flat(1));
} else if (roomWatchers.has(IRRELEVANT_ROOM)) {
callbacks.push(...roomWatchers.get(IRRELEVANT_ROOM));
}
for (const callback of callbacks) {

View file

@ -0,0 +1,46 @@
/*
Copyright 2021 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 SettingController from "./SettingController";
import { SettingLevel } from "../SettingLevel";
import SettingsStore from "../SettingsStore";
/**
* Enforces that a boolean setting cannot be enabled if the incompatible setting
* is also enabled, to prevent cascading undefined behaviour between conflicting
* labs flags.
*/
export default class IncompatibleController extends SettingController {
public constructor(private settingName: string, private forcedValue = false) {
super();
}
public getValueOverride(
level: SettingLevel,
roomId: string,
calculatedValue: any,
calculatedAtLevel: SettingLevel,
): any {
if (this.incompatibleSettingEnabled) {
return this.forcedValue;
}
return null; // no override
}
public get incompatibleSettingEnabled(): boolean {
return SettingsStore.getValue(this.settingName);
}
}

View file

@ -0,0 +1,45 @@
/*
Copyright 2021 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 SettingController from "./SettingController";
import { SettingLevel } from "../SettingLevel";
/**
* For animation-like settings, this controller checks whether the user has
* indicated they prefer reduced motion via browser or OS level settings.
* If they have, this forces the setting value to false.
*/
export default class ReducedMotionController extends SettingController {
public getValueOverride(
level: SettingLevel,
roomId: string,
calculatedValue: any,
calculatedAtLevel: SettingLevel,
): any {
if (this.prefersReducedMotion()) {
return false;
}
return null; // no override
}
public get settingDisabled(): boolean {
return this.prefersReducedMotion();
}
private prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
}

View file

@ -20,6 +20,7 @@ import SettingsHandler from "./SettingsHandler";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import {SettingLevel} from "../SettingLevel";
import { CallbackFn, WatchManager } from "../WatchManager";
import { Layout } from "../Layout";
/**
* Gets and sets settings at the "device" level for the current device.
@ -67,6 +68,13 @@ export default class DeviceSettingsHandler extends SettingsHandler {
return val['value'];
}
// Special case for old useIRCLayout setting
if (settingName === "layout") {
const settings = this.getSettings() || {};
if (settings["useIRCLayout"]) return Layout.IRC;
return settings[settingName];
}
const settings = this.getSettings() || {};
return settings[settingName];
}
@ -106,6 +114,18 @@ export default class DeviceSettingsHandler extends SettingsHandler {
return Promise.resolve();
}
// Special case for old useIRCLayout setting
if (settingName === "layout") {
const settings = this.getSettings() || {};
delete settings["useIRCLayout"];
settings["layout"] = newValue;
localStorage.setItem("mx_local_settings", JSON.stringify(settings));
this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue);
return Promise.resolve();
}
const settings = this.getSettings() || {};
settings[settingName] = newValue;
localStorage.setItem("mx_local_settings", JSON.stringify(settings));