Merge remote-tracking branch 'upstream/develop' into public-e2ee-warn
This commit is contained in:
commit
6e688b3c3a
139 changed files with 5141 additions and 1989 deletions
|
@ -1,7 +1,8 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
|
@ -60,7 +61,6 @@ import Modal from './Modal';
|
|||
import { _t } from './languageHandler';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import WidgetUtils from './utils/WidgetUtils';
|
||||
import WidgetEchoStore from './stores/WidgetEchoStore';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import { Jitsi } from "./widgets/Jitsi";
|
||||
import { WidgetType } from "./widgets/WidgetType";
|
||||
|
@ -86,6 +86,12 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/
|
|||
import EventEmitter from 'events';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import { ensureDMExists, findDMForUser } from './createRoom';
|
||||
import { IPushRule, RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules";
|
||||
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
|
||||
import { WidgetLayoutStore, Container } from './stores/widgets/WidgetLayoutStore';
|
||||
import { getIncomingCallToastKey } from './toasts/IncomingCallToast';
|
||||
import ToastStore from './stores/ToastStore';
|
||||
import IncomingCallToast from "./toasts/IncomingCallToast";
|
||||
|
||||
export const PROTOCOL_PSTN = 'm.protocol.pstn';
|
||||
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
|
||||
|
@ -476,14 +482,28 @@ export default class CallHandler extends EventEmitter {
|
|||
}
|
||||
|
||||
switch (newState) {
|
||||
case CallState.Ringing:
|
||||
this.play(AudioID.Ring);
|
||||
case CallState.Ringing: {
|
||||
const incomingCallPushRule = (
|
||||
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall) as IPushRule
|
||||
);
|
||||
const pushRuleEnabled = incomingCallPushRule?.enabled;
|
||||
const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => (
|
||||
action.set_tweak === TweakName.Sound &&
|
||||
action.value === "ring"
|
||||
));
|
||||
|
||||
if (pushRuleEnabled && tweakSetToRing) {
|
||||
this.play(AudioID.Ring);
|
||||
} else {
|
||||
this.silenceCall(call.callId);
|
||||
}
|
||||
break;
|
||||
case CallState.InviteSent:
|
||||
}
|
||||
case CallState.InviteSent: {
|
||||
this.play(AudioID.Ringback);
|
||||
break;
|
||||
case CallState.Ended:
|
||||
{
|
||||
}
|
||||
case CallState.Ended: {
|
||||
const hangupReason = call.hangupReason;
|
||||
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
|
||||
this.removeCallForRoom(mappedRoomId);
|
||||
|
@ -624,6 +644,19 @@ export default class CallHandler extends EventEmitter {
|
|||
`Call state in ${mappedRoomId} changed to ${status}`,
|
||||
);
|
||||
|
||||
const toastKey = getIncomingCallToastKey(call.callId);
|
||||
if (status === CallState.Ringing) {
|
||||
ToastStore.sharedInstance().addOrReplaceToast({
|
||||
key: toastKey,
|
||||
priority: 100,
|
||||
component: IncomingCallToast,
|
||||
bodyClassName: "mx_IncomingCallToast",
|
||||
props: { call },
|
||||
});
|
||||
} else {
|
||||
ToastStore.sharedInstance().dismissToast(toastKey);
|
||||
}
|
||||
|
||||
dis.dispatch({
|
||||
action: 'call_state',
|
||||
room_id: mappedRoomId,
|
||||
|
@ -914,6 +947,8 @@ export default class CallHandler extends EventEmitter {
|
|||
action: 'view_room',
|
||||
room_id: roomId,
|
||||
});
|
||||
|
||||
await this.placeCall(roomId, PlaceCallType.Voice, null);
|
||||
}
|
||||
|
||||
private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) {
|
||||
|
@ -993,14 +1028,10 @@ export default class CallHandler extends EventEmitter {
|
|||
|
||||
// prevent double clicking the call button
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
|
||||
const hasJitsi = currentJitsiWidgets.length > 0
|
||||
|| WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI);
|
||||
if (hasJitsi) {
|
||||
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
|
||||
title: _t('Call in Progress'),
|
||||
description: _t('A call is currently being placed!'),
|
||||
});
|
||||
const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type));
|
||||
if (jitsiWidget) {
|
||||
// If there already is a Jitsi widget pin it
|
||||
WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -209,6 +209,14 @@ async function loadImageElement(imageFile: File) {
|
|||
return { width, height, img };
|
||||
}
|
||||
|
||||
// Minimum size for image files before we generate a thumbnail for them.
|
||||
const IMAGE_SIZE_THRESHOLD_THUMBNAIL = 1 << 15; // 32KB
|
||||
// Minimum size improvement for image thumbnails, if both are not met then don't bother uploading thumbnail.
|
||||
const IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE = 1 << 16; // 1MB
|
||||
const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10%
|
||||
// We don't apply these thresholds to video thumbnails as a poster image is always useful
|
||||
// and videos tend to be much larger.
|
||||
|
||||
/**
|
||||
* Read the metadata for an image file and create and upload a thumbnail of the image.
|
||||
*
|
||||
|
@ -217,23 +225,33 @@ async function loadImageElement(imageFile: File) {
|
|||
* @param {File} imageFile The image to read and thumbnail.
|
||||
* @return {Promise} A promise that resolves with the attachment info.
|
||||
*/
|
||||
function infoForImageFile(matrixClient, roomId, imageFile) {
|
||||
async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File) {
|
||||
let thumbnailType = "image/png";
|
||||
if (imageFile.type === "image/jpeg") {
|
||||
thumbnailType = "image/jpeg";
|
||||
}
|
||||
|
||||
let imageInfo;
|
||||
return loadImageElement(imageFile).then((r) => {
|
||||
return createThumbnail(r.img, r.width, r.height, thumbnailType);
|
||||
}).then((result) => {
|
||||
imageInfo = result.info;
|
||||
return uploadFile(matrixClient, roomId, result.thumbnail);
|
||||
}).then((result) => {
|
||||
imageInfo.thumbnail_url = result.url;
|
||||
imageInfo.thumbnail_file = result.file;
|
||||
const imageElement = await loadImageElement(imageFile);
|
||||
|
||||
const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType);
|
||||
const imageInfo = result.info;
|
||||
|
||||
// we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
|
||||
const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size;
|
||||
if (
|
||||
imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || // image is small enough already
|
||||
(sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && // thumbnail is not sufficiently smaller than original
|
||||
sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT))
|
||||
) {
|
||||
delete imageInfo["thumbnail_info"];
|
||||
return imageInfo;
|
||||
});
|
||||
}
|
||||
|
||||
const uploadResult = await uploadFile(matrixClient, roomId, result.thumbnail);
|
||||
|
||||
imageInfo["thumbnail_url"] = uploadResult.url;
|
||||
imageInfo["thumbnail_file"] = uploadResult.file;
|
||||
return imageInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -57,7 +57,33 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
|
|||
|
||||
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet', 'matrix'];
|
||||
export const PERMITTED_URL_SCHEMES = [
|
||||
"bitcoin",
|
||||
"ftp",
|
||||
"geo",
|
||||
"http",
|
||||
"https",
|
||||
"im",
|
||||
"irc",
|
||||
"ircs",
|
||||
"magnet",
|
||||
"mailto",
|
||||
"matrix",
|
||||
"mms",
|
||||
"news",
|
||||
"nntp",
|
||||
"openpgp4fpr",
|
||||
"sip",
|
||||
"sftp",
|
||||
"sms",
|
||||
"smsto",
|
||||
"ssh",
|
||||
"tel",
|
||||
"urn",
|
||||
"webcal",
|
||||
"wtai",
|
||||
"xmpp",
|
||||
];
|
||||
|
||||
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
|
||||
|
||||
|
|
|
@ -146,23 +146,23 @@ export default class IdentityAuthClient {
|
|||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '',
|
||||
QuestionDialog, {
|
||||
title: _t("Identity server has no terms of service"),
|
||||
description: (
|
||||
<div>
|
||||
<p>{ _t(
|
||||
"This action requires accessing the default identity server " +
|
||||
title: _t("Identity server has no terms of service"),
|
||||
description: (
|
||||
<div>
|
||||
<p>{ _t(
|
||||
"This action requires accessing the default identity server " +
|
||||
"<server /> to validate an email address or phone number, " +
|
||||
"but the server does not have any terms of service.", {},
|
||||
{
|
||||
server: () => <b>{ abbreviateUrl(identityServerUrl) }</b>,
|
||||
},
|
||||
) }</p>
|
||||
<p>{ _t(
|
||||
"Only continue if you trust the owner of the server.",
|
||||
) }</p>
|
||||
</div>
|
||||
),
|
||||
button: _t("Trust"),
|
||||
{
|
||||
server: () => <b>{ abbreviateUrl(identityServerUrl) }</b>,
|
||||
},
|
||||
) }</p>
|
||||
<p>{ _t(
|
||||
"Only continue if you trust the owner of the server.",
|
||||
) }</p>
|
||||
</div>
|
||||
),
|
||||
button: _t("Trust"),
|
||||
});
|
||||
const [confirmed] = await finished;
|
||||
if (confirmed) {
|
||||
|
|
|
@ -48,6 +48,7 @@ import { Jitsi } from "./widgets/Jitsi";
|
|||
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform";
|
||||
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
|
||||
import CountlyAnalytics from "./CountlyAnalytics";
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import CallHandler from './CallHandler';
|
||||
import LifecycleCustomisations from "./customisations/Lifecycle";
|
||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||
|
@ -573,6 +574,8 @@ async function doSetLoggedIn(
|
|||
await abortLogin();
|
||||
}
|
||||
|
||||
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
|
||||
|
||||
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
|
||||
|
||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
||||
|
@ -700,6 +703,8 @@ export function logout(): void {
|
|||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||
}
|
||||
|
||||
PosthogAnalytics.instance.logout();
|
||||
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
// logout doesn't work for guest sessions
|
||||
// Also we sometimes want to re-log in a guest session if we abort the login.
|
||||
|
|
355
src/PosthogAnalytics.ts
Normal file
355
src/PosthogAnalytics.ts
Normal file
|
@ -0,0 +1,355 @@
|
|||
/*
|
||||
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 posthog, { PostHog } from 'posthog-js';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
|
||||
/* Posthog analytics tracking.
|
||||
*
|
||||
* Anonymity behaviour is as follows:
|
||||
*
|
||||
* - If Posthog isn't configured in `config.json`, events are not sent.
|
||||
* - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is
|
||||
* enabled, events are not sent (this detection is built into posthog and turned on via the
|
||||
* `respect_dnt` flag being passed to `posthog.init`).
|
||||
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e.
|
||||
* hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256.
|
||||
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e.
|
||||
* redact all matrix identifiers in tracking events.
|
||||
* - If both flags are false or not set, events are not sent.
|
||||
*/
|
||||
|
||||
interface IEvent {
|
||||
// The event name that will be used by PostHog. Event names should use snake_case.
|
||||
eventName: string;
|
||||
|
||||
// The properties of the event that will be stored in PostHog. This is just a placeholder,
|
||||
// extending interfaces must override this with a concrete definition to do type validation.
|
||||
properties: {};
|
||||
}
|
||||
|
||||
export enum Anonymity {
|
||||
Disabled,
|
||||
Anonymous,
|
||||
Pseudonymous
|
||||
}
|
||||
|
||||
// If an event extends IPseudonymousEvent, the event contains pseudonymous data
|
||||
// that won't be sent unless the user has explicitly consented to pseudonymous tracking.
|
||||
// For example, it might contain hashed user IDs or room IDs.
|
||||
// Such events will be automatically dropped if PosthogAnalytics.anonymity isn't set to Pseudonymous.
|
||||
export interface IPseudonymousEvent extends IEvent {}
|
||||
|
||||
// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data;
|
||||
// i.e. no identifiers that can be associated with the user.
|
||||
export interface IAnonymousEvent extends IEvent {}
|
||||
|
||||
export interface IRoomEvent extends IPseudonymousEvent {
|
||||
hashedRoomId: string;
|
||||
}
|
||||
|
||||
interface IPageView extends IAnonymousEvent {
|
||||
eventName: "$pageview";
|
||||
properties: {
|
||||
durationMs?: number;
|
||||
screen?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const hashHex = async (input: string): Promise<string> => {
|
||||
const buf = new TextEncoder().encode(input);
|
||||
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
|
||||
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
const whitelistedScreens = new Set([
|
||||
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
|
||||
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
|
||||
]);
|
||||
|
||||
export async function getRedactedCurrentLocation(
|
||||
origin: string,
|
||||
hash: string,
|
||||
pathname: string,
|
||||
anonymity: Anonymity,
|
||||
): Promise<string> {
|
||||
// Redact PII from the current location.
|
||||
// If anonymous is true, redact entirely, if false, substitute it with a hash.
|
||||
// For known screens, assumes a URL structure of /<screen name>/might/be/pii
|
||||
if (origin.startsWith('file://')) {
|
||||
pathname = "/<redacted_file_scheme_url>/";
|
||||
}
|
||||
|
||||
let hashStr;
|
||||
if (hash == "") {
|
||||
hashStr = "";
|
||||
} else {
|
||||
let [beforeFirstSlash, screen, ...parts] = hash.split("/");
|
||||
|
||||
if (!whitelistedScreens.has(screen)) {
|
||||
screen = "<redacted_screen_name>";
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
|
||||
}
|
||||
|
||||
hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`;
|
||||
}
|
||||
return origin + pathname + hashStr;
|
||||
}
|
||||
|
||||
interface PlatformProperties {
|
||||
appVersion: string;
|
||||
appPlatform: string;
|
||||
}
|
||||
|
||||
export class PosthogAnalytics {
|
||||
/* Wrapper for Posthog analytics.
|
||||
* 3 modes of anonymity are supported, governed by this.anonymity
|
||||
* - Anonymity.Disabled means *no data* is passed to posthog
|
||||
* - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog
|
||||
* - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed
|
||||
* to Posthog
|
||||
*
|
||||
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
|
||||
*
|
||||
* To pass an event to Posthog:
|
||||
*
|
||||
* 1. Declare a type for the event, extending IAnonymousEvent, IPseudonymousEvent or IRoomEvent.
|
||||
* 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is
|
||||
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
|
||||
*/
|
||||
|
||||
private anonymity = Anonymity.Disabled;
|
||||
// set true during the constructor if posthog config is present, otherwise false
|
||||
private enabled = false;
|
||||
private static _instance = null;
|
||||
private platformSuperProperties = {};
|
||||
|
||||
public static get instance(): PosthogAnalytics {
|
||||
if (!this._instance) {
|
||||
this._instance = new PosthogAnalytics(posthog);
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
constructor(private readonly posthog: PostHog) {
|
||||
const posthogConfig = SdkConfig.get()["posthog"];
|
||||
if (posthogConfig) {
|
||||
this.posthog.init(posthogConfig.projectApiKey, {
|
||||
api_host: posthogConfig.apiHost,
|
||||
autocapture: false,
|
||||
mask_all_text: true,
|
||||
mask_all_element_attributes: true,
|
||||
// This only triggers on page load, which for our SPA isn't particularly useful.
|
||||
// Plus, the .capture call originating from somewhere in posthog makes it hard
|
||||
// to redact URLs, which requires async code.
|
||||
//
|
||||
// To raise this manually, just call .capture("$pageview") or posthog.capture_pageview.
|
||||
capture_pageview: false,
|
||||
sanitize_properties: this.sanitizeProperties,
|
||||
respect_dnt: true,
|
||||
});
|
||||
this.enabled = true;
|
||||
} else {
|
||||
this.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeProperties = (properties: posthog.Properties): posthog.Properties => {
|
||||
// Callback from posthog to sanitize properties before sending them to the server.
|
||||
//
|
||||
// Here we sanitize posthog's built in properties which leak PII e.g. url reporting.
|
||||
// See utils.js _.info.properties in posthog-js.
|
||||
|
||||
// Replace the $current_url with a redacted version.
|
||||
// $redacted_current_url is injected by this class earlier in capture(), as its generation
|
||||
// is async and can't be done in this non-async callback.
|
||||
if (!properties['$redacted_current_url']) {
|
||||
console.log("$redacted_current_url not set in sanitizeProperties, will drop $current_url entirely");
|
||||
}
|
||||
properties['$current_url'] = properties['$redacted_current_url'];
|
||||
delete properties['$redacted_current_url'];
|
||||
|
||||
if (this.anonymity == Anonymity.Anonymous) {
|
||||
// drop referrer information for anonymous users
|
||||
properties['$referrer'] = null;
|
||||
properties['$referring_domain'] = null;
|
||||
properties['$initial_referrer'] = null;
|
||||
properties['$initial_referring_domain'] = null;
|
||||
|
||||
// drop device ID, which is a UUID persisted in local storage
|
||||
properties['$device_id'] = null;
|
||||
}
|
||||
|
||||
return properties;
|
||||
};
|
||||
|
||||
private static getAnonymityFromSettings(): Anonymity {
|
||||
// determine the current anonymity level based on current user settings
|
||||
|
||||
// "Send anonymous usage data which helps us improve Element. This will use a cookie."
|
||||
const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true);
|
||||
|
||||
// (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie."
|
||||
//
|
||||
// TODO: Currently, this is only a labs flag, for testing purposes.
|
||||
const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymous_analytics_opt_in", null, true);
|
||||
|
||||
let anonymity;
|
||||
if (pseudonumousOptIn) {
|
||||
anonymity = Anonymity.Pseudonymous;
|
||||
} else if (analyticsOptIn) {
|
||||
anonymity = Anonymity.Anonymous;
|
||||
} else {
|
||||
anonymity = Anonymity.Disabled;
|
||||
}
|
||||
|
||||
return anonymity;
|
||||
}
|
||||
|
||||
private registerSuperProperties(properties: posthog.Properties) {
|
||||
if (this.enabled) {
|
||||
this.posthog.register(properties);
|
||||
}
|
||||
}
|
||||
|
||||
private static async getPlatformProperties(): Promise<PlatformProperties> {
|
||||
const platform = PlatformPeg.get();
|
||||
let appVersion;
|
||||
try {
|
||||
appVersion = await platform.getAppVersion();
|
||||
} catch (e) {
|
||||
// this happens if no version is set i.e. in dev
|
||||
appVersion = "unknown";
|
||||
}
|
||||
|
||||
return {
|
||||
appVersion,
|
||||
appPlatform: platform.getHumanReadableName(),
|
||||
};
|
||||
}
|
||||
|
||||
private async capture(eventName: string, properties: posthog.Properties) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
const { origin, hash, pathname } = window.location;
|
||||
properties['$redacted_current_url'] = await getRedactedCurrentLocation(
|
||||
origin, hash, pathname, this.anonymity);
|
||||
this.posthog.capture(eventName, properties);
|
||||
}
|
||||
|
||||
public isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
public setAnonymity(anonymity: Anonymity): void {
|
||||
// Update this.anonymity.
|
||||
// This is public for testing purposes, typically you want to call updateAnonymityFromSettings
|
||||
// to ensure this value is in step with the user's settings.
|
||||
if (this.enabled && (anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)) {
|
||||
// when transitioning to Disabled or Anonymous ensure we clear out any prior state
|
||||
// set in posthog e.g. distinct ID
|
||||
this.posthog.reset();
|
||||
// Restore any previously set platform super properties
|
||||
this.registerSuperProperties(this.platformSuperProperties);
|
||||
}
|
||||
this.anonymity = anonymity;
|
||||
}
|
||||
|
||||
public async identifyUser(userId: string): Promise<void> {
|
||||
if (this.anonymity == Anonymity.Pseudonymous) {
|
||||
this.posthog.identify(await hashHex(userId));
|
||||
}
|
||||
}
|
||||
|
||||
public getAnonymity(): Anonymity {
|
||||
return this.anonymity;
|
||||
}
|
||||
|
||||
public logout(): void {
|
||||
if (this.enabled) {
|
||||
this.posthog.reset();
|
||||
}
|
||||
this.setAnonymity(Anonymity.Anonymous);
|
||||
}
|
||||
|
||||
public async trackPseudonymousEvent<E extends IPseudonymousEvent>(
|
||||
eventName: E["eventName"],
|
||||
properties: E["properties"] = {},
|
||||
) {
|
||||
if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return;
|
||||
await this.capture(eventName, properties);
|
||||
}
|
||||
|
||||
public async trackAnonymousEvent<E extends IAnonymousEvent>(
|
||||
eventName: E["eventName"],
|
||||
properties: E["properties"] = {},
|
||||
): Promise<void> {
|
||||
if (this.anonymity == Anonymity.Disabled) return;
|
||||
await this.capture(eventName, properties);
|
||||
}
|
||||
|
||||
public async trackRoomEvent<E extends IRoomEvent>(
|
||||
eventName: E["eventName"],
|
||||
roomId: string,
|
||||
properties: Omit<E["properties"], "roomId">,
|
||||
): Promise<void> {
|
||||
const updatedProperties = {
|
||||
...properties,
|
||||
hashedRoomId: roomId ? await hashHex(roomId) : null,
|
||||
};
|
||||
await this.trackPseudonymousEvent(eventName, updatedProperties);
|
||||
}
|
||||
|
||||
public async trackPageView(durationMs: number): Promise<void> {
|
||||
const hash = window.location.hash;
|
||||
|
||||
let screen = null;
|
||||
const split = hash.split("/");
|
||||
if (split.length >= 2) {
|
||||
screen = split[1];
|
||||
}
|
||||
|
||||
await this.trackAnonymousEvent<IPageView>("$pageview", {
|
||||
durationMs,
|
||||
screen,
|
||||
});
|
||||
}
|
||||
|
||||
public async updatePlatformSuperProperties(): Promise<void> {
|
||||
// Update super properties in posthog with our platform (app version, platform).
|
||||
// These properties will be subsequently passed in every event.
|
||||
//
|
||||
// This only needs to be done once per page lifetime. Note that getPlatformProperties
|
||||
// is async and can involve a network request if we are running in a browser.
|
||||
this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties();
|
||||
this.registerSuperProperties(this.platformSuperProperties);
|
||||
}
|
||||
|
||||
public async updateAnonymityFromSettings(userId?: string): Promise<void> {
|
||||
// Update this.anonymity based on the user's analytics opt-in settings
|
||||
// Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous
|
||||
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
|
||||
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
|
||||
await this.identifyUser(userId);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,11 +25,44 @@ import { Action } from './dispatcher/actions';
|
|||
import defaultDispatcher from './dispatcher/dispatcher';
|
||||
import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
|
||||
// These functions are frequently used just to check whether an event has
|
||||
// any text to display at all. For this reason they return deferred values
|
||||
// to avoid the expense of looking up translations when they're not needed.
|
||||
|
||||
function textForCallInviteEvent(event: MatrixEvent): () => string | null {
|
||||
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
|
||||
// FIXME: Find a better way to determine this from the event?
|
||||
let isVoice = true;
|
||||
if (event.getContent().offer && event.getContent().offer.sdp &&
|
||||
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
|
||||
isVoice = false;
|
||||
}
|
||||
const isSupported = MatrixClientPeg.get().supportsVoip();
|
||||
|
||||
// This ladder could be reduced down to a couple string variables, however other languages
|
||||
// can have a hard time translating those strings. In an effort to make translations easier
|
||||
// and more accurate, we break out the string-based variables to a couple booleans.
|
||||
if (isVoice && isSupported) {
|
||||
return () => _t("%(senderName)s placed a voice call.", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
} else if (isVoice && !isSupported) {
|
||||
return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
} else if (!isVoice && isSupported) {
|
||||
return () => _t("%(senderName)s placed a video call.", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
} else if (!isVoice && !isSupported) {
|
||||
return () => _t("%(senderName)s placed a video call. (not supported by this browser)", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null {
|
||||
// XXX: SYJS-16 "sender is sometimes null for join messages"
|
||||
const senderName = ev.sender ? ev.sender.name : ev.getSender();
|
||||
|
@ -567,6 +600,7 @@ interface IHandlers {
|
|||
|
||||
const handlers: IHandlers = {
|
||||
'm.room.message': textForMessageEvent,
|
||||
'm.call.invite': textForCallInviteEvent,
|
||||
};
|
||||
|
||||
const stateHandlers: IHandlers = {
|
||||
|
|
|
@ -163,7 +163,7 @@ const shortcuts: Record<Categories, IShortcut[]> = {
|
|||
modifiers: [Modifiers.SHIFT],
|
||||
key: Key.PAGE_UP,
|
||||
}],
|
||||
description: _td("Jump to oldest unread message"),
|
||||
description: _td("Jump to oldest unread message"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL, Modifiers.SHIFT],
|
||||
|
|
|
@ -38,17 +38,9 @@ function makePlaybackWaveform(input: number[]): number[] {
|
|||
// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
|
||||
const noiseWaveform = input.map(v => Math.abs(v));
|
||||
|
||||
// Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
|
||||
// We also rescale the waveform to be 0-1 for the remaining function logic.
|
||||
const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
|
||||
|
||||
// Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled
|
||||
// waveform. Most speech happens below the 0.5 mark.
|
||||
const filtered = resampled.map(v => clamp(v, 0.1, 0.5));
|
||||
|
||||
// Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something
|
||||
// sensible. This is what we return to keep our contract of "values between zero and one".
|
||||
return arrayRescale(filtered, 0, 1);
|
||||
// Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
|
||||
// We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon.
|
||||
return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
|
||||
}
|
||||
|
||||
export class Playback extends EventEmitter implements IDestroyable {
|
||||
|
|
|
@ -30,6 +30,7 @@ import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
|
|||
import { uploadFile } from "../ContentMessages";
|
||||
import { FixedRollingArray } from "../utils/FixedRollingArray";
|
||||
import { clamp } from "../utils/numbers";
|
||||
import mxRecorderWorkletPath from "./RecorderWorklet";
|
||||
|
||||
const CHANNELS = 1; // stereo isn't important
|
||||
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
|
||||
|
@ -113,16 +114,10 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
});
|
||||
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
|
||||
|
||||
// Set up our worklet. We use this for timing information and waveform analysis: the
|
||||
// web audio API prefers this be done async to avoid holding the main thread with math.
|
||||
const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript;
|
||||
if (!mxRecorderWorkletPath) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Unable to create recorder: no worklet script registered");
|
||||
}
|
||||
|
||||
// Connect our inputs and outputs
|
||||
if (this.recorderContext.audioWorklet) {
|
||||
// Set up our worklet. We use this for timing information and waveform analysis: the
|
||||
// web audio API prefers this be done async to avoid holding the main thread with math.
|
||||
await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
|
||||
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
|
||||
this.recorderSource.connect(this.recorderWorklet);
|
||||
|
|
|
@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { CSSProperties, RefObject, useRef, useState } from "react";
|
||||
import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
|
||||
|
@ -80,6 +80,10 @@ export interface IProps extends IPosition {
|
|||
managed?: boolean;
|
||||
wrapperClassName?: string;
|
||||
|
||||
// If true, this context menu will be mounted as a child to the parent container. Otherwise
|
||||
// it will be mounted to a container at the root of the DOM.
|
||||
mountAsChild?: boolean;
|
||||
|
||||
// Function to be called on menu close
|
||||
onFinished();
|
||||
// on resize callback
|
||||
|
@ -390,7 +394,13 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
render(): React.ReactChild {
|
||||
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
|
||||
if (this.props.mountAsChild) {
|
||||
// Render as a child of the current parent
|
||||
return this.renderMenu();
|
||||
} else {
|
||||
// Render as a child of a container at the root of the DOM
|
||||
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -461,10 +471,14 @@ type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val:
|
|||
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
|
||||
const button = useRef<T>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const open = () => {
|
||||
const open = (ev?: SyntheticEvent) => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setIsOpen(true);
|
||||
};
|
||||
const close = () => {
|
||||
const close = (ev?: SyntheticEvent) => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
|
|
|
@ -107,6 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore";
|
|||
import SoftLogout from './auth/SoftLogout';
|
||||
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
|
||||
import { copyPlaintext } from "../../utils/strings";
|
||||
import { PosthogAnalytics } from '../../PosthogAnalytics';
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
|
@ -387,6 +388,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (SettingsStore.getValue("analyticsOptIn")) {
|
||||
Analytics.enable();
|
||||
}
|
||||
|
||||
PosthogAnalytics.instance.updateAnonymityFromSettings();
|
||||
PosthogAnalytics.instance.updatePlatformSuperProperties();
|
||||
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||
}
|
||||
|
||||
|
@ -443,6 +448,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const durationMs = this.stopPageChangeTimer();
|
||||
Analytics.trackPageChange(durationMs);
|
||||
CountlyAnalytics.instance.trackPageChange(durationMs);
|
||||
PosthogAnalytics.instance.trackPageView(durationMs);
|
||||
}
|
||||
if (this.focusComposer) {
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
|
||||
import React, { ReactNode, useMemo, useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
|
||||
import classNames from "classnames";
|
||||
|
@ -44,11 +43,13 @@ import { getChildOrder } from "../../stores/SpaceStore";
|
|||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { linkifyElement } from "../../HtmlUtils";
|
||||
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
||||
interface IHierarchyProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
refreshToken?: any;
|
||||
additionalButtons?: ReactNode;
|
||||
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
|
||||
}
|
||||
|
@ -315,18 +316,25 @@ export const HierarchyLevel = ({
|
|||
</React.Fragment>;
|
||||
};
|
||||
|
||||
// mutate argument refreshToken to force a reload
|
||||
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
|
||||
export const useSpaceSummary = (space: Room): [
|
||||
null,
|
||||
ISpaceSummaryRoom[],
|
||||
Map<string, Map<string, ISpaceSummaryEvent>>?,
|
||||
Map<string, Set<string>>?,
|
||||
Map<string, Set<string>>?,
|
||||
] | [Error] => {
|
||||
// crude temporary refresh token approach until we have pagination and rework the data flow here
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
useDispatcher(defaultDispatcher, (payload => {
|
||||
if (payload.action === Action.UpdateSpaceHierarchy) {
|
||||
setRefreshToken(t => t + 1);
|
||||
}
|
||||
}));
|
||||
|
||||
// TODO pagination
|
||||
return useAsyncMemo(async () => {
|
||||
try {
|
||||
const data = await cli.getSpaceSummary(space.roomId);
|
||||
const data = await space.client.getSpaceSummary(space.roomId);
|
||||
|
||||
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
|
||||
const childParentRelations = new EnhancedMap<string, Set<string>>();
|
||||
|
@ -354,7 +362,6 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
space,
|
||||
initialText = "",
|
||||
showRoom,
|
||||
refreshToken,
|
||||
additionalButtons,
|
||||
children,
|
||||
}) => {
|
||||
|
@ -364,7 +371,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
|
||||
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
|
||||
|
||||
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
|
||||
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(space);
|
||||
|
||||
const roomsMap = useMemo(() => {
|
||||
if (!rooms) return null;
|
||||
|
|
|
@ -47,13 +47,23 @@ import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
|||
import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import { useStateArray } from "../../hooks/useStateArray";
|
||||
import SpacePublicShare from "../views/spaces/SpacePublicShare";
|
||||
import { shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, showSpaceSettings } from "../../utils/space";
|
||||
import {
|
||||
shouldShowSpaceSettings,
|
||||
showAddExistingRooms,
|
||||
showCreateNewRoom,
|
||||
showCreateNewSubspace,
|
||||
showSpaceSettings,
|
||||
} from "../../utils/space";
|
||||
import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory";
|
||||
import MemberAvatar from "../views/avatars/MemberAvatar";
|
||||
import { useStateToggle } from "../../hooks/useStateToggle";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import FacePile from "../views/elements/FacePile";
|
||||
import { AddExistingToSpace } from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import {
|
||||
AddExistingToSpace,
|
||||
defaultDmsRenderer,
|
||||
defaultRoomsRenderer,
|
||||
defaultSpacesRenderer,
|
||||
} from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
|
@ -62,10 +72,8 @@ import IconizedContextMenu, {
|
|||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { BetaPill } from "../views/beta/BetaCard";
|
||||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||
import Modal from "../../Modal";
|
||||
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
|
||||
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
@ -92,28 +100,6 @@ enum Phase {
|
|||
PrivateExistingRooms,
|
||||
}
|
||||
|
||||
// XXX: Temporary for the Spaces Beta only
|
||||
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
|
||||
if (!SdkConfig.get().bug_report_endpoint_url) return null;
|
||||
|
||||
return <div className="mx_SpaceFeedbackPrompt">
|
||||
<hr />
|
||||
<div>
|
||||
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
if (onClick) onClick();
|
||||
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
|
||||
featureId: "feature_spaces",
|
||||
});
|
||||
}}>
|
||||
{ _t("Feedback") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const RoomMemberCount = ({ room, children }) => {
|
||||
const members = useRoomMembers(room);
|
||||
const count = members.length;
|
||||
|
@ -206,11 +192,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
|
||||
if (inviteSender) {
|
||||
inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
|
||||
<MemberAvatar member={inviter} width={32} height={32} />
|
||||
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
|
||||
<div>
|
||||
<div className="mx_SpaceRoomView_preview_inviter_name">
|
||||
{ _t("<inviter/> invites you", {}, {
|
||||
inviter: () => <b>{ inviter.name || inviteSender }</b>,
|
||||
inviter: () => <b>{ inviter?.name || inviteSender }</b>,
|
||||
}) }
|
||||
</div>
|
||||
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
|
||||
|
@ -307,7 +293,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
</div>;
|
||||
};
|
||||
|
||||
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
||||
const SpaceLandingAddButton = ({ space }) => {
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
let contextMenu;
|
||||
|
@ -331,24 +317,32 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
|||
closeMenu();
|
||||
|
||||
if (await showCreateNewRoom(space)) {
|
||||
onNewRoomAdded();
|
||||
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add existing room")}
|
||||
iconClassName="mx_RoomList_iconHash"
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
const [added] = await showAddExistingRooms(space);
|
||||
if (added) {
|
||||
onNewRoomAdded();
|
||||
}
|
||||
showAddExistingRooms(space);
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add space")}
|
||||
iconClassName="mx_RoomList_iconPlus"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
showCreateNewSubspace(space);
|
||||
}}
|
||||
>
|
||||
<BetaPill />
|
||||
</IconizedContextMenuOption>
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
}
|
||||
|
@ -389,11 +383,9 @@ const SpaceLanding = ({ space }) => {
|
|||
|
||||
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
||||
const [refreshToken, forceUpdate] = useStateToggle(false);
|
||||
|
||||
let addRoomButton;
|
||||
if (canAddRooms) {
|
||||
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
|
||||
addRoomButton = <SpaceLandingAddButton space={space} />;
|
||||
}
|
||||
|
||||
let settingsButton;
|
||||
|
@ -416,6 +408,7 @@ const SpaceLanding = ({ space }) => {
|
|||
};
|
||||
|
||||
return <div className="mx_SpaceRoomView_landing">
|
||||
<SpaceFeedbackPrompt />
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<div className="mx_SpaceRoomView_landing_name">
|
||||
<RoomName room={space}>
|
||||
|
@ -440,15 +433,8 @@ const SpaceLanding = ({ space }) => {
|
|||
</div>
|
||||
) }
|
||||
</RoomTopic>
|
||||
<SpaceFeedbackPrompt />
|
||||
<hr />
|
||||
|
||||
<SpaceHierarchy
|
||||
space={space}
|
||||
showRoom={showRoom}
|
||||
refreshToken={refreshToken}
|
||||
additionalButtons={addRoomButton}
|
||||
/>
|
||||
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -531,7 +517,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -550,11 +535,12 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
|
|||
{ _t("Skip for now") }
|
||||
</AccessibleButton>
|
||||
}
|
||||
filterPlaceholder={_t("Search for rooms or spaces")}
|
||||
onFinished={onFinished}
|
||||
roomsRenderer={defaultRoomsRenderer}
|
||||
spacesRenderer={defaultSpacesRenderer}
|
||||
dmsRenderer={defaultDmsRenderer}
|
||||
/>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons" />
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -574,7 +560,6 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRoom
|
|||
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -603,9 +588,8 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
|
|||
</AccessibleButton>
|
||||
<div className="mx_SpaceRoomView_betaWarning">
|
||||
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
|
||||
<p>{ _t("We're working on this as part of the beta, but just want to let you know.") }</p>
|
||||
<p>{ _t("We're working on this, but just want to let you know.") }</p>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -728,7 +712,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
|
|
@ -58,28 +58,39 @@ export default class ToastContainer extends React.Component<{}, IState> {
|
|||
let containerClasses;
|
||||
if (totalCount !== 0) {
|
||||
const topToast = this.state.toasts[0];
|
||||
const { title, icon, key, component, className, props } = topToast;
|
||||
const toastClasses = classNames("mx_Toast_toast", {
|
||||
const { title, icon, key, component, className, bodyClassName, props } = topToast;
|
||||
const bodyClasses = classNames("mx_Toast_body", bodyClassName);
|
||||
const toastClasses = classNames("mx_Toast_toast", className, {
|
||||
"mx_Toast_hasIcon": icon,
|
||||
[`mx_Toast_icon_${icon}`]: icon,
|
||||
}, className);
|
||||
|
||||
let countIndicator;
|
||||
if (isStacked || this.state.countSeen > 0) {
|
||||
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
|
||||
}
|
||||
|
||||
});
|
||||
const toastProps = Object.assign({}, props, {
|
||||
key,
|
||||
toastKey: key,
|
||||
});
|
||||
toast = (<div className={toastClasses}>
|
||||
<div className="mx_Toast_title">
|
||||
<h2>{ title }</h2>
|
||||
<span>{ countIndicator }</span>
|
||||
const content = React.createElement(component, toastProps);
|
||||
|
||||
let countIndicator;
|
||||
if (title && isStacked || this.state.countSeen > 0) {
|
||||
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
|
||||
}
|
||||
|
||||
let titleElement;
|
||||
if (title) {
|
||||
titleElement = (
|
||||
<div className="mx_Toast_title">
|
||||
<h2>{ title }</h2>
|
||||
<span>{ countIndicator }</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
toast = (
|
||||
<div className={toastClasses}>
|
||||
{ titleElement }
|
||||
<div className={bodyClasses}>{ content }</div>
|
||||
</div>
|
||||
<div className="mx_Toast_body">{ React.createElement(component, toastProps) }</div>
|
||||
</div>);
|
||||
);
|
||||
|
||||
containerClasses = classNames("mx_ToastContainer", {
|
||||
"mx_ToastContainer_stacked": isStacked,
|
||||
|
|
|
@ -17,8 +17,7 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { arrayFastResample } from "../../../utils/arrays";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
|
||||
import Waveform from "./Waveform";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
||||
|
@ -48,18 +47,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
|
|||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
waveform: [],
|
||||
waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
|
||||
const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
|
||||
// The incoming data is between zero and one, but typically even screaming into a
|
||||
// microphone won't send you over 0.6, so we artificially adjust the gain for the
|
||||
// waveform. This results in a slightly more cinematic/animated waveform for the
|
||||
// user.
|
||||
this.waveform = bars.map(b => percentageOf(b, 0, 0.50));
|
||||
// The incoming data is between zero and one, so we don't need to clamp/rescale it.
|
||||
this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
|
||||
this.scheduledUpdate.mark();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -103,8 +103,8 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
|
|||
}
|
||||
|
||||
private resetRecaptcha() {
|
||||
if (this.captchaWidgetId !== null) {
|
||||
global.grecaptcha.reset(this.captchaWidgetId);
|
||||
if (this.captchaWidgetId) {
|
||||
global?.grecaptcha?.reset(this.captchaWidgetId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,8 @@ import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
|
|||
import SdkConfig from "../../../SdkConfig";
|
||||
import SettingsFlag from "../elements/SettingsFlag";
|
||||
|
||||
// XXX: Keep this around for re-use in future Betas
|
||||
|
||||
interface IProps {
|
||||
title?: string;
|
||||
featureId: string;
|
||||
|
|
|
@ -49,6 +49,13 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
|||
this.props.onFinished();
|
||||
};
|
||||
|
||||
onKeyDown = (ev) => {
|
||||
// Prevent Backspace and Delete keys from functioning in the entry field
|
||||
if (ev.code === "Backspace" || ev.code === "Delete") {
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
onChange = (ev) => {
|
||||
this.setState({ value: ev.target.value });
|
||||
};
|
||||
|
@ -64,6 +71,7 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
|||
className="mx_DialPadContextMenu_dialled"
|
||||
value={this.state.value}
|
||||
autoFocus={true}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -86,14 +86,18 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
|
|||
>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
|
||||
<span className="mx_IconizedContextMenu_label">{ label }</span>
|
||||
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> }
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", {
|
||||
mx_IconizedContextMenu_checked: active,
|
||||
mx_IconizedContextMenu_unchecked: !active,
|
||||
})} />
|
||||
</MenuItemCheckbox>;
|
||||
};
|
||||
|
||||
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, ...props }) => {
|
||||
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, children, ...props }) => {
|
||||
return <MenuItem {...props} label={label}>
|
||||
{ iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> }
|
||||
<span className="mx_IconizedContextMenu_label">{ label }</span>
|
||||
{ children }
|
||||
</MenuItem>;
|
||||
};
|
||||
|
||||
|
|
216
src/components/views/context_menus/SpaceContextMenu.tsx
Normal file
216
src/components/views/context_menus/SpaceContextMenu.tsx
Normal file
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
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 React, { useContext } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import {
|
||||
IProps as IContextMenuProps,
|
||||
} from "../../structures/ContextMenu";
|
||||
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import {
|
||||
leaveSpace,
|
||||
shouldShowSpaceSettings,
|
||||
showAddExistingRooms,
|
||||
showCreateNewRoom,
|
||||
showCreateNewSubspace,
|
||||
showSpaceInvite,
|
||||
showSpaceSettings,
|
||||
} from "../../../utils/space";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
|
||||
import { BetaPill } from "../beta/BetaCard";
|
||||
|
||||
interface IProps extends IContextMenuProps {
|
||||
space: Room;
|
||||
}
|
||||
|
||||
const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const userId = cli.getUserId();
|
||||
|
||||
let inviteOption;
|
||||
if (space.getJoinRule() === "public" || space.canInvite(userId)) {
|
||||
const onInviteClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showSpaceInvite(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
inviteOption = (
|
||||
<IconizedContextMenuOption
|
||||
className="mx_SpacePanel_contextMenu_inviteButton"
|
||||
iconClassName="mx_SpacePanel_iconInvite"
|
||||
label={_t("Invite people")}
|
||||
onClick={onInviteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let settingsOption;
|
||||
let leaveSection;
|
||||
if (shouldShowSpaceSettings(space)) {
|
||||
const onSettingsClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showSpaceSettings(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
settingsOption = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconSettings"
|
||||
label={_t("Settings")}
|
||||
onClick={onSettingsClick}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const onLeaveClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
leaveSpace(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
leaveSection = <IconizedContextMenuOptionList red first>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconLeave"
|
||||
label={_t("Leave space")}
|
||||
onClick={onLeaveClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>;
|
||||
}
|
||||
|
||||
const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
||||
let newRoomSection;
|
||||
if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
|
||||
const onNewRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showCreateNewRoom(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
const onAddExistingRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showAddExistingRooms(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
const onNewSubspaceClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showCreateNewSubspace(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
newRoomSection = <IconizedContextMenuOptionList first>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconPlus"
|
||||
label={_t("Create new room")}
|
||||
onClick={onNewRoomClick}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconHash"
|
||||
label={_t("Add existing room")}
|
||||
onClick={onAddExistingRoomClick}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconPlus"
|
||||
label={_t("Add space")}
|
||||
onClick={onNewSubspaceClick}
|
||||
>
|
||||
<BetaPill />
|
||||
</IconizedContextMenuOption>
|
||||
</IconizedContextMenuOptionList>;
|
||||
}
|
||||
|
||||
const onMembersClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (!RoomViewStore.getRoomId()) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: space.roomId,
|
||||
}, true);
|
||||
}
|
||||
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.SpaceMemberList,
|
||||
refireParams: { space: space },
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
const onExploreRoomsClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: space.roomId,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
return <IconizedContextMenu
|
||||
{...props}
|
||||
onFinished={onFinished}
|
||||
className="mx_SpacePanel_contextMenu"
|
||||
compact
|
||||
>
|
||||
<div className="mx_SpacePanel_contextMenu_header">
|
||||
{ space.name }
|
||||
</div>
|
||||
<IconizedContextMenuOptionList first>
|
||||
{ inviteOption }
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconMembers"
|
||||
label={_t("Members")}
|
||||
onClick={onMembersClick}
|
||||
/>
|
||||
{ settingsOption }
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconExplore"
|
||||
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
|
||||
onClick={onExploreRoomsClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
{ newRoomSection }
|
||||
{ leaveSection }
|
||||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
export default SpaceContextMenu;
|
||||
|
67
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal file
67
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
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 React, { useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { AddExistingToSpace, defaultSpacesRenderer, SubspaceSelector } from "./AddExistingToSpaceDialog";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
onCreateSubspaceClick(): void;
|
||||
onFinished(added?: boolean): void;
|
||||
}
|
||||
|
||||
const AddExistingSubspaceDialog: React.FC<IProps> = ({ space, onCreateSubspaceClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
|
||||
return <BaseDialog
|
||||
title={(
|
||||
<SubspaceSelector
|
||||
title={_t("Add existing space")}
|
||||
space={space}
|
||||
value={selectedSpace}
|
||||
onChange={setSelectedSpace}
|
||||
/>
|
||||
)}
|
||||
className="mx_AddExistingToSpaceDialog"
|
||||
contentId="mx_AddExistingToSpace"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<MatrixClientContext.Provider value={space.client}>
|
||||
<AddExistingToSpace
|
||||
space={space}
|
||||
onFinished={onFinished}
|
||||
footerPrompt={<>
|
||||
<div>{ _t("Want to add a new space instead?") }</div>
|
||||
<AccessibleButton onClick={onCreateSubspaceClick} kind="link">
|
||||
{ _t("Create a new space") }
|
||||
</AccessibleButton>
|
||||
</>}
|
||||
filterPlaceholder={_t("Search for spaces")}
|
||||
spacesRenderer={defaultSpacesRenderer}
|
||||
/>
|
||||
</MatrixClientContext.Provider>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default AddExistingSubspaceDialog;
|
||||
|
|
@ -18,9 +18,9 @@ import React, { ReactNode, useContext, useMemo, useState } from "react";
|
|||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import SearchBox from "../../structures/SearchBox";
|
||||
|
@ -35,19 +35,20 @@ import StyledCheckbox from "../elements/StyledCheckbox";
|
|||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
|
||||
import ProgressBar from "../elements/ProgressBar";
|
||||
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import QueryMatcher from "../../../autocomplete/QueryMatcher";
|
||||
import TruncatedList from "../elements/TruncatedList";
|
||||
import EntityTile from "../rooms/EntityTile";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
interface IProps {
|
||||
space: Room;
|
||||
onCreateRoomClick(space: Room): void;
|
||||
onCreateRoomClick(): void;
|
||||
onAddSubspaceClick(): void;
|
||||
onFinished(added?: boolean): void;
|
||||
}
|
||||
|
||||
const Entry = ({ room, checked, onChange }) => {
|
||||
export const Entry = ({ room, checked, onChange }) => {
|
||||
return <label className="mx_AddExistingToSpace_entry">
|
||||
{ room?.isSpaceRoom()
|
||||
? <RoomAvatar room={room} height={32} width={32} />
|
||||
|
@ -65,14 +66,36 @@ const Entry = ({ room, checked, onChange }) => {
|
|||
interface IAddExistingToSpaceProps {
|
||||
space: Room;
|
||||
footerPrompt?: ReactNode;
|
||||
filterPlaceholder: string;
|
||||
emptySelectionButton?: ReactNode;
|
||||
onFinished(added: boolean): void;
|
||||
roomsRenderer?(
|
||||
rooms: Room[],
|
||||
selectedToAdd: Set<Room>,
|
||||
onChange: undefined | ((checked: boolean, room: Room) => void),
|
||||
truncateAt: number,
|
||||
overflowTile: (overflowCount: number, totalCount: number) => JSX.Element,
|
||||
): ReactNode;
|
||||
spacesRenderer?(
|
||||
spaces: Room[],
|
||||
selectedToAdd: Set<Room>,
|
||||
onChange?: (checked: boolean, room: Room) => void,
|
||||
): ReactNode;
|
||||
dmsRenderer?(
|
||||
dms: Room[],
|
||||
selectedToAdd: Set<Room>,
|
||||
onChange?: (checked: boolean, room: Room) => void,
|
||||
): ReactNode;
|
||||
}
|
||||
|
||||
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
||||
space,
|
||||
footerPrompt,
|
||||
emptySelectionButton,
|
||||
filterPlaceholder,
|
||||
roomsRenderer,
|
||||
dmsRenderer,
|
||||
spacesRenderer,
|
||||
onFinished,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
@ -196,7 +219,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
</>;
|
||||
}
|
||||
|
||||
const onChange = !busy && !error ? (checked, room) => {
|
||||
const onChange = !busy && !error ? (checked: boolean, room: Room) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(room);
|
||||
} else {
|
||||
|
@ -206,7 +229,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
} : null;
|
||||
|
||||
const [truncateAt, setTruncateAt] = useState(20);
|
||||
function overflowTile(overflowCount, totalCount) {
|
||||
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
|
||||
const text = _t("and %(count)s others...", { count: overflowCount });
|
||||
return (
|
||||
<EntityTile
|
||||
|
@ -222,73 +245,36 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
let noResults = true;
|
||||
if ((roomsRenderer && rooms.length > 0) ||
|
||||
(dmsRenderer && dms.length > 0) ||
|
||||
(!roomsRenderer && !dmsRenderer && spacesRenderer && spaces.length > 0) // only count spaces when alone
|
||||
) {
|
||||
noResults = false;
|
||||
}
|
||||
|
||||
return <div className="mx_AddExistingToSpace">
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t("Filter your rooms and spaces")}
|
||||
placeholder={filterPlaceholder}
|
||||
onSearch={setQuery}
|
||||
autoComplete={true}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_AddExistingToSpace_content">
|
||||
{ rooms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
<TruncatedList
|
||||
truncateAt={truncateAt}
|
||||
createOverflowElement={overflowTile}
|
||||
getChildren={(start, end) => rooms.slice(start, end).map(room =>
|
||||
<Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>,
|
||||
)}
|
||||
getChildCount={() => rooms.length}
|
||||
/>
|
||||
</div>
|
||||
{ rooms.length > 0 && roomsRenderer ? (
|
||||
roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile)
|
||||
) : undefined }
|
||||
|
||||
{ spaces.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
<div className="mx_AddExistingToSpace_section_experimental">
|
||||
<div>{ _t("Feeling experimental?") }</div>
|
||||
<div>{ _t("You can add existing spaces to a space.") }</div>
|
||||
</div>
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selectedToAdd.has(space)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, space);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
{ spaces.length > 0 && spacesRenderer ? (
|
||||
spacesRenderer(spaces, selectedToAdd, onChange)
|
||||
) : null }
|
||||
|
||||
{ dms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Direct Messages") }</h3>
|
||||
{ dms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
{ dms.length > 0 && dmsRenderer ? (
|
||||
dmsRenderer(dms, selectedToAdd, onChange)
|
||||
) : null }
|
||||
|
||||
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
|
||||
{ noResults ? <span className="mx_AddExistingToSpace_noResults">
|
||||
{ _t("No results") }
|
||||
</span> : undefined }
|
||||
</AutoHideScrollbar>
|
||||
|
@ -299,50 +285,126 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
|
||||
export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = (
|
||||
rooms, selectedToAdd, onChange, truncateAt, overflowTile,
|
||||
) => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
<TruncatedList
|
||||
truncateAt={truncateAt}
|
||||
createOverflowElement={overflowTile}
|
||||
getChildren={(start, end) => rooms.slice(start, end).map(room =>
|
||||
<Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked: boolean) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>,
|
||||
)}
|
||||
getChildCount={() => rooms.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
let spaceOptionSection;
|
||||
if (existingSubspaces.length > 0) {
|
||||
const options = [space, ...existingSubspaces].map((space) => {
|
||||
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
|
||||
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
|
||||
});
|
||||
return <div key={space.roomId} className={classes}>
|
||||
<RoomAvatar room={space} width={24} height={24} />
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
});
|
||||
export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selectedToAdd.has(space)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, space);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
);
|
||||
|
||||
spaceOptionSection = (
|
||||
export const defaultDmsRenderer: IAddExistingToSpaceProps["dmsRenderer"] = (dms, selectedToAdd, onChange) => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Direct Messages") }</h3>
|
||||
{ dms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked: boolean) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ISubspaceSelectorProps {
|
||||
title: string;
|
||||
space: Room;
|
||||
value: Room;
|
||||
onChange(space: Room): void;
|
||||
}
|
||||
|
||||
export const SubspaceSelector = ({ title, space, value, onChange }: ISubspaceSelectorProps) => {
|
||||
const options = useMemo(() => {
|
||||
return [space, ...SpaceStore.instance.getChildSpaces(space.roomId).filter(space => {
|
||||
return space.currentState.maySendStateEvent(EventType.SpaceChild, space.client.credentials.userId);
|
||||
})];
|
||||
}, [space]);
|
||||
|
||||
let body;
|
||||
if (options.length > 1) {
|
||||
body = (
|
||||
<Dropdown
|
||||
id="mx_SpaceSelectDropdown"
|
||||
className="mx_SpaceSelectDropdown"
|
||||
onOptionChange={(key: string) => {
|
||||
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
|
||||
onChange(options.find(space => space.roomId === key) || space);
|
||||
}}
|
||||
value={selectedSpace.roomId}
|
||||
value={value.roomId}
|
||||
label={_t("Space selection")}
|
||||
>
|
||||
{ options }
|
||||
{ options.map((space) => {
|
||||
const classes = classNames({
|
||||
mx_SubspaceSelector_dropdownOptionActive: space === value,
|
||||
});
|
||||
return <div key={space.roomId} className={classes}>
|
||||
<RoomAvatar room={space} width={24} height={24} />
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
}) }
|
||||
</Dropdown>
|
||||
);
|
||||
} else {
|
||||
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
body = (
|
||||
<div className="mx_SubspaceSelector_onlySpace">
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = <React.Fragment>
|
||||
<RoomAvatar room={selectedSpace} height={40} width={40} />
|
||||
return <div className="mx_SubspaceSelector">
|
||||
<RoomAvatar room={value} height={40} width={40} />
|
||||
<div>
|
||||
<h1>{ _t("Add existing rooms") }</h1>
|
||||
{ spaceOptionSection }
|
||||
<h1>{ title }</h1>
|
||||
{ body }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onAddSubspaceClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
|
||||
return <BaseDialog
|
||||
title={title}
|
||||
title={(
|
||||
<SubspaceSelector
|
||||
title={_t("Add existing rooms")}
|
||||
space={space}
|
||||
value={selectedSpace}
|
||||
onChange={setSelectedSpace}
|
||||
/>
|
||||
)}
|
||||
className="mx_AddExistingToSpaceDialog"
|
||||
contentId="mx_AddExistingToSpace"
|
||||
onFinished={onFinished}
|
||||
|
@ -354,14 +416,35 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick,
|
|||
onFinished={onFinished}
|
||||
footerPrompt={<>
|
||||
<div>{ _t("Want to add a new room instead?") }</div>
|
||||
<AccessibleButton onClick={() => onCreateRoomClick(space)} kind="link">
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
onCreateRoomClick();
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Create a new room") }
|
||||
</AccessibleButton>
|
||||
</>}
|
||||
filterPlaceholder={_t("Search for rooms")}
|
||||
roomsRenderer={defaultRoomsRenderer}
|
||||
spacesRenderer={() => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
onAddSubspaceClick();
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Adding spaces has moved.") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
)}
|
||||
dmsRenderer={defaultDmsRenderer}
|
||||
/>
|
||||
</MatrixClientContext.Provider>
|
||||
|
||||
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
|
|
|
@ -14,22 +14,18 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Field from "../elements/Field";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { submitFeedback } from "../../../rageshake/submit-rageshake";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import Modal from "../../../Modal";
|
||||
import InfoDialog from "./InfoDialog";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { UserTab } from "./UserSettingsDialog";
|
||||
import GenericFeatureFeedbackDialog from "./GenericFeatureFeedbackDialog";
|
||||
|
||||
// XXX: Keep this around for re-use in future Betas
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
featureId: string;
|
||||
|
@ -38,77 +34,28 @@ interface IProps extends IDialogProps {
|
|||
const BetaFeedbackDialog: React.FC<IProps> = ({ featureId, onFinished }) => {
|
||||
const info = SettingsStore.getBetaInfo(featureId);
|
||||
|
||||
const [comment, setComment] = useState("");
|
||||
const [canContact, setCanContact] = useState(false);
|
||||
|
||||
const sendFeedback = async (ok: boolean) => {
|
||||
if (!ok) return onFinished(false);
|
||||
|
||||
const extraData = SettingsStore.getBetaInfo(featureId)?.extraSettings.reduce((o, k) => {
|
||||
o[k] = SettingsStore.getValue(k);
|
||||
return o;
|
||||
}, {});
|
||||
|
||||
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact, extraData);
|
||||
onFinished(true);
|
||||
|
||||
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {
|
||||
title: _t("Beta feedback"),
|
||||
description: _t("Thank you for your feedback, we really appreciate it."),
|
||||
button: _t("Done"),
|
||||
hasCloseButton: false,
|
||||
fixedWidth: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (<QuestionDialog
|
||||
className="mx_BetaFeedbackDialog"
|
||||
hasCancelButton={true}
|
||||
return <GenericFeatureFeedbackDialog
|
||||
title={_t("%(featureName)s beta feedback", { featureName: info.title })}
|
||||
description={<React.Fragment>
|
||||
<div className="mx_BetaFeedbackDialog_subheading">
|
||||
{ _t(info.feedbackSubheading) }
|
||||
|
||||
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }
|
||||
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
onFinished(false);
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Labs,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{ _t("To leave the beta, visit your settings.") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
id="feedbackComment"
|
||||
label={_t("Feedback")}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={comment}
|
||||
element="textarea"
|
||||
onChange={(ev) => {
|
||||
setComment(ev.target.value);
|
||||
}}
|
||||
autoFocus={true}
|
||||
/>
|
||||
|
||||
<StyledCheckbox
|
||||
checked={canContact}
|
||||
onClick={e => setCanContact((e.target as HTMLInputElement).checked)}
|
||||
>
|
||||
{ _t("You may contact me if you have any follow up questions") }
|
||||
</StyledCheckbox>
|
||||
</React.Fragment>}
|
||||
button={_t("Send feedback")}
|
||||
buttonDisabled={!comment}
|
||||
onFinished={sendFeedback}
|
||||
/>);
|
||||
subheading={_t(info.feedbackSubheading)}
|
||||
onFinished={onFinished}
|
||||
rageshakeLabel={info.feedbackLabel}
|
||||
rageshakeData={Object.fromEntries((SettingsStore.getBetaInfo(featureId)?.extraSettings || []).map(k => {
|
||||
return SettingsStore.getValue(k);
|
||||
}))}
|
||||
>
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
onFinished(false);
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Labs,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{ _t("To leave the beta, visit your settings.") }
|
||||
</AccessibleButton>
|
||||
</GenericFeatureFeedbackDialog>;
|
||||
};
|
||||
|
||||
export default BetaFeedbackDialog;
|
||||
|
|
|
@ -32,8 +32,8 @@ import RoomAliasField from "../elements/RoomAliasField";
|
|||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import BaseDialog from "../dialogs/BaseDialog";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
|
||||
|
||||
interface IProps {
|
||||
defaultPublic?: boolean;
|
||||
|
@ -325,21 +325,6 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
|||
title = this.state.joinRule === JoinRule.Public ? _t('Create a public room') : _t('Create a private room');
|
||||
}
|
||||
|
||||
const options = [
|
||||
<div key={JoinRule.Invite} className="mx_CreateRoomDialog_dropdown_invite">
|
||||
{ _t("Private room (invite only)") }
|
||||
</div>,
|
||||
<div key={JoinRule.Public} className="mx_CreateRoomDialog_dropdown_public">
|
||||
{ _t("Public room") }
|
||||
</div>,
|
||||
];
|
||||
|
||||
if (this.supportsRestricted) {
|
||||
options.unshift(<div key={JoinRule.Restricted} className="mx_CreateRoomDialog_dropdown_restricted">
|
||||
{ _t("Visible to space members") }
|
||||
</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} title={title}>
|
||||
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
|
||||
|
@ -359,16 +344,14 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
|||
className="mx_CreateRoomDialog_topic"
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
id="mx_CreateRoomDialog_typeDropdown"
|
||||
className="mx_CreateRoomDialog_typeDropdown"
|
||||
onOptionChange={this.onJoinRuleChange}
|
||||
menuWidth={448}
|
||||
value={this.state.joinRule}
|
||||
<JoinRuleDropdown
|
||||
label={_t("Room visibility")}
|
||||
>
|
||||
{ options }
|
||||
</Dropdown>
|
||||
labelInvite={_t("Private room (invite only)")}
|
||||
labelPublic={_t("Public room")}
|
||||
labelRestricted={this.supportsRestricted ? _t("Visible to space members") : undefined}
|
||||
value={this.state.joinRule}
|
||||
onChange={this.onJoinRuleChange}
|
||||
/>
|
||||
|
||||
{ publicPrivateLabel }
|
||||
{ e2eeSection }
|
||||
|
|
210
src/components/views/dialogs/CreateSubspaceDialog.tsx
Normal file
210
src/components/views/dialogs/CreateSubspaceDialog.tsx
Normal file
|
@ -0,0 +1,210 @@
|
|||
/*
|
||||
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 React, { useRef, useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { BetaPill } from "../beta/BetaCard";
|
||||
import Field from "../elements/Field";
|
||||
import RoomAliasField from "../elements/RoomAliasField";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import { SpaceCreateForm } from "../spaces/SpaceCreateMenu";
|
||||
import createRoom from "../../../createRoom";
|
||||
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
|
||||
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
onAddExistingSpaceClick(): void;
|
||||
onFinished(added?: boolean): void;
|
||||
}
|
||||
|
||||
const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick, onFinished }) => {
|
||||
const [parentSpace, setParentSpace] = useState(space);
|
||||
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
const [name, setName] = useState("");
|
||||
const spaceNameField = useRef<Field>();
|
||||
const [alias, setAlias] = useState("");
|
||||
const spaceAliasField = useRef<RoomAliasField>();
|
||||
const [avatar, setAvatar] = useState<File>(null);
|
||||
const [topic, setTopic] = useState<string>("");
|
||||
|
||||
const supportsRestricted = !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred;
|
||||
|
||||
const spaceJoinRule = space.getJoinRule();
|
||||
let defaultJoinRule = JoinRule.Invite;
|
||||
if (spaceJoinRule === JoinRule.Public) {
|
||||
defaultJoinRule = JoinRule.Public;
|
||||
} else if (supportsRestricted) {
|
||||
defaultJoinRule = JoinRule.Restricted;
|
||||
}
|
||||
const [joinRule, setJoinRule] = useState<JoinRule>(defaultJoinRule);
|
||||
|
||||
const onCreateSubspaceClick = async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
|
||||
setBusy(true);
|
||||
// require & validate the space name field
|
||||
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
|
||||
spaceNameField.current.focus();
|
||||
spaceNameField.current.validate({ allowEmpty: false, focused: true });
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
// validate the space name alias field but do not require it
|
||||
if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
|
||||
spaceAliasField.current.focus();
|
||||
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createRoom({
|
||||
createOpts: {
|
||||
preset: joinRule === JoinRule.Public ? Preset.PublicChat : Preset.PrivateChat,
|
||||
name,
|
||||
power_level_content_override: {
|
||||
// Only allow Admins to write to the timeline to prevent hidden sync spam
|
||||
events_default: 100,
|
||||
...joinRule === JoinRule.Public ? { invite: 0 } : {},
|
||||
},
|
||||
room_alias_name: joinRule === JoinRule.Public && alias
|
||||
? alias.substr(1, alias.indexOf(":") - 1)
|
||||
: undefined,
|
||||
topic,
|
||||
},
|
||||
avatar,
|
||||
roomType: RoomType.Space,
|
||||
parentSpace,
|
||||
spinner: false,
|
||||
encryption: false,
|
||||
andView: true,
|
||||
inlineErrors: true,
|
||||
});
|
||||
|
||||
onFinished(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
let joinRuleMicrocopy: JSX.Element;
|
||||
if (joinRule === JoinRule.Restricted) {
|
||||
joinRuleMicrocopy = <p>
|
||||
{ _t(
|
||||
"Anyone in <SpaceName/> will be able to find and join.", {}, {
|
||||
SpaceName: () => <b>{ parentSpace.name }</b>,
|
||||
},
|
||||
) }
|
||||
</p>;
|
||||
} else if (joinRule === JoinRule.Public) {
|
||||
joinRuleMicrocopy = <p>
|
||||
{ _t(
|
||||
"Anyone will be able to find and join this space, not just members of <SpaceName/>.", {}, {
|
||||
SpaceName: () => <b>{ parentSpace.name }</b>,
|
||||
},
|
||||
) }
|
||||
</p>;
|
||||
} else if (joinRule === JoinRule.Invite) {
|
||||
joinRuleMicrocopy = <p>
|
||||
{ _t("Only people invited will be able to find and join this space.") }
|
||||
</p>;
|
||||
}
|
||||
|
||||
return <BaseDialog
|
||||
title={(
|
||||
<SubspaceSelector
|
||||
title={_t("Create a space")}
|
||||
space={space}
|
||||
value={parentSpace}
|
||||
onChange={setParentSpace}
|
||||
/>
|
||||
)}
|
||||
className="mx_CreateSubspaceDialog"
|
||||
contentId="mx_CreateSubspaceDialog"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<MatrixClientContext.Provider value={space.client}>
|
||||
<div className="mx_CreateSubspaceDialog_content">
|
||||
<div className="mx_CreateSubspaceDialog_betaNotice">
|
||||
<BetaPill />
|
||||
{ _t("Add a space to a space you manage.") }
|
||||
</div>
|
||||
|
||||
<SpaceCreateForm
|
||||
busy={busy}
|
||||
onSubmit={onCreateSubspaceClick}
|
||||
setAvatar={setAvatar}
|
||||
name={name}
|
||||
setName={setName}
|
||||
nameFieldRef={spaceNameField}
|
||||
topic={topic}
|
||||
setTopic={setTopic}
|
||||
alias={alias}
|
||||
setAlias={setAlias}
|
||||
showAliasField={joinRule === JoinRule.Public}
|
||||
aliasFieldRef={spaceAliasField}
|
||||
>
|
||||
<JoinRuleDropdown
|
||||
label={_t("Space visibility")}
|
||||
labelInvite={_t("Private space (invite only)")}
|
||||
labelPublic={_t("Public space")}
|
||||
labelRestricted={supportsRestricted ? _t("Visible to space members") : undefined}
|
||||
width={478}
|
||||
value={joinRule}
|
||||
onChange={setJoinRule}
|
||||
/>
|
||||
{ joinRuleMicrocopy }
|
||||
</SpaceCreateForm>
|
||||
</div>
|
||||
|
||||
<div className="mx_CreateSubspaceDialog_footer">
|
||||
<div className="mx_CreateSubspaceDialog_footer_prompt">
|
||||
<div>{ _t("Want to add an existing space instead?") }</div>
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
onAddExistingSpaceClick();
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Add existing space") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
|
||||
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished(false)}>
|
||||
{ _t("Cancel") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSubspaceClick}>
|
||||
{ busy ? _t("Adding...") : _t("Add") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</MatrixClientContext.Provider>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default CreateSubspaceDialog;
|
||||
|
|
@ -490,9 +490,9 @@ class RoomStateExplorer extends React.PureComponent<IExplorerProps, IRoomStateEx
|
|||
forceStateEvent={true}
|
||||
onBack={this.onBack}
|
||||
inputs={{
|
||||
eventType: this.state.event.getType(),
|
||||
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
|
||||
stateKey: this.state.event.getStateKey(),
|
||||
eventType: this.state.event.getType(),
|
||||
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
|
||||
stateKey: this.state.event.getStateKey(),
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
|
101
src/components/views/dialogs/GenericFeatureFeedbackDialog.tsx
Normal file
101
src/components/views/dialogs/GenericFeatureFeedbackDialog.tsx
Normal file
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
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 React, { useState } from "react";
|
||||
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Field from "../elements/Field";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import { submitFeedback } from "../../../rageshake/submit-rageshake";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import Modal from "../../../Modal";
|
||||
import InfoDialog from "./InfoDialog";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
title: string;
|
||||
subheading: string;
|
||||
rageshakeLabel: string;
|
||||
rageshakeData?: Record<string, string>;
|
||||
}
|
||||
|
||||
const GenericFeatureFeedbackDialog: React.FC<IProps> = ({
|
||||
title,
|
||||
subheading,
|
||||
children,
|
||||
rageshakeLabel,
|
||||
rageshakeData = {},
|
||||
onFinished,
|
||||
}) => {
|
||||
const [comment, setComment] = useState("");
|
||||
const [canContact, setCanContact] = useState(false);
|
||||
|
||||
const sendFeedback = async (ok: boolean) => {
|
||||
if (!ok) return onFinished(false);
|
||||
|
||||
submitFeedback(SdkConfig.get().bug_report_endpoint_url, rageshakeLabel, comment, canContact, rageshakeData);
|
||||
onFinished(true);
|
||||
|
||||
Modal.createTrackedDialog("Feedback Sent", rageshakeLabel, InfoDialog, {
|
||||
title,
|
||||
description: _t("Thank you for your feedback, we really appreciate it."),
|
||||
button: _t("Done"),
|
||||
hasCloseButton: false,
|
||||
fixedWidth: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (<QuestionDialog
|
||||
className="mx_GenericFeatureFeedbackDialog"
|
||||
hasCancelButton={true}
|
||||
title={title}
|
||||
description={<React.Fragment>
|
||||
<div className="mx_GenericFeatureFeedbackDialog_subheading">
|
||||
{ subheading }
|
||||
|
||||
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }
|
||||
|
||||
{ children }
|
||||
</div>
|
||||
|
||||
<Field
|
||||
id="feedbackComment"
|
||||
label={_t("Feedback")}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={comment}
|
||||
element="textarea"
|
||||
onChange={(ev) => {
|
||||
setComment(ev.target.value);
|
||||
}}
|
||||
autoFocus={true}
|
||||
/>
|
||||
|
||||
<StyledCheckbox
|
||||
checked={canContact}
|
||||
onChange={e => setCanContact((e.target as HTMLInputElement).checked)}
|
||||
>
|
||||
{ _t("You may contact me if you have any follow up questions") }
|
||||
</StyledCheckbox>
|
||||
</React.Fragment>}
|
||||
button={_t("Send feedback")}
|
||||
buttonDisabled={!comment}
|
||||
onFinished={sendFeedback}
|
||||
/>);
|
||||
};
|
||||
|
||||
export default GenericFeatureFeedbackDialog;
|
|
@ -20,10 +20,10 @@ import { _t } from '../../../languageHandler';
|
|||
import SdkConfig from '../../../SdkConfig';
|
||||
|
||||
export default function KeySignatureUploadFailedDialog({
|
||||
failures,
|
||||
source,
|
||||
continuation,
|
||||
onFinished,
|
||||
failures,
|
||||
source,
|
||||
continuation,
|
||||
onFinished,
|
||||
}) {
|
||||
const RETRIES = 2;
|
||||
const BaseDialog = sdk.getComponent('dialogs.BaseDialog');
|
||||
|
|
197
src/components/views/dialogs/LeaveSpaceDialog.tsx
Normal file
197
src/components/views/dialogs/LeaveSpaceDialog.tsx
Normal file
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
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 React, { useEffect, useMemo, useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import BaseDialog from "../dialogs/BaseDialog";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
import { Entry } from "./AddExistingToSpaceDialog";
|
||||
import SearchBox from "../../structures/SearchBox";
|
||||
import QueryMatcher from "../../../autocomplete/QueryMatcher";
|
||||
import StyledRadioGroup from "../elements/StyledRadioGroup";
|
||||
|
||||
enum RoomsToLeave {
|
||||
All = "All",
|
||||
Specific = "Specific",
|
||||
None = "None",
|
||||
}
|
||||
|
||||
const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const lcQuery = query.toLowerCase().trim();
|
||||
|
||||
const filteredRooms = useMemo(() => {
|
||||
if (!lcQuery) {
|
||||
return rooms;
|
||||
}
|
||||
|
||||
const matcher = new QueryMatcher<Room>(rooms, {
|
||||
keys: ["name"],
|
||||
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
|
||||
shouldMatchWordsOnly: false,
|
||||
});
|
||||
|
||||
return matcher.match(lcQuery);
|
||||
}, [rooms, lcQuery]);
|
||||
|
||||
return <div className="mx_LeaveSpaceDialog_section">
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={filterPlaceholder}
|
||||
onSearch={setQuery}
|
||||
autoComplete={true}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_LeaveSpaceDialog_content">
|
||||
{ filteredRooms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selected.has(room)}
|
||||
onChange={(checked) => {
|
||||
onChange(checked, room);
|
||||
}}
|
||||
/>;
|
||||
}) }
|
||||
{ filteredRooms.length < 1 ? <span className="mx_LeaveSpaceDialog_noResults">
|
||||
{ _t("No results") }
|
||||
</span> : undefined }
|
||||
</AutoHideScrollbar>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => {
|
||||
const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
|
||||
const [state, setState] = useState<string>(RoomsToLeave.All);
|
||||
|
||||
useEffect(() => {
|
||||
if (state === RoomsToLeave.All) {
|
||||
setRoomsToLeave(spaceChildren);
|
||||
} else {
|
||||
setRoomsToLeave([]);
|
||||
}
|
||||
}, [setRoomsToLeave, state, spaceChildren]);
|
||||
|
||||
return <div className="mx_LeaveSpaceDialog_section">
|
||||
<StyledRadioGroup
|
||||
name="roomsToLeave"
|
||||
value={state}
|
||||
onChange={setState}
|
||||
definitions={[
|
||||
{
|
||||
value: RoomsToLeave.All,
|
||||
label: _t("Leave all rooms and spaces"),
|
||||
}, {
|
||||
value: RoomsToLeave.None,
|
||||
label: _t("Don't leave any"),
|
||||
}, {
|
||||
value: RoomsToLeave.Specific,
|
||||
label: _t("Leave specific rooms and spaces"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{ state === RoomsToLeave.Specific && (
|
||||
<SpaceChildPicker
|
||||
filterPlaceholder={_t("Search %(spaceName)s", { spaceName: space.name })}
|
||||
rooms={spaceChildren}
|
||||
selected={selected}
|
||||
onChange={(selected: boolean, room: Room) => {
|
||||
if (selected) {
|
||||
setRoomsToLeave([room, ...roomsToLeave]);
|
||||
} else {
|
||||
setRoomsToLeave(roomsToLeave.filter(r => r !== room));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) }
|
||||
</div>;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
onFinished(leave: boolean, rooms?: Room[]): void;
|
||||
}
|
||||
|
||||
const isOnlyAdmin = (room: Room): boolean => {
|
||||
return !room.getJoinedMembers().some(member => {
|
||||
return member.userId !== room.client.credentials.userId && member.powerLevelNorm === 100;
|
||||
});
|
||||
};
|
||||
|
||||
const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
|
||||
const spaceChildren = useMemo(() => SpaceStore.instance.getChildren(space.roomId), [space.roomId]);
|
||||
const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]);
|
||||
|
||||
let rejoinWarning;
|
||||
if (space.getJoinRule() !== JoinRule.Public) {
|
||||
rejoinWarning = _t("You won't be able to rejoin unless you are re-invited.");
|
||||
}
|
||||
|
||||
let onlyAdminWarning;
|
||||
if (isOnlyAdmin(space)) {
|
||||
onlyAdminWarning = _t("You're the only admin of this space. " +
|
||||
"Leaving it will mean no one has control over it.");
|
||||
} else {
|
||||
const numChildrenOnlyAdminIn = roomsToLeave.filter(isOnlyAdmin).length;
|
||||
if (numChildrenOnlyAdminIn > 0) {
|
||||
onlyAdminWarning = _t("You're the only admin of some of the rooms or spaces you wish to leave. " +
|
||||
"Leaving them will leave them without any admins.");
|
||||
}
|
||||
}
|
||||
|
||||
return <BaseDialog
|
||||
title={_t("Leave %(spaceName)s", { spaceName: space.name })}
|
||||
className="mx_LeaveSpaceDialog"
|
||||
contentId="mx_LeaveSpaceDialog"
|
||||
onFinished={() => onFinished(false)}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<div className="mx_Dialog_content" id="mx_LeaveSpaceDialog">
|
||||
<p>
|
||||
{ _t("Are you sure you want to leave <spaceName/>?", {}, {
|
||||
spaceName: () => <b>{ space.name }</b>,
|
||||
}) }
|
||||
|
||||
{ rejoinWarning }
|
||||
</p>
|
||||
|
||||
{ spaceChildren.length > 0 && <LeaveRoomsPicker
|
||||
space={space}
|
||||
spaceChildren={spaceChildren}
|
||||
roomsToLeave={roomsToLeave}
|
||||
setRoomsToLeave={setRoomsToLeave}
|
||||
/> }
|
||||
|
||||
{ onlyAdminWarning && <div className="mx_LeaveSpaceDialog_section_warning">
|
||||
{ onlyAdminWarning }
|
||||
</div> }
|
||||
</div>
|
||||
<DialogButtons
|
||||
primaryButton={_t("Leave space")}
|
||||
onPrimaryButtonClick={() => onFinished(true, roomsToLeave)}
|
||||
hasCancel={true}
|
||||
onCancel={onFinished}
|
||||
/>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default LeaveSpaceDialog;
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
|
||||
import Modal from '../../../Modal';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
|
@ -24,19 +25,25 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
|||
import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (success: boolean) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
shouldLoadBackupStatus: boolean;
|
||||
loading: boolean;
|
||||
backupInfo: IKeyBackupInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.dialogs.LogoutDialog")
|
||||
export default class LogoutDialog extends React.Component {
|
||||
defaultProps = {
|
||||
export default class LogoutDialog extends React.Component<IProps, IState> {
|
||||
static defaultProps = {
|
||||
onFinished: function() {},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this);
|
||||
this._onExportE2eKeysClicked = this._onExportE2eKeysClicked.bind(this);
|
||||
this._onFinished = this._onFinished.bind(this);
|
||||
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
|
||||
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled();
|
||||
|
@ -49,11 +56,11 @@ export default class LogoutDialog extends React.Component {
|
|||
};
|
||||
|
||||
if (shouldLoadBackupStatus) {
|
||||
this._loadBackupStatus();
|
||||
this.loadBackupStatus();
|
||||
}
|
||||
}
|
||||
|
||||
async _loadBackupStatus() {
|
||||
private async loadBackupStatus() {
|
||||
try {
|
||||
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
|
||||
this.setState({
|
||||
|
@ -69,29 +76,29 @@ export default class LogoutDialog extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_onSettingsLinkClick() {
|
||||
private onSettingsLinkClick = (): void => {
|
||||
// close dialog
|
||||
this.props.onFinished();
|
||||
}
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
_onExportE2eKeysClicked() {
|
||||
private onExportE2eKeysClicked = (): void => {
|
||||
Modal.createTrackedDialogAsync('Export E2E Keys', '',
|
||||
import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'),
|
||||
{
|
||||
matrixClient: MatrixClientPeg.get(),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_onFinished(confirmed) {
|
||||
private onFinished = (confirmed: boolean): void => {
|
||||
if (confirmed) {
|
||||
dis.dispatch({ action: 'logout' });
|
||||
}
|
||||
// close dialog
|
||||
this.props.onFinished();
|
||||
}
|
||||
this.props.onFinished(confirmed);
|
||||
};
|
||||
|
||||
_onSetRecoveryMethodClick() {
|
||||
private onSetRecoveryMethodClick = (): void => {
|
||||
if (this.state.backupInfo) {
|
||||
// A key backup exists for this account, but the creating device is not
|
||||
// verified, so restore the backup which will give us the keys from it and
|
||||
|
@ -108,15 +115,15 @@ export default class LogoutDialog extends React.Component {
|
|||
}
|
||||
|
||||
// close dialog
|
||||
this.props.onFinished();
|
||||
}
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
_onLogoutConfirm() {
|
||||
private onLogoutConfirm = (): void => {
|
||||
dis.dispatch({ action: 'logout' });
|
||||
|
||||
// close dialog
|
||||
this.props.onFinished();
|
||||
}
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.shouldLoadBackupStatus) {
|
||||
|
@ -152,16 +159,16 @@ export default class LogoutDialog extends React.Component {
|
|||
</div>
|
||||
<DialogButtons primaryButton={setupButtonCaption}
|
||||
hasCancel={false}
|
||||
onPrimaryButtonClick={this._onSetRecoveryMethodClick}
|
||||
onPrimaryButtonClick={this.onSetRecoveryMethodClick}
|
||||
focus={true}
|
||||
>
|
||||
<button onClick={this._onLogoutConfirm}>
|
||||
<button onClick={this.onLogoutConfirm}>
|
||||
{ _t("I don't want my encrypted messages") }
|
||||
</button>
|
||||
</DialogButtons>
|
||||
<details>
|
||||
<summary>{ _t("Advanced") }</summary>
|
||||
<p><button onClick={this._onExportE2eKeysClicked}>
|
||||
<p><button onClick={this.onExportE2eKeysClicked}>
|
||||
{ _t("Manually export keys") }
|
||||
</button></p>
|
||||
</details>
|
||||
|
@ -174,7 +181,7 @@ export default class LogoutDialog extends React.Component {
|
|||
title={_t("You'll lose access to your encrypted messages")}
|
||||
contentId='mx_Dialog_content'
|
||||
hasCancel={true}
|
||||
onFinished={this._onFinished}
|
||||
onFinished={this.onFinished}
|
||||
>
|
||||
{ dialogContent }
|
||||
</BaseDialog>);
|
||||
|
@ -187,7 +194,7 @@ export default class LogoutDialog extends React.Component {
|
|||
"Are you sure you want to sign out?",
|
||||
)}
|
||||
button={_t("Sign out")}
|
||||
onFinished={this._onFinished}
|
||||
onFinished={this.onFinished}
|
||||
/>);
|
||||
}
|
||||
}
|
68
src/components/views/elements/JoinRuleDropdown.tsx
Normal file
68
src/components/views/elements/JoinRuleDropdown.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
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 React from 'react';
|
||||
import { JoinRule } from 'matrix-js-sdk/src/@types/partials';
|
||||
|
||||
import Dropdown from "./Dropdown";
|
||||
|
||||
interface IProps {
|
||||
value: JoinRule;
|
||||
label: string;
|
||||
width?: number;
|
||||
labelInvite: string;
|
||||
labelPublic: string;
|
||||
labelRestricted?: string; // if omitted then this option will be hidden, e.g if unsupported
|
||||
onChange(value: JoinRule): void;
|
||||
}
|
||||
|
||||
const JoinRuleDropdown = ({
|
||||
label,
|
||||
labelInvite,
|
||||
labelPublic,
|
||||
labelRestricted,
|
||||
value,
|
||||
width = 448,
|
||||
onChange,
|
||||
}: IProps) => {
|
||||
const options = [
|
||||
<div key={JoinRule.Invite} className="mx_JoinRuleDropdown_invite">
|
||||
{ labelInvite }
|
||||
</div>,
|
||||
<div key={JoinRule.Public} className="mx_JoinRuleDropdown_public">
|
||||
{ labelPublic }
|
||||
</div>,
|
||||
];
|
||||
|
||||
if (labelRestricted) {
|
||||
options.unshift(<div key={JoinRule.Restricted} className="mx_JoinRuleDropdown_restricted">
|
||||
{ labelRestricted }
|
||||
</div>);
|
||||
}
|
||||
|
||||
return <Dropdown
|
||||
id="mx_JoinRuleDropdown"
|
||||
className="mx_JoinRuleDropdown"
|
||||
onOptionChange={onChange}
|
||||
menuWidth={width}
|
||||
value={value}
|
||||
label={label}
|
||||
>
|
||||
{ options }
|
||||
</Dropdown>;
|
||||
};
|
||||
|
||||
export default JoinRuleDropdown;
|
|
@ -192,7 +192,8 @@ class Pill extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
onUserPillClicked = () => {
|
||||
onUserPillClicked = (e) => {
|
||||
e.preventDefault();
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: this.state.member,
|
||||
|
|
|
@ -25,7 +25,6 @@ import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call';
|
|||
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
|
||||
import classNames from 'classnames';
|
||||
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
@ -117,14 +116,12 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
|||
if (state === CallState.Ended) {
|
||||
const hangupReason = this.props.callEventGrouper.hangupReason;
|
||||
const gotRejected = this.props.callEventGrouper.gotRejected;
|
||||
const rejectParty = this.props.callEventGrouper.rejectParty;
|
||||
|
||||
if (gotRejected) {
|
||||
const weDeclinedCall = MatrixClientPeg.get().getUserId() === rejectParty;
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ weDeclinedCall ? _t("You declined this call") : _t("They declined this call") }
|
||||
{ this.renderCallBackButton(weDeclinedCall ? _t("Call back") : _t("Call again")) }
|
||||
{ _t("Call declined") }
|
||||
{ this.renderCallBackButton(_t("Call back")) }
|
||||
</div>
|
||||
);
|
||||
} else if (([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason)) {
|
||||
|
@ -136,14 +133,14 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
|||
// Also, if we don't have a reason
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("This call has ended") }
|
||||
{ _t("Call ended") }
|
||||
</div>
|
||||
);
|
||||
} else if (hangupReason === CallErrorCode.InviteTimeout) {
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("They didn't pick up") }
|
||||
{ this.renderCallBackButton(_t("Call again")) }
|
||||
{ _t("Missed call") }
|
||||
{ this.renderCallBackButton(_t("Call back")) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -176,7 +173,8 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
|||
className="mx_CallEvent_content_tooltip"
|
||||
kind={InfoTooltipKind.Warning}
|
||||
/>
|
||||
{ _t("This call has failed") }
|
||||
{ _t("Connection failed") }
|
||||
{ this.renderCallBackButton(_t("Retry")) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -190,7 +188,7 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
|||
if (state === CustomCallState.Missed) {
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("You missed this call") }
|
||||
{ _t("Missed call") }
|
||||
{ this.renderCallBackButton(_t("Call back")) }
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -16,12 +16,13 @@ limitations under the License.
|
|||
|
||||
import { MatrixEvent } from "matrix-js-sdk/src";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import React, { createRef } from "react";
|
||||
import React from "react";
|
||||
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import classNames from "classnames";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { FileDownloader } from "../../../utils/FileDownloader";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
@ -39,7 +40,7 @@ interface IState {
|
|||
|
||||
@replaceableComponent("views.messages.DownloadActionButton")
|
||||
export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
|
||||
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
|
||||
private downloader = new FileDownloader();
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -56,27 +57,21 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
|
|||
|
||||
if (this.state.blob) {
|
||||
// Cheat and trigger a download, again.
|
||||
return this.onFrameLoad();
|
||||
return this.doDownload();
|
||||
}
|
||||
|
||||
const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
|
||||
this.setState({ blob });
|
||||
await this.doDownload();
|
||||
};
|
||||
|
||||
private onFrameLoad = () => {
|
||||
this.setState({ loading: false });
|
||||
|
||||
// we aren't showing the iframe, so we can send over the bare minimum styles and such.
|
||||
this.iframe.current.contentWindow.postMessage({
|
||||
imgSrc: "", // no image
|
||||
imgStyle: null,
|
||||
style: "",
|
||||
private async doDownload() {
|
||||
await this.downloader.download({
|
||||
blob: this.state.blob,
|
||||
download: this.props.mediaEventHelperGet().fileName,
|
||||
textContent: "",
|
||||
auto: true, // autodownload
|
||||
}, '*');
|
||||
};
|
||||
name: this.props.mediaEventHelperGet().fileName,
|
||||
});
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
|
||||
public render() {
|
||||
let spinner: JSX.Element;
|
||||
|
@ -92,18 +87,11 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
|
|||
|
||||
return <RovingAccessibleTooltipButton
|
||||
className={classes}
|
||||
title={spinner ? _t("Downloading") : _t("Download")}
|
||||
title={spinner ? _t("Decrypting") : _t("Download")}
|
||||
onClick={this.onDownloadClick}
|
||||
disabled={!!spinner}
|
||||
>
|
||||
{ spinner }
|
||||
{ this.state.blob && <iframe
|
||||
src="usercontent/" // XXX: Like MFileBody, this should come from the skin
|
||||
ref={this.iframe}
|
||||
onLoad={this.onFrameLoad}
|
||||
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation"
|
||||
style={{ display: "none" }}
|
||||
/> }
|
||||
</RovingAccessibleTooltipButton>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,8 @@ import { TileShape } from "../rooms/EventTile";
|
|||
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import { FileDownloader } from "../../../utils/FileDownloader";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
|
||||
export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
|
||||
|
||||
|
@ -111,6 +113,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
|
||||
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
|
||||
private userDidClick = false;
|
||||
private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current);
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -118,6 +121,32 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
this.state = {};
|
||||
}
|
||||
|
||||
private get content(): IMediaEventContent {
|
||||
return this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
}
|
||||
|
||||
private get fileName(): string {
|
||||
return this.content.body && this.content.body.length > 0 ? this.content.body : _t("Attachment");
|
||||
}
|
||||
|
||||
private get linkText(): string {
|
||||
return presentableTextForFile(this.content);
|
||||
}
|
||||
|
||||
private downloadFile(fileName: string, text: string) {
|
||||
this.fileDownloader.download({
|
||||
blob: this.state.decryptedBlob,
|
||||
name: fileName,
|
||||
autoDownload: this.userDidClick,
|
||||
opts: {
|
||||
imgSrc: DOWNLOAD_ICON_URL,
|
||||
imgStyle: null,
|
||||
style: computedStyle(this.dummyLink.current),
|
||||
textContent: _t("Download %(text)s", { text }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getContentUrl(): string {
|
||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
||||
return media.srcHttp;
|
||||
|
@ -129,24 +158,56 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
const text = presentableTextForFile(content);
|
||||
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
|
||||
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
|
||||
const contentUrl = this.getContentUrl();
|
||||
const fileSize = content.info ? content.info.size : null;
|
||||
const fileType = content.info ? content.info.mimetype : "application/octet-stream";
|
||||
private decryptFile = async (): Promise<void> => {
|
||||
if (this.state.decryptedBlob) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.userDidClick = true;
|
||||
this.setState({
|
||||
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Unable to decrypt attachment: ", err);
|
||||
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Error decrypting attachment"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let placeholder = null;
|
||||
private onPlaceholderClick = async () => {
|
||||
const mediaHelper = this.props.mediaEventHelper;
|
||||
if (mediaHelper.media.isEncrypted) {
|
||||
await this.decryptFile();
|
||||
this.downloadFile(this.fileName, this.linkText);
|
||||
} else {
|
||||
// As a button we're missing the `download` attribute for styling reasons, so
|
||||
// download with the file downloader.
|
||||
this.fileDownloader.download({
|
||||
blob: await mediaHelper.sourceBlob.value,
|
||||
name: this.fileName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public render() {
|
||||
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
|
||||
const contentUrl = this.getContentUrl();
|
||||
const fileSize = this.content.info ? this.content.info.size : null;
|
||||
const fileType = this.content.info ? this.content.info.mimetype : "application/octet-stream";
|
||||
|
||||
let placeholder: React.ReactNode = null;
|
||||
if (this.props.showGenericPlaceholder) {
|
||||
placeholder = (
|
||||
<div className="mx_MediaBody mx_MFileBody_info">
|
||||
<AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
|
||||
<span className="mx_MFileBody_info_icon" />
|
||||
<span className="mx_MFileBody_info_filename">
|
||||
{ presentableTextForFile(content, _t("Attachment"), false) }
|
||||
</span>
|
||||
</div>
|
||||
<TextWithTooltip tooltip={presentableTextForFile(this.content, _t("Attachment"), true)}>
|
||||
<span className="mx_MFileBody_info_filename">
|
||||
{ presentableTextForFile(this.content, _t("Attachment"), true, true) }
|
||||
</span>
|
||||
</TextWithTooltip>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -157,20 +218,6 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
// Need to decrypt the attachment
|
||||
// Wait for the user to click on the link before downloading
|
||||
// and decrypting the attachment.
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
this.userDidClick = true;
|
||||
this.setState({
|
||||
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Unable to decrypt attachment: ", err);
|
||||
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Error decrypting attachment"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// This button should actually Download because usercontent/ will try to click itself
|
||||
// but it is not guaranteed between various browsers' settings.
|
||||
|
@ -178,31 +225,14 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
<span className="mx_MFileBody">
|
||||
{ placeholder }
|
||||
{ showDownloadLink && <div className="mx_MFileBody_download">
|
||||
<AccessibleButton onClick={decrypt}>
|
||||
{ _t("Decrypt %(text)s", { text: text }) }
|
||||
<AccessibleButton onClick={this.decryptFile}>
|
||||
{ _t("Decrypt %(text)s", { text: this.linkText }) }
|
||||
</AccessibleButton>
|
||||
</div> }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// When the iframe loads we tell it to render a download link
|
||||
const onIframeLoad = (ev) => {
|
||||
ev.target.contentWindow.postMessage({
|
||||
imgSrc: DOWNLOAD_ICON_URL,
|
||||
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
|
||||
style: computedStyle(this.dummyLink.current),
|
||||
blob: this.state.decryptedBlob,
|
||||
// Set a download attribute for encrypted files so that the file
|
||||
// will have the correct name when the user tries to download it.
|
||||
// We can't provide a Content-Disposition header like we would for HTTP.
|
||||
download: fileName,
|
||||
textContent: _t("Download %(text)s", { text: text }),
|
||||
// only auto-download if a user triggered this iframe explicitly
|
||||
auto: this.userDidClick,
|
||||
}, "*");
|
||||
};
|
||||
|
||||
const url = "usercontent/"; // XXX: this path should probably be passed from the skin
|
||||
|
||||
// If the attachment is encrypted then put the link inside an iframe.
|
||||
|
@ -218,9 +248,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
*/ }
|
||||
<a ref={this.dummyLink} />
|
||||
</div>
|
||||
{ /*
|
||||
TODO: Move iframe (and dummy link) into FileDownloader.
|
||||
We currently have it set up this way because of styles applied to the iframe
|
||||
itself which cannot be easily handled/overridden by the FileDownloader. In
|
||||
future, the download link may disappear entirely at which point it could also
|
||||
be suitable to just remove this bit of code.
|
||||
*/ }
|
||||
<iframe
|
||||
src={url}
|
||||
onLoad={onIframeLoad}
|
||||
onLoad={() => this.downloadFile(this.fileName, this.linkText)}
|
||||
ref={this.iframe}
|
||||
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
|
||||
</div> }
|
||||
|
@ -259,7 +296,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
|
||||
// We have to create an anchor to download the file
|
||||
const tempAnchor = document.createElement('a');
|
||||
tempAnchor.download = fileName;
|
||||
tempAnchor.download = this.fileName;
|
||||
tempAnchor.href = blobUrl;
|
||||
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
|
||||
tempAnchor.click();
|
||||
|
@ -268,7 +305,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
};
|
||||
} else {
|
||||
// Else we are hoping the browser will do the right thing
|
||||
downloadProps["download"] = fileName;
|
||||
downloadProps["download"] = this.fileName;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -277,16 +314,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
{ showDownloadLink && <div className="mx_MFileBody_download">
|
||||
<a {...downloadProps}>
|
||||
<span className="mx_MFileBody_download_icon" />
|
||||
{ _t("Download %(text)s", { text: text }) }
|
||||
{ _t("Download %(text)s", { text: this.linkText }) }
|
||||
</a>
|
||||
{ this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
|
||||
{ content.info && content.info.size ? filesize(content.info.size) : "" }
|
||||
{ this.content.info && this.content.info.size ? filesize(this.content.info.size) : "" }
|
||||
</div> }
|
||||
</div> }
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
const extra = text ? (': ' + text) : '';
|
||||
const extra = this.linkText ? (': ' + this.linkText) : '';
|
||||
return <span className="mx_MFileBody">
|
||||
{ placeholder }
|
||||
{ _t("Invalid file%(extra)s", { extra: extra }) }
|
||||
|
|
|
@ -366,7 +366,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
}
|
||||
|
||||
const thumbnail = (
|
||||
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px", maxWidth: maxWidth + "px" }}>
|
||||
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight, maxWidth: maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}>
|
||||
{ showPlaceholder &&
|
||||
<div
|
||||
className="mx_MImageBody_thumbnail"
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import MAudioBody from "./MAudioBody";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import MVoiceMessageBody from "./MVoiceMessageBody";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
|
||||
|
@ -27,8 +26,7 @@ export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
|
|||
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
|
||||
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']
|
||||
|| !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice'];
|
||||
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages");
|
||||
if (isVoiceMessage && voiceMessagesEnabled) {
|
||||
if (isVoiceMessage) {
|
||||
return <MVoiceMessageBody {...this.props} />;
|
||||
} else {
|
||||
return <MAudioBody {...this.props} />;
|
||||
|
|
|
@ -15,18 +15,21 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src';
|
||||
import classNames from 'classnames';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
|
||||
@replaceableComponent("views.messages.ViewSourceEvent")
|
||||
export default class ViewSourceEvent extends React.PureComponent {
|
||||
static propTypes = {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
};
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.ViewSourceEvent")
|
||||
export default class ViewSourceEvent extends React.PureComponent<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
@ -35,7 +38,7 @@ export default class ViewSourceEvent extends React.PureComponent {
|
|||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
const { mxEvent } = this.props;
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
|
@ -46,15 +49,15 @@ export default class ViewSourceEvent extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
onToggle = (ev) => {
|
||||
private onToggle = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
const { expanded } = this.state;
|
||||
this.setState({
|
||||
expanded: !expanded,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): React.ReactNode {
|
||||
const { mxEvent } = this.props;
|
||||
const { expanded } = this.state;
|
||||
|
|
@ -152,7 +152,7 @@ const PinnedMessagesCard = ({ room, onClose }: IProps) => {
|
|||
<h2>{ _t("Nothing pinned, yet") }</h2>
|
||||
{ _t("If you have permissions, open the menu on any message and select " +
|
||||
"<b>Pin</b> to stick them here.", {}, {
|
||||
b: sub => <b>{ sub }</b>,
|
||||
b: sub => <b>{ sub }</b>,
|
||||
}) }
|
||||
</div>
|
||||
</div>;
|
||||
|
|
|
@ -1381,8 +1381,8 @@ const BasicUserInfo: React.FC<{
|
|||
className="mx_UserInfo_field"
|
||||
onClick={() => {
|
||||
dis.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Security,
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Security,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -55,6 +55,14 @@ const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.sourc
|
|||
|
||||
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
|
||||
|
||||
const SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"];
|
||||
const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
|
||||
["(", ")"],
|
||||
["[", "]"],
|
||||
["{", "}"],
|
||||
["<", ">"],
|
||||
]);
|
||||
|
||||
function ctrlShortcutLabel(key: string): string {
|
||||
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
|
||||
}
|
||||
|
@ -99,6 +107,7 @@ interface IState {
|
|||
showVisualBell?: boolean;
|
||||
autoComplete?: AutocompleteWrapperModel;
|
||||
completionIndex?: number;
|
||||
surroundWith: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.rooms.BasicMessageEditor")
|
||||
|
@ -117,12 +126,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
|
||||
private readonly emoticonSettingHandle: string;
|
||||
private readonly shouldShowPillAvatarSettingHandle: string;
|
||||
private readonly surroundWithHandle: string;
|
||||
private readonly historyManager = new HistoryManager();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
|
||||
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
|
||||
};
|
||||
|
||||
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
|
||||
|
@ -130,6 +141,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
this.configureEmoticonAutoReplace();
|
||||
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
|
||||
this.configureShouldShowPillAvatar);
|
||||
this.surroundWithHandle = SettingsStore.watchSetting("MessageComposerInput.surroundWith", null,
|
||||
this.surroundWithSettingChanged);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IProps) {
|
||||
|
@ -422,6 +435,28 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
private onKeyDown = (event: React.KeyboardEvent): void => {
|
||||
const model = this.props.model;
|
||||
let handled = false;
|
||||
|
||||
if (this.state.surroundWith && document.getSelection().type != "Caret") {
|
||||
// This surrounds the selected text with a character. This is
|
||||
// intentionally left out of the keybinding manager as the keybinds
|
||||
// here shouldn't be changeable
|
||||
|
||||
const selectionRange = getRangeForSelection(
|
||||
this.editorRef.current,
|
||||
this.props.model,
|
||||
document.getSelection(),
|
||||
);
|
||||
// trim the range as we want it to exclude leading/trailing spaces
|
||||
selectionRange.trim();
|
||||
|
||||
if ([...SURROUND_WITH_DOUBLE_CHARACTERS.keys(), ...SURROUND_WITH_CHARACTERS].includes(event.key)) {
|
||||
this.historyManager.ensureLastChangesPushed(this.props.model);
|
||||
this.modifiedFlag = true;
|
||||
toggleInlineFormat(selectionRange, event.key, SURROUND_WITH_DOUBLE_CHARACTERS.get(event.key));
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
||||
switch (action) {
|
||||
case MessageComposerAction.FormatBold:
|
||||
|
@ -574,6 +609,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
this.setState({ showPillAvatar });
|
||||
};
|
||||
|
||||
private surroundWithSettingChanged = () => {
|
||||
const surroundWith = SettingsStore.getValue("MessageComposerInput.surroundWith");
|
||||
this.setState({ surroundWith });
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener("selectionchange", this.onSelectionChange);
|
||||
this.editorRef.current.removeEventListener("input", this.onInput, true);
|
||||
|
@ -581,6 +621,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
|
||||
SettingsStore.unwatchSetting(this.emoticonSettingHandle);
|
||||
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
|
||||
SettingsStore.unwatchSetting(this.surroundWithHandle);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
|
|
@ -394,12 +394,10 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />);
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("feature_voice_messages")) {
|
||||
controls.push(<VoiceRecordComposerTile
|
||||
key="controls_voice_record"
|
||||
ref={c => this.voiceRecordingButton = c}
|
||||
room={this.props.room} />);
|
||||
}
|
||||
controls.push(<VoiceRecordComposerTile
|
||||
key="controls_voice_record"
|
||||
ref={c => this.voiceRecordingButton = c}
|
||||
room={this.props.room} />);
|
||||
|
||||
if (!this.state.isComposerEmpty || this.state.haveRecording) {
|
||||
controls.push(
|
||||
|
|
|
@ -64,9 +64,9 @@ const NewRoomIntro = () => {
|
|||
height={AVATAR_SIZE}
|
||||
onClick={() => {
|
||||
defaultDispatcher.dispatch<ViewUserPayload>({
|
||||
action: Action.ViewUser,
|
||||
// XXX: We should be using a real member object and not assuming what the receiver wants.
|
||||
member: member || { userId: dmPartner } as User,
|
||||
action: Action.ViewUser,
|
||||
// XXX: We should be using a real member object and not assuming what the receiver wants.
|
||||
member: member || { userId: dmPartner } as User,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -145,7 +145,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
|
|||
if (oldInfo && oldInfo.left) {
|
||||
// start at the old height and in the old h pos
|
||||
startStyles.push({ top: startTopOffset+"px",
|
||||
left: toPx(oldInfo.left) });
|
||||
left: toPx(oldInfo.left) });
|
||||
}
|
||||
|
||||
startStyles.push({ top: startTopOffset+'px', left: '0' });
|
||||
|
@ -174,14 +174,14 @@ export default class ReadReceiptMarker extends React.PureComponent {
|
|||
title = _t(
|
||||
"Seen by %(userName)s at %(dateTime)s",
|
||||
{ userName: this.props.fallbackUserId,
|
||||
dateTime: dateString },
|
||||
dateTime: dateString },
|
||||
);
|
||||
} else {
|
||||
title = _t(
|
||||
"Seen by %(displayName)s (%(userName)s) at %(dateTime)s",
|
||||
{ displayName: this.props.member.rawDisplayName,
|
||||
userName: this.props.fallbackUserId,
|
||||
dateTime: dateString },
|
||||
userName: this.props.fallbackUserId,
|
||||
dateTime: dateString },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,10 +17,7 @@ limitations under the License.
|
|||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import React, { ReactNode } from "react";
|
||||
import {
|
||||
RecordingState,
|
||||
VoiceRecording,
|
||||
} from "../../../audio/VoiceRecording";
|
||||
import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import classNames from "classnames";
|
||||
|
@ -34,6 +31,11 @@ import { MsgType } from "matrix-js-sdk/src/@types/event";
|
|||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
import InlineSpinner from "../elements/InlineSpinner";
|
||||
import { PlaybackManager } from "../../../audio/PlaybackManager";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
|
@ -42,6 +44,7 @@ interface IProps {
|
|||
interface IState {
|
||||
recorder?: VoiceRecording;
|
||||
recordingPhase?: RecordingState;
|
||||
didUploadFail?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -69,9 +72,19 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
|
||||
await this.state.recorder.stop();
|
||||
|
||||
let upload: IUpload;
|
||||
try {
|
||||
const upload = await this.state.recorder.upload(this.props.room.roomId);
|
||||
upload = await this.state.recorder.upload(this.props.room.roomId);
|
||||
} catch (e) {
|
||||
console.error("Error uploading voice message:", e);
|
||||
|
||||
// Flag error and move on. The recording phase will be reset by the upload function.
|
||||
this.setState({ didUploadFail: true });
|
||||
|
||||
return; // don't dispose the recording: the user has a chance to re-upload
|
||||
}
|
||||
|
||||
try {
|
||||
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
|
||||
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
|
||||
"body": "Voice message",
|
||||
|
@ -104,12 +117,11 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error sending/uploading voice message:", e);
|
||||
Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
|
||||
title: _t('Upload Failed'),
|
||||
description: _t("The voice message failed to upload."),
|
||||
});
|
||||
return; // don't dispose the recording so the user can retry, maybe
|
||||
console.error("Error sending voice message:", e);
|
||||
|
||||
// Voice message should be in the timeline at this point, so let other things take care
|
||||
// of error handling. We also shouldn't need the recording anymore, so fall through to
|
||||
// disposal.
|
||||
}
|
||||
await this.disposeRecording();
|
||||
}
|
||||
|
@ -118,7 +130,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
await VoiceRecordingStore.instance.disposeRecording();
|
||||
|
||||
// Reset back to no recording, which means no phase (ie: restart component entirely)
|
||||
this.setState({ recorder: null, recordingPhase: null });
|
||||
this.setState({ recorder: null, recordingPhase: null, didUploadFail: false });
|
||||
}
|
||||
|
||||
private onCancel = async () => {
|
||||
|
@ -166,6 +178,9 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
}
|
||||
|
||||
try {
|
||||
// stop any noises which might be happening
|
||||
await PlaybackManager.instance.playOnly(null);
|
||||
|
||||
const recorder = VoiceRecordingStore.instance.startRecording();
|
||||
await recorder.start();
|
||||
|
||||
|
@ -209,9 +224,9 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording,
|
||||
});
|
||||
|
||||
let tooltip = _t("Record a voice message");
|
||||
let tooltip = _t("Send voice message");
|
||||
if (!!this.state.recorder) {
|
||||
tooltip = _t("Stop the recording");
|
||||
tooltip = _t("Stop recording");
|
||||
}
|
||||
|
||||
let stopOrRecordBtn = <AccessibleTooltipButton
|
||||
|
@ -229,12 +244,30 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
|
||||
deleteButton = <AccessibleTooltipButton
|
||||
className='mx_VoiceRecordComposerTile_delete'
|
||||
title={_t("Delete recording")}
|
||||
title={_t("Delete")}
|
||||
onClick={this.onCancel}
|
||||
/>;
|
||||
}
|
||||
|
||||
let uploadIndicator;
|
||||
if (this.state.recordingPhase === RecordingState.Uploading) {
|
||||
uploadIndicator = <span className='mx_VoiceRecordComposerTile_uploadingState'>
|
||||
<InlineSpinner w={16} h={16} />
|
||||
</span>;
|
||||
} else if (this.state.didUploadFail && this.state.recordingPhase === RecordingState.Ended) {
|
||||
uploadIndicator = <span className='mx_VoiceRecordComposerTile_failedState'>
|
||||
<span className='mx_VoiceRecordComposerTile_uploadState_badge'>
|
||||
{ /* Need to stick the badge in a span to ensure it doesn't create a block component */ }
|
||||
<NotificationBadge
|
||||
notification={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
|
||||
/>
|
||||
</span>
|
||||
<span className='text-warning'>{ _t("Failed to send") }</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
return (<>
|
||||
{ uploadIndicator }
|
||||
{ deleteButton }
|
||||
{ this.renderWaveformArea() }
|
||||
{ recordingInfo }
|
||||
|
|
|
@ -26,6 +26,7 @@ import { replaceableComponent } from "../../../../../utils/replaceableComponent"
|
|||
import SettingsFlag from '../../../elements/SettingsFlag';
|
||||
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
|
||||
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||
import SpaceStore from "../../../../../stores/SpaceStore";
|
||||
|
||||
interface IState {
|
||||
autoLaunch: boolean;
|
||||
|
@ -47,6 +48,10 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
|
|||
'breadcrumbs',
|
||||
];
|
||||
|
||||
static SPACES_SETTINGS = [
|
||||
"Spaces.allRoomsInHome",
|
||||
];
|
||||
|
||||
static KEYBINDINGS_SETTINGS = [
|
||||
'ctrlFForSearch',
|
||||
];
|
||||
|
@ -56,6 +61,7 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
|
|||
'MessageComposerInput.suggestEmoji',
|
||||
'sendTypingNotifications',
|
||||
'MessageComposerInput.ctrlEnterToSend',
|
||||
'MessageComposerInput.surroundWith',
|
||||
'MessageComposerInput.showStickersButton',
|
||||
];
|
||||
|
||||
|
@ -231,6 +237,11 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
|
|||
{ this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) }
|
||||
</div>
|
||||
|
||||
{ SpaceStore.spacesEnabled && <div className="mx_SettingsTab_section">
|
||||
<span className="mx_SettingsTab_subheading">{ _t("Spaces") }</span>
|
||||
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) }
|
||||
</div> }
|
||||
|
||||
<div className="mx_SettingsTab_section">
|
||||
<span className="mx_SettingsTab_subheading">{ _t("Keyboard shortcuts") }</span>
|
||||
<AccessibleButton className="mx_SettingsFlag" onClick={KeyboardShortcuts.toggleDialog}>
|
||||
|
|
|
@ -36,6 +36,7 @@ import { UIFeature } from "../../../../../settings/UIFeature";
|
|||
import { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel";
|
||||
import CountlyAnalytics from "../../../../../CountlyAnalytics";
|
||||
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
|
||||
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
|
||||
|
||||
export class IgnoredUser extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -106,6 +107,7 @@ export default class SecurityUserSettingsTab extends React.Component {
|
|||
_updateAnalytics = (checked) => {
|
||||
checked ? Analytics.enable() : Analytics.disable();
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ !checked);
|
||||
PosthogAnalytics.instance.updateAnonymityFromSettings(MatrixClientPeg.get().getUserId());
|
||||
};
|
||||
|
||||
_onExportE2eKeysClicked = () => {
|
||||
|
|
|
@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useContext, useRef, useState } from "react";
|
||||
import React, { ComponentProps, RefObject, SyntheticEvent, useContext, useRef, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { EventType, RoomType, RoomCreateTypeField } from "matrix-js-sdk/src/@types/event";
|
||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import FocusLock from "react-focus-lock";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
@ -24,18 +24,16 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
|||
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
|
||||
import createRoom from "../../../createRoom";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { SpaceAvatar } from "./SpaceBasicSettings";
|
||||
import SpaceBasicSettings, { SpaceAvatar } from "./SpaceBasicSettings";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { BetaPill } from "../beta/BetaCard";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { UserTab } from "../dialogs/UserSettingsDialog";
|
||||
import Field from "../elements/Field";
|
||||
import withValidation from "../elements/Validation";
|
||||
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
|
||||
import { Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { ICreateRoomStateEvent } from "matrix-js-sdk/src/@types/requests";
|
||||
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import RoomAliasField from "../elements/RoomAliasField";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import Modal from "../../../Modal";
|
||||
import GenericFeatureFeedbackDialog from "../dialogs/GenericFeatureFeedbackDialog";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
|
||||
return (
|
||||
|
@ -66,8 +64,111 @@ const nameToAlias = (name: string, domain: string): string => {
|
|||
return `#${localpart}:${domain}`;
|
||||
};
|
||||
|
||||
const SpaceCreateMenu = ({ onFinished }) => {
|
||||
// XXX: Temporary for the Spaces release only
|
||||
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
|
||||
if (!SdkConfig.get().bug_report_endpoint_url) return null;
|
||||
|
||||
return <div className="mx_SpaceFeedbackPrompt">
|
||||
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a new feature.") }</span>
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
if (onClick) onClick();
|
||||
Modal.createTrackedDialog("Spaces Feedback", "", GenericFeatureFeedbackDialog, {
|
||||
title: _t("Spaces feedback"),
|
||||
subheading: _t("Thank you for trying Spaces. " +
|
||||
"Your feedback will help inform the next versions."),
|
||||
rageshakeLabel: "spaces-feedback",
|
||||
rageshakeData: Object.fromEntries([
|
||||
"feature_spaces.all_rooms",
|
||||
"feature_spaces.space_member_dms",
|
||||
"feature_spaces.space_dm_badges",
|
||||
].map(k => [k, SettingsStore.getValue(k)])),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{ _t("Give feedback.") }
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
};
|
||||
|
||||
type BProps = Pick<ComponentProps<typeof SpaceBasicSettings>, "setAvatar" | "name" | "setName" | "topic" | "setTopic">;
|
||||
interface ISpaceCreateFormProps extends BProps {
|
||||
busy: boolean;
|
||||
alias: string;
|
||||
nameFieldRef: RefObject<Field>;
|
||||
aliasFieldRef: RefObject<RoomAliasField>;
|
||||
showAliasField?: boolean;
|
||||
onSubmit(e: SyntheticEvent): void;
|
||||
setAlias(alias: string): void;
|
||||
}
|
||||
|
||||
export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
|
||||
busy,
|
||||
onSubmit,
|
||||
setAvatar,
|
||||
name,
|
||||
setName,
|
||||
nameFieldRef,
|
||||
alias,
|
||||
aliasFieldRef,
|
||||
setAlias,
|
||||
showAliasField,
|
||||
topic,
|
||||
setTopic,
|
||||
children,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const domain = cli.getDomain();
|
||||
|
||||
return <form className="mx_SpaceBasicSettings" onSubmit={onSubmit}>
|
||||
<SpaceAvatar setAvatar={setAvatar} avatarDisabled={busy} />
|
||||
|
||||
<Field
|
||||
name="spaceName"
|
||||
label={_t("Name")}
|
||||
autoFocus={true}
|
||||
value={name}
|
||||
onChange={ev => {
|
||||
const newName = ev.target.value;
|
||||
if (!alias || alias === nameToAlias(name, domain)) {
|
||||
setAlias(nameToAlias(newName, domain));
|
||||
}
|
||||
setName(newName);
|
||||
}}
|
||||
ref={nameFieldRef}
|
||||
onValidate={spaceNameValidator}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
{ showAliasField
|
||||
? <RoomAliasField
|
||||
ref={aliasFieldRef}
|
||||
onChange={setAlias}
|
||||
domain={domain}
|
||||
value={alias}
|
||||
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
|
||||
label={_t("Address")}
|
||||
disabled={busy}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
|
||||
<Field
|
||||
name="spaceTopic"
|
||||
element="textarea"
|
||||
label={_t("Description")}
|
||||
value={topic}
|
||||
onChange={ev => setTopic(ev.target.value)}
|
||||
rows={3}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
{ children }
|
||||
</form>;
|
||||
};
|
||||
|
||||
const SpaceCreateMenu = ({ onFinished }) => {
|
||||
const [visibility, setVisibility] = useState<Visibility>(null);
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
|
||||
|
@ -98,42 +199,26 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const initialState: ICreateRoomStateEvent[] = [
|
||||
{
|
||||
type: EventType.RoomHistoryVisibility,
|
||||
content: {
|
||||
"history_visibility": visibility === Visibility.Public ? "world_readable" : "invited",
|
||||
},
|
||||
},
|
||||
];
|
||||
if (avatar) {
|
||||
const url = await cli.uploadContent(avatar);
|
||||
|
||||
initialState.push({
|
||||
type: EventType.RoomAvatar,
|
||||
content: { url },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await createRoom({
|
||||
createOpts: {
|
||||
preset: visibility === Visibility.Public ? Preset.PublicChat : Preset.PrivateChat,
|
||||
name,
|
||||
creation_content: {
|
||||
[RoomCreateTypeField]: RoomType.Space,
|
||||
},
|
||||
initial_state: initialState,
|
||||
power_level_content_override: {
|
||||
// Only allow Admins to write to the timeline to prevent hidden sync spam
|
||||
events_default: 100,
|
||||
...Visibility.Public ? { invite: 0 } : {},
|
||||
...visibility === Visibility.Public ? { invite: 0 } : {},
|
||||
},
|
||||
room_alias_name: visibility === Visibility.Public && alias
|
||||
? alias.substr(1, alias.indexOf(":") - 1)
|
||||
: undefined,
|
||||
topic,
|
||||
},
|
||||
avatar,
|
||||
roomType: RoomType.Space,
|
||||
historyVisibility: visibility === Visibility.Public
|
||||
? HistoryVisibility.WorldReadable
|
||||
: HistoryVisibility.Invited,
|
||||
spinner: false,
|
||||
encryption: false,
|
||||
andView: true,
|
||||
|
@ -171,7 +256,6 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
<SpaceFeedbackPrompt onClick={onFinished} />
|
||||
</React.Fragment>;
|
||||
} else {
|
||||
const domain = cli.getDomain();
|
||||
body = <React.Fragment>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_SpaceCreateMenu_back"
|
||||
|
@ -192,49 +276,20 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
}
|
||||
</p>
|
||||
|
||||
<form className="mx_SpaceBasicSettings" onSubmit={onSpaceCreateClick}>
|
||||
<SpaceAvatar setAvatar={setAvatar} avatarDisabled={busy} />
|
||||
|
||||
<Field
|
||||
name="spaceName"
|
||||
label={_t("Name")}
|
||||
autoFocus={true}
|
||||
value={name}
|
||||
onChange={ev => {
|
||||
const newName = ev.target.value;
|
||||
if (!alias || alias === nameToAlias(name, domain)) {
|
||||
setAlias(nameToAlias(newName, domain));
|
||||
}
|
||||
setName(newName);
|
||||
}}
|
||||
ref={spaceNameField}
|
||||
onValidate={spaceNameValidator}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
{ visibility === Visibility.Public
|
||||
? <RoomAliasField
|
||||
ref={spaceAliasField}
|
||||
onChange={setAlias}
|
||||
domain={domain}
|
||||
value={alias}
|
||||
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
|
||||
label={_t("Address")}
|
||||
disabled={busy}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
|
||||
<Field
|
||||
name="spaceTopic"
|
||||
element="textarea"
|
||||
label={_t("Description")}
|
||||
value={topic}
|
||||
onChange={ev => setTopic(ev.target.value)}
|
||||
rows={3}
|
||||
disabled={busy}
|
||||
/>
|
||||
</form>
|
||||
<SpaceCreateForm
|
||||
busy={busy}
|
||||
onSubmit={onSpaceCreateClick}
|
||||
setAvatar={setAvatar}
|
||||
name={name}
|
||||
setName={setName}
|
||||
nameFieldRef={spaceNameField}
|
||||
topic={topic}
|
||||
setTopic={setTopic}
|
||||
alias={alias}
|
||||
setAlias={setAlias}
|
||||
showAliasField={visibility === Visibility.Public}
|
||||
aliasFieldRef={spaceAliasField}
|
||||
/>
|
||||
|
||||
<AccessibleButton kind="primary" onClick={onSpaceCreateClick} disabled={busy}>
|
||||
{ busy ? _t("Creating...") : _t("Create") }
|
||||
|
@ -252,13 +307,6 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
managed={false}
|
||||
>
|
||||
<FocusLock returnFocus={true}>
|
||||
<BetaPill onClick={() => {
|
||||
onFinished();
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Labs,
|
||||
});
|
||||
}} />
|
||||
{ body }
|
||||
</FocusLock>
|
||||
</ContextMenu>;
|
||||
|
|
|
@ -14,115 +14,46 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react";
|
||||
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
|
||||
import React, { ComponentProps, Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
|
||||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import { useContextMenu } from "../../structures/ContextMenu";
|
||||
import SpaceCreateMenu from "./SpaceCreateMenu";
|
||||
import { SpaceItem } from "./SpaceTreeLevel";
|
||||
import { SpaceButton, SpaceItem } from "./SpaceTreeLevel";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import SpaceStore, {
|
||||
HOME_SPACE,
|
||||
UPDATE_HOME_BEHAVIOUR,
|
||||
UPDATE_INVITED_SPACES,
|
||||
UPDATE_SELECTED_SPACE,
|
||||
UPDATE_TOP_LEVEL_SPACES,
|
||||
} from "../../../stores/SpaceStore";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
import NotificationBadge from "../rooms/NotificationBadge";
|
||||
import {
|
||||
RovingAccessibleButton,
|
||||
RovingAccessibleTooltipButton,
|
||||
RovingTabIndexProvider,
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
|
||||
interface IButtonProps {
|
||||
space?: Room;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
tooltip?: string;
|
||||
notificationState?: NotificationState;
|
||||
isNarrow?: boolean;
|
||||
onClick(): void;
|
||||
}
|
||||
|
||||
const SpaceButton: React.FC<IButtonProps> = ({
|
||||
space,
|
||||
className,
|
||||
selected,
|
||||
onClick,
|
||||
tooltip,
|
||||
notificationState,
|
||||
isNarrow,
|
||||
children,
|
||||
}) => {
|
||||
const classes = classNames("mx_SpaceButton", className, {
|
||||
mx_SpaceButton_active: selected,
|
||||
mx_SpaceButton_narrow: isNarrow,
|
||||
});
|
||||
|
||||
let avatar = <div className="mx_SpaceButton_avatarPlaceholder"><div className="mx_SpaceButton_icon" /></div>;
|
||||
if (space) {
|
||||
avatar = <RoomAvatar width={32} height={32} room={space} />;
|
||||
}
|
||||
|
||||
let notifBadge;
|
||||
if (notificationState) {
|
||||
notifBadge = <div className="mx_SpacePanel_badgeContainer">
|
||||
<NotificationBadge
|
||||
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
|
||||
forceCount={false}
|
||||
notification={notificationState}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let button;
|
||||
if (isNarrow) {
|
||||
button = (
|
||||
<RovingAccessibleTooltipButton className={classes} title={tooltip} onClick={onClick} role="treeitem">
|
||||
<div className="mx_SpaceButton_selectionWrapper">
|
||||
{ avatar }
|
||||
{ notifBadge }
|
||||
{ children }
|
||||
</div>
|
||||
</RovingAccessibleTooltipButton>
|
||||
);
|
||||
} else {
|
||||
button = (
|
||||
<RovingAccessibleButton className={classes} onClick={onClick} role="treeitem">
|
||||
<div className="mx_SpaceButton_selectionWrapper">
|
||||
{ avatar }
|
||||
<span className="mx_SpaceButton_name">{ tooltip }</span>
|
||||
{ notifBadge }
|
||||
{ children }
|
||||
</div>
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
return <li className={classNames({
|
||||
"mx_SpaceItem": true,
|
||||
"collapsed": isNarrow,
|
||||
})}>
|
||||
{ button }
|
||||
</li>;
|
||||
};
|
||||
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuCheckbox,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../context_menus/IconizedContextMenu";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
|
||||
const useSpaces = (): [Room[], Room[], Room | null] => {
|
||||
const [invites, setInvites] = useState<Room[]>(SpaceStore.instance.invitedSpaces);
|
||||
useEventEmitter(SpaceStore.instance, UPDATE_INVITED_SPACES, setInvites);
|
||||
const [spaces, setSpaces] = useState<Room[]>(SpaceStore.instance.spacePanelSpaces);
|
||||
useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces);
|
||||
const [activeSpace, setActiveSpace] = useState<Room>(SpaceStore.instance.activeSpace);
|
||||
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace);
|
||||
const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
|
||||
return SpaceStore.instance.invitedSpaces;
|
||||
});
|
||||
const spaces = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, () => {
|
||||
return SpaceStore.instance.spacePanelSpaces;
|
||||
});
|
||||
const activeSpace = useEventEmitterState<Room>(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
|
||||
return SpaceStore.instance.activeSpace;
|
||||
});
|
||||
return [invites, spaces, activeSpace];
|
||||
};
|
||||
|
||||
|
@ -132,23 +63,108 @@ interface IInnerSpacePanelProps {
|
|||
setPanelCollapsed: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const HomeButtonContextMenu = ({ onFinished, ...props }: ComponentProps<typeof SpaceContextMenu>) => {
|
||||
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
|
||||
return SpaceStore.instance.allRoomsInHome;
|
||||
});
|
||||
|
||||
return <IconizedContextMenu
|
||||
{...props}
|
||||
onFinished={onFinished}
|
||||
className="mx_SpacePanel_contextMenu"
|
||||
compact
|
||||
>
|
||||
<div className="mx_SpacePanel_contextMenu_header">
|
||||
{ _t("Home") }
|
||||
</div>
|
||||
<IconizedContextMenuOptionList first>
|
||||
<IconizedContextMenuCheckbox
|
||||
iconClassName="mx_SpacePanel_noIcon"
|
||||
label={_t("Show all rooms")}
|
||||
active={allRoomsInHome}
|
||||
onClick={() => {
|
||||
SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.ACCOUNT, !allRoomsInHome);
|
||||
}}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
interface IHomeButtonProps {
|
||||
selected: boolean;
|
||||
isPanelCollapsed: boolean;
|
||||
}
|
||||
|
||||
const HomeButton = ({ selected, isPanelCollapsed }: IHomeButtonProps) => {
|
||||
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
|
||||
return SpaceStore.instance.allRoomsInHome;
|
||||
});
|
||||
|
||||
return <li className={classNames("mx_SpaceItem", {
|
||||
"collapsed": isPanelCollapsed,
|
||||
})}>
|
||||
<SpaceButton
|
||||
className="mx_SpaceButton_home"
|
||||
onClick={() => SpaceStore.instance.setActiveSpace(null)}
|
||||
selected={selected}
|
||||
label={allRoomsInHome ? _t("All rooms") : _t("Home")}
|
||||
notificationState={allRoomsInHome
|
||||
? RoomNotificationStateStore.instance.globalState
|
||||
: SpaceStore.instance.getNotificationState(HOME_SPACE)}
|
||||
isNarrow={isPanelCollapsed}
|
||||
ContextMenuComponent={HomeButtonContextMenu}
|
||||
contextMenuTooltip={_t("Options")}
|
||||
/>
|
||||
</li>;
|
||||
};
|
||||
|
||||
const CreateSpaceButton = ({
|
||||
isPanelCollapsed,
|
||||
setPanelCollapsed,
|
||||
}: Pick<IInnerSpacePanelProps, "isPanelCollapsed" | "setPanelCollapsed">) => {
|
||||
// We don't need the handle as we position the menu in a constant location
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<void>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanelCollapsed && menuDisplayed) {
|
||||
closeMenu();
|
||||
}
|
||||
}, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
let contextMenu = null;
|
||||
if (menuDisplayed) {
|
||||
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
|
||||
}
|
||||
|
||||
const onNewClick = menuDisplayed ? closeMenu : () => {
|
||||
if (!isPanelCollapsed) setPanelCollapsed(true);
|
||||
openMenu();
|
||||
};
|
||||
|
||||
return <li className={classNames("mx_SpaceItem", {
|
||||
"collapsed": isPanelCollapsed,
|
||||
})}>
|
||||
<SpaceButton
|
||||
className={classNames("mx_SpaceButton_new", {
|
||||
mx_SpaceButton_newCancel: menuDisplayed,
|
||||
})}
|
||||
label={menuDisplayed ? _t("Cancel") : _t("Create a space")}
|
||||
onClick={onNewClick}
|
||||
isNarrow={isPanelCollapsed}
|
||||
/>
|
||||
|
||||
{ contextMenu }
|
||||
</li>;
|
||||
};
|
||||
|
||||
// Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation
|
||||
const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCollapsed, setPanelCollapsed }) => {
|
||||
const [invites, spaces, activeSpace] = useSpaces();
|
||||
const activeSpaces = activeSpace ? [activeSpace] : [];
|
||||
|
||||
const homeNotificationState = SpaceStore.spacesTweakAllRoomsEnabled
|
||||
? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE);
|
||||
|
||||
return <div className="mx_SpaceTreeLevel">
|
||||
<SpaceButton
|
||||
className="mx_SpaceButton_home"
|
||||
onClick={() => SpaceStore.instance.setActiveSpace(null)}
|
||||
selected={!activeSpace}
|
||||
tooltip={SpaceStore.spacesTweakAllRoomsEnabled ? _t("All rooms") : _t("Home")}
|
||||
notificationState={homeNotificationState}
|
||||
isNarrow={isPanelCollapsed}
|
||||
/>
|
||||
<HomeButton selected={!activeSpace} isPanelCollapsed={isPanelCollapsed} />
|
||||
{ invites.map(s => (
|
||||
<SpaceItem
|
||||
key={s.roomId}
|
||||
|
@ -178,26 +194,13 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCo
|
|||
</Draggable>
|
||||
)) }
|
||||
{ children }
|
||||
<CreateSpaceButton isPanelCollapsed={isPanelCollapsed} setPanelCollapsed={setPanelCollapsed} />
|
||||
</div>;
|
||||
});
|
||||
|
||||
const SpacePanel = () => {
|
||||
// We don't need the handle as we position the menu in a constant location
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<void>();
|
||||
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanelCollapsed && menuDisplayed) {
|
||||
closeMenu();
|
||||
}
|
||||
}, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
let contextMenu = null;
|
||||
if (menuDisplayed) {
|
||||
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
|
||||
}
|
||||
|
||||
const onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
let handled = true;
|
||||
|
||||
|
@ -259,11 +262,6 @@ const SpacePanel = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const onNewClick = menuDisplayed ? closeMenu : () => {
|
||||
if (!isPanelCollapsed) setPanelCollapsed(true);
|
||||
openMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={result => {
|
||||
if (!result.destination) return; // dropped outside the list
|
||||
|
@ -291,15 +289,6 @@ const SpacePanel = () => {
|
|||
>
|
||||
{ provided.placeholder }
|
||||
</InnerSpacePanel>
|
||||
|
||||
<SpaceButton
|
||||
className={classNames("mx_SpaceButton_new", {
|
||||
mx_SpaceButton_newCancel: menuDisplayed,
|
||||
})}
|
||||
tooltip={menuDisplayed ? _t("Cancel") : _t("Create a space")}
|
||||
onClick={onNewClick}
|
||||
isNarrow={isPanelCollapsed}
|
||||
/>
|
||||
</AutoHideScrollbar>
|
||||
) }
|
||||
</Droppable>
|
||||
|
@ -308,7 +297,6 @@ const SpacePanel = () => {
|
|||
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
|
||||
title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")}
|
||||
/>
|
||||
{ contextMenu }
|
||||
</ul>
|
||||
) }
|
||||
</RovingTabIndexProvider>
|
||||
|
|
|
@ -21,12 +21,11 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
|
|||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
|
||||
import SpaceBasicSettings from "./SpaceBasicSettings";
|
||||
import { avatarUrlForRoom } from "../../../Avatar";
|
||||
import { IDialogProps } from "../dialogs/IDialogProps";
|
||||
import { getTopic } from "../elements/RoomTopic";
|
||||
import { defaultDispatcher } from "../../../dispatcher/dispatcher";
|
||||
import { leaveSpace } from "../../../utils/space";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
matrixClient: MatrixClient;
|
||||
|
@ -96,8 +95,6 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp
|
|||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
|
||||
<SpaceFeedbackPrompt />
|
||||
|
||||
<div className="mx_SettingsTab_section">
|
||||
<SpaceBasicSettings
|
||||
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
|
||||
|
@ -128,10 +125,7 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp
|
|||
<AccessibleButton
|
||||
kind="danger"
|
||||
onClick={() => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "leave_room",
|
||||
room_id: space.roomId,
|
||||
});
|
||||
leaveSpace(space);
|
||||
}}
|
||||
>
|
||||
{ _t("Leave Space") }
|
||||
|
|
|
@ -14,7 +14,14 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef, InputHTMLAttributes, LegacyRef } from "react";
|
||||
import React, {
|
||||
createRef,
|
||||
MouseEvent,
|
||||
InputHTMLAttributes,
|
||||
LegacyRef,
|
||||
ComponentProps,
|
||||
ComponentType,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
|
@ -23,31 +30,104 @@ import SpaceStore from "../../../stores/SpaceStore";
|
|||
import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore";
|
||||
import NotificationBadge from "../rooms/NotificationBadge";
|
||||
import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../context_menus/IconizedContextMenu";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
|
||||
import { toRightOf } from "../../structures/ContextMenu";
|
||||
import {
|
||||
shouldShowSpaceSettings,
|
||||
showAddExistingRooms,
|
||||
showCreateNewRoom,
|
||||
showSpaceInvite,
|
||||
showSpaceSettings,
|
||||
} from "../../../utils/space";
|
||||
import { toRightOf, useContextMenu } from "../../structures/ContextMenu";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager";
|
||||
import { NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
|
||||
|
||||
interface IButtonProps extends Omit<ComponentProps<typeof RovingAccessibleTooltipButton>, "title"> {
|
||||
space?: Room;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
label: string;
|
||||
contextMenuTooltip?: string;
|
||||
notificationState?: NotificationState;
|
||||
isNarrow?: boolean;
|
||||
avatarSize?: number;
|
||||
ContextMenuComponent?: ComponentType<ComponentProps<typeof SpaceContextMenu>>;
|
||||
onClick(ev: MouseEvent): void;
|
||||
}
|
||||
|
||||
export const SpaceButton: React.FC<IButtonProps> = ({
|
||||
space,
|
||||
className,
|
||||
selected,
|
||||
onClick,
|
||||
label,
|
||||
contextMenuTooltip,
|
||||
notificationState,
|
||||
avatarSize,
|
||||
isNarrow,
|
||||
children,
|
||||
ContextMenuComponent,
|
||||
...props
|
||||
}) => {
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLElement>();
|
||||
|
||||
let avatar = <div className="mx_SpaceButton_avatarPlaceholder"><div className="mx_SpaceButton_icon" /></div>;
|
||||
if (space) {
|
||||
avatar = <RoomAvatar width={avatarSize} height={avatarSize} room={space} />;
|
||||
}
|
||||
|
||||
let notifBadge;
|
||||
if (notificationState) {
|
||||
notifBadge = <div className="mx_SpacePanel_badgeContainer">
|
||||
<NotificationBadge
|
||||
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space || null)}
|
||||
forceCount={false}
|
||||
notification={notificationState}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let contextMenu: JSX.Element;
|
||||
if (menuDisplayed && ContextMenuComponent) {
|
||||
contextMenu = <ContextMenuComponent
|
||||
{...toRightOf(handle.current?.getBoundingClientRect(), 0)}
|
||||
space={space}
|
||||
onFinished={closeMenu}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RovingAccessibleTooltipButton
|
||||
{...props}
|
||||
className={classNames("mx_SpaceButton", className, {
|
||||
mx_SpaceButton_active: selected,
|
||||
mx_SpaceButton_hasMenuOpen: menuDisplayed,
|
||||
mx_SpaceButton_narrow: isNarrow,
|
||||
})}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
onContextMenu={openMenu}
|
||||
forceHide={!isNarrow || menuDisplayed}
|
||||
role="treeitem"
|
||||
inputRef={handle}
|
||||
>
|
||||
{ children }
|
||||
<div className="mx_SpaceButton_selectionWrapper">
|
||||
{ avatar }
|
||||
{ !isNarrow && <span className="mx_SpaceButton_name">{ label }</span> }
|
||||
{ notifBadge }
|
||||
|
||||
{ ContextMenuComponent && <ContextMenuTooltipButton
|
||||
className="mx_SpaceButton_menuButton"
|
||||
onClick={openMenu}
|
||||
title={contextMenuTooltip}
|
||||
isExpanded={menuDisplayed}
|
||||
/> }
|
||||
|
||||
{ contextMenu }
|
||||
</div>
|
||||
</RovingAccessibleTooltipButton>
|
||||
);
|
||||
};
|
||||
|
||||
interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
|
||||
space?: Room;
|
||||
|
@ -61,7 +141,6 @@ interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
|
|||
|
||||
interface IItemState {
|
||||
collapsed: boolean;
|
||||
contextMenuPosition: Pick<DOMRect, "right" | "top" | "height">;
|
||||
childSpaces: Room[];
|
||||
}
|
||||
|
||||
|
@ -81,7 +160,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
|
||||
this.state = {
|
||||
collapsed: collapsed,
|
||||
contextMenuPosition: null,
|
||||
childSpaces: this.childSpaces,
|
||||
};
|
||||
|
||||
|
@ -124,19 +202,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
evt.stopPropagation();
|
||||
};
|
||||
|
||||
private onContextMenu = (ev: React.MouseEvent) => {
|
||||
if (this.props.space.getMyMembership() !== "join") return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
contextMenuPosition: {
|
||||
right: ev.clientX,
|
||||
top: ev.clientY,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
let handled = true;
|
||||
const action = getKeyBindingsManager().getRoomListAction(ev);
|
||||
|
@ -180,188 +245,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
SpaceStore.instance.setActiveSpace(this.props.space);
|
||||
};
|
||||
|
||||
private onMenuOpenClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
|
||||
};
|
||||
|
||||
private onMenuClose = () => {
|
||||
this.setState({ contextMenuPosition: null });
|
||||
};
|
||||
|
||||
private onInviteClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showSpaceInvite(this.props.space);
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onSettingsClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showSpaceSettings(this.props.space);
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onLeaveClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
defaultDispatcher.dispatch({
|
||||
action: "leave_room",
|
||||
room_id: this.props.space.roomId,
|
||||
});
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onNewRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showCreateNewRoom(this.props.space);
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onAddExistingRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showAddExistingRooms(this.props.space);
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onMembersClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (!RoomViewStore.getRoomId()) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: this.props.space.roomId,
|
||||
}, true);
|
||||
}
|
||||
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.SpaceMemberList,
|
||||
refireParams: { space: this.props.space },
|
||||
});
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onExploreRoomsClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: this.props.space.roomId,
|
||||
});
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private renderContextMenu(): React.ReactElement {
|
||||
if (this.props.space.getMyMembership() !== "join") return null;
|
||||
|
||||
let contextMenu = null;
|
||||
if (this.state.contextMenuPosition) {
|
||||
const userId = this.context.getUserId();
|
||||
|
||||
let inviteOption;
|
||||
if (this.props.space.getJoinRule() === "public" || this.props.space.canInvite(userId)) {
|
||||
inviteOption = (
|
||||
<IconizedContextMenuOption
|
||||
className="mx_SpacePanel_contextMenu_inviteButton"
|
||||
iconClassName="mx_SpacePanel_iconInvite"
|
||||
label={_t("Invite people")}
|
||||
onClick={this.onInviteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let settingsOption;
|
||||
let leaveSection;
|
||||
if (shouldShowSpaceSettings(this.props.space)) {
|
||||
settingsOption = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconSettings"
|
||||
label={_t("Settings")}
|
||||
onClick={this.onSettingsClick}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
leaveSection = <IconizedContextMenuOptionList red first>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconLeave"
|
||||
label={_t("Leave space")}
|
||||
onClick={this.onLeaveClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>;
|
||||
}
|
||||
|
||||
const canAddRooms = this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
||||
let newRoomSection;
|
||||
if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
|
||||
newRoomSection = <IconizedContextMenuOptionList first>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconPlus"
|
||||
label={_t("Create new room")}
|
||||
onClick={this.onNewRoomClick}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconHash"
|
||||
label={_t("Add existing room")}
|
||||
onClick={this.onAddExistingRoomClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>;
|
||||
}
|
||||
|
||||
contextMenu = <IconizedContextMenu
|
||||
{...toRightOf(this.state.contextMenuPosition, 0)}
|
||||
onFinished={this.onMenuClose}
|
||||
className="mx_SpacePanel_contextMenu"
|
||||
compact
|
||||
>
|
||||
<div className="mx_SpacePanel_contextMenu_header">
|
||||
{ this.props.space.name }
|
||||
</div>
|
||||
<IconizedContextMenuOptionList first>
|
||||
{ inviteOption }
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconMembers"
|
||||
label={_t("Members")}
|
||||
onClick={this.onMembersClick}
|
||||
/>
|
||||
{ settingsOption }
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconExplore"
|
||||
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
|
||||
onClick={this.onExploreRoomsClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
{ newRoomSection }
|
||||
{ leaveSection }
|
||||
</IconizedContextMenu>;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_SpaceButton_menuButton"
|
||||
onClick={this.onMenuOpenClick}
|
||||
title={_t("Space options")}
|
||||
isExpanded={!!this.state.contextMenuPosition}
|
||||
/>
|
||||
{ contextMenu }
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef,
|
||||
|
@ -369,7 +252,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
|
||||
const collapsed = this.isCollapsed;
|
||||
|
||||
const isActive = activeSpaces.includes(space);
|
||||
const itemClasses = classNames(this.props.className, {
|
||||
"mx_SpaceItem": true,
|
||||
"mx_SpaceItem_narrow": isPanelCollapsed,
|
||||
|
@ -378,12 +260,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
});
|
||||
|
||||
const isInvite = space.getMyMembership() === "invite";
|
||||
const classes = classNames("mx_SpaceButton", {
|
||||
mx_SpaceButton_active: isActive,
|
||||
mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition,
|
||||
mx_SpaceButton_narrow: isPanelCollapsed,
|
||||
mx_SpaceButton_invite: isInvite,
|
||||
});
|
||||
|
||||
const notificationState = isInvite
|
||||
? StaticNotificationState.forSymbol("!", NotificationColor.Red)
|
||||
: SpaceStore.instance.getNotificationState(space.roomId);
|
||||
|
@ -398,19 +275,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
/>;
|
||||
}
|
||||
|
||||
let notifBadge;
|
||||
if (notificationState) {
|
||||
notifBadge = <div className="mx_SpacePanel_badgeContainer">
|
||||
<NotificationBadge
|
||||
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
|
||||
forceCount={false}
|
||||
notification={notificationState}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const avatarSize = isNested ? 24 : 32;
|
||||
|
||||
const toggleCollapseButton = this.state.childSpaces?.length ?
|
||||
<AccessibleButton
|
||||
className="mx_SpaceButton_toggleCollapse"
|
||||
|
@ -421,25 +285,23 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
|
||||
return (
|
||||
<li {...otherProps} className={itemClasses} ref={innerRef}>
|
||||
<RovingAccessibleTooltipButton
|
||||
className={classes}
|
||||
title={space.name}
|
||||
<SpaceButton
|
||||
space={space}
|
||||
className={isInvite ? "mx_SpaceButton_invite" : undefined}
|
||||
selected={activeSpaces.includes(space)}
|
||||
label={space.name}
|
||||
contextMenuTooltip={_t("Space options")}
|
||||
notificationState={notificationState}
|
||||
isNarrow={isPanelCollapsed}
|
||||
avatarSize={isNested ? 24 : 32}
|
||||
onClick={this.onClick}
|
||||
onContextMenu={this.onContextMenu}
|
||||
forceHide={!isPanelCollapsed || !!this.state.contextMenuPosition}
|
||||
role="treeitem"
|
||||
aria-expanded={!collapsed}
|
||||
inputRef={this.buttonRef}
|
||||
onKeyDown={this.onKeyDown}
|
||||
aria-expanded={!collapsed}
|
||||
ContextMenuComponent={this.props.space.getMyMembership() === "join"
|
||||
? SpaceContextMenu : undefined}
|
||||
>
|
||||
{ toggleCollapseButton }
|
||||
<div className="mx_SpaceButton_selectionWrapper">
|
||||
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
|
||||
{ !isPanelCollapsed && <span className="mx_SpaceButton_name">{ space.name }</span> }
|
||||
{ notifBadge }
|
||||
{ this.renderContextMenu() }
|
||||
</div>
|
||||
</RovingAccessibleTooltipButton>
|
||||
</SpaceButton>
|
||||
|
||||
{ childItems }
|
||||
</li>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
|
@ -15,7 +16,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import IncomingCallBox from './IncomingCallBox';
|
||||
import CallPreview from './CallPreview';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
|
@ -31,7 +31,6 @@ interface IState {
|
|||
export default class CallContainer extends React.PureComponent<IProps, IState> {
|
||||
public render() {
|
||||
return <div className="mx_CallContainer">
|
||||
<IncomingCallBox />
|
||||
<CallPreview />
|
||||
</div>;
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ const PIP_VIEW_WIDTH = 336;
|
|||
const PIP_VIEW_HEIGHT = 232;
|
||||
|
||||
const MOVING_AMT = 0.2;
|
||||
const SNAPPING_AMT = 0.05;
|
||||
const SNAPPING_AMT = 0.1;
|
||||
|
||||
const PADDING = {
|
||||
top: 58,
|
||||
|
|
|
@ -23,11 +23,16 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
|||
import { _t, _td } from '../../../languageHandler';
|
||||
import VideoFeed from './VideoFeed';
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import { CallState, CallType, MatrixCall, CallEvent } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import { CallEvent, CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import classNames from 'classnames';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../../Keyboard';
|
||||
import { alwaysAboveLeftOf, alwaysAboveRightOf, ChevronFace, ContextMenuButton } from '../../structures/ContextMenu';
|
||||
import {
|
||||
alwaysAboveLeftOf,
|
||||
alwaysAboveRightOf,
|
||||
ChevronFace,
|
||||
ContextMenuTooltipButton,
|
||||
} from '../../structures/ContextMenu';
|
||||
import CallContextMenu from '../context_menus/CallContextMenu';
|
||||
import { avatarUrlForMember } from '../../../Avatar';
|
||||
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
|
||||
|
@ -37,6 +42,8 @@ import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker
|
|||
import Modal from '../../../Modal';
|
||||
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
|
||||
import CallViewSidebar from './CallViewSidebar';
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { Alignment } from "../elements/Tooltip";
|
||||
|
||||
interface IProps {
|
||||
// The call for us to display
|
||||
|
@ -75,6 +82,8 @@ interface IState {
|
|||
sidebarShown: boolean;
|
||||
}
|
||||
|
||||
const tooltipYOffset = -24;
|
||||
|
||||
function getFullScreenElement() {
|
||||
return (
|
||||
document.fullscreenElement ||
|
||||
|
@ -478,9 +487,12 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
let vidMuteButton;
|
||||
if (this.props.call.type === CallType.Video) {
|
||||
vidMuteButton = (
|
||||
<AccessibleButton
|
||||
<AccessibleTooltipButton
|
||||
className={vidClasses}
|
||||
onClick={this.onVidMuteClick}
|
||||
title={this.state.vidMuted ? _t("Start the camera") : _t("Stop the camera")}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={tooltipYOffset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -495,9 +507,15 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
this.props.call.state === CallState.Connected
|
||||
) {
|
||||
screensharingButton = (
|
||||
<AccessibleButton
|
||||
<AccessibleTooltipButton
|
||||
className={screensharingClasses}
|
||||
onClick={this.onScreenshareClick}
|
||||
title={this.state.screensharing
|
||||
? _t("Stop sharing your screen")
|
||||
: _t("Start sharing your screen")
|
||||
}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={tooltipYOffset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -517,6 +535,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
<AccessibleButton
|
||||
className={sidebarButtonClasses}
|
||||
onClick={this.onToggleSidebar}
|
||||
aria-label={this.state.sidebarShown ? _t("Hide sidebar") : _t("Show sidebar")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -525,36 +544,79 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
let contextMenuButton;
|
||||
if (this.state.callState === CallState.Connected) {
|
||||
contextMenuButton = (
|
||||
<ContextMenuButton
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
|
||||
onClick={this.onMoreClick}
|
||||
inputRef={this.contextMenuButton}
|
||||
isExpanded={this.state.showMoreMenu}
|
||||
title={_t("More")}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={tooltipYOffset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
let dialpadButton;
|
||||
if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) {
|
||||
dialpadButton = (
|
||||
<ContextMenuButton
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
|
||||
inputRef={this.dialpadButton}
|
||||
onClick={this.onDialpadClick}
|
||||
isExpanded={this.state.showDialpad}
|
||||
title={_t("Dialpad")}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={tooltipYOffset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let dialPad;
|
||||
if (this.state.showDialpad) {
|
||||
dialPad = <DialpadContextMenu
|
||||
{...alwaysAboveRightOf(
|
||||
this.dialpadButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
// We mount the context menus as a as a child typically in order to include the
|
||||
// context menus when fullscreening the call content.
|
||||
// However, this does not work as well when the call is embedded in a
|
||||
// picture-in-picture frame. Thus, only mount as child when we are *not* in PiP.
|
||||
mountAsChild={!this.props.pipMode}
|
||||
onFinished={this.closeDialpad}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
let contextMenu;
|
||||
if (this.state.showMoreMenu) {
|
||||
contextMenu = <CallContextMenu
|
||||
{...alwaysAboveLeftOf(
|
||||
this.contextMenuButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
mountAsChild={!this.props.pipMode}
|
||||
onFinished={this.closeContextMenu}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={callControlsClasses}
|
||||
onMouseEnter={this.onCallControlsMouseEnter}
|
||||
onMouseLeave={this.onCallControlsMouseLeave}
|
||||
>
|
||||
{ dialPad }
|
||||
{ contextMenu }
|
||||
{ dialpadButton }
|
||||
<AccessibleButton
|
||||
<AccessibleTooltipButton
|
||||
className={micClasses}
|
||||
onClick={this.onMicMuteClick}
|
||||
title={this.state.micMuted ? _t("Unmute the microphone") : _t("Mute the microphone")}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={tooltipYOffset}
|
||||
/>
|
||||
{ vidMuteButton }
|
||||
<div className={micCacheClasses} />
|
||||
|
@ -562,9 +624,12 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
{ screensharingButton }
|
||||
{ sidebarButton }
|
||||
{ contextMenuButton }
|
||||
<AccessibleButton
|
||||
<AccessibleTooltipButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
|
||||
onClick={this.onHangupClick}
|
||||
title={_t("Hangup")}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={tooltipYOffset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -789,7 +854,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
let fullScreenButton;
|
||||
if (!this.props.pipMode) {
|
||||
fullScreenButton = (
|
||||
<div
|
||||
<AccessibleTooltipButton
|
||||
className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
|
||||
onClick={this.onFullscreenClick}
|
||||
title={_t("Fill Screen")}
|
||||
|
@ -799,7 +864,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
|
||||
let expandButton;
|
||||
if (this.props.pipMode) {
|
||||
expandButton = <div
|
||||
expandButton = <AccessibleTooltipButton
|
||||
className="mx_CallView_header_button mx_CallView_header_button_expand"
|
||||
onClick={this.onExpandClick}
|
||||
title={_t("Return to call")}
|
||||
|
@ -858,37 +923,9 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
myClassName = 'mx_CallView_pip';
|
||||
}
|
||||
|
||||
let dialPad;
|
||||
if (this.state.showDialpad) {
|
||||
dialPad = <DialpadContextMenu
|
||||
{...alwaysAboveRightOf(
|
||||
this.dialpadButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
onFinished={this.closeDialpad}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
let contextMenu;
|
||||
if (this.state.showMoreMenu) {
|
||||
contextMenu = <CallContextMenu
|
||||
{...alwaysAboveLeftOf(
|
||||
this.contextMenuButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
onFinished={this.closeContextMenu}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <div className={"mx_CallView " + myClassName}>
|
||||
{ header }
|
||||
{ contentView }
|
||||
{ dialPad }
|
||||
{ contextMenu }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,176 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
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 from 'react';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { ActionPayload } from '../../../dispatcher/payloads';
|
||||
import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
|
||||
import RoomAvatar from '../avatars/RoomAvatar';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import { CallState } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
||||
interface IState {
|
||||
incomingCall: any;
|
||||
silenced: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.voip.IncomingCallBox")
|
||||
export default class IncomingCallBox extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
this.state = {
|
||||
incomingCall: null,
|
||||
silenced: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
|
||||
};
|
||||
|
||||
public componentWillUnmount() {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
CallHandler.sharedInstance().removeListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
switch (payload.action) {
|
||||
case 'call_state': {
|
||||
const call = CallHandler.sharedInstance().getCallForRoom(payload.room_id);
|
||||
if (call && call.state === CallState.Ringing) {
|
||||
this.setState({
|
||||
incomingCall: call,
|
||||
silenced: false, // Reset silenced state for new call
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
incomingCall: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onSilencedCallsChanged = () => {
|
||||
const callId = this.state.incomingCall?.callId;
|
||||
if (!callId) return;
|
||||
this.setState({ silenced: CallHandler.sharedInstance().isCallSilenced(callId) });
|
||||
};
|
||||
|
||||
private onAnswerClick: React.MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
dis.dispatch({
|
||||
action: 'answer',
|
||||
room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall),
|
||||
});
|
||||
};
|
||||
|
||||
private onRejectClick: React.MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
dis.dispatch({
|
||||
action: 'reject',
|
||||
room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall),
|
||||
});
|
||||
};
|
||||
|
||||
private onSilenceClick: React.MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
const callId = this.state.incomingCall.callId;
|
||||
this.state.silenced ?
|
||||
CallHandler.sharedInstance().unSilenceCall(callId):
|
||||
CallHandler.sharedInstance().silenceCall(callId);
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (!this.state.incomingCall) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let room = null;
|
||||
if (this.state.incomingCall) {
|
||||
room = MatrixClientPeg.get().getRoom(CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall));
|
||||
}
|
||||
|
||||
const caller = room ? room.name : _t("Unknown caller");
|
||||
|
||||
let incomingCallText = null;
|
||||
if (this.state.incomingCall) {
|
||||
if (this.state.incomingCall.type === "voice") {
|
||||
incomingCallText = _t("Incoming voice call");
|
||||
} else if (this.state.incomingCall.type === "video") {
|
||||
incomingCallText = _t("Incoming video call");
|
||||
} else {
|
||||
incomingCallText = _t("Incoming call");
|
||||
}
|
||||
}
|
||||
|
||||
const silenceClass = classNames({
|
||||
"mx_IncomingCallBox_iconButton": true,
|
||||
"mx_IncomingCallBox_unSilence": this.state.silenced,
|
||||
"mx_IncomingCallBox_silence": !this.state.silenced,
|
||||
});
|
||||
|
||||
return <div className="mx_IncomingCallBox">
|
||||
<div className="mx_IncomingCallBox_CallerInfo">
|
||||
<RoomAvatar
|
||||
room={room}
|
||||
height={32}
|
||||
width={32}
|
||||
/>
|
||||
<div>
|
||||
<h1>{ caller }</h1>
|
||||
<p>{ incomingCallText }</p>
|
||||
</div>
|
||||
<AccessibleTooltipButton
|
||||
className={silenceClass}
|
||||
onClick={this.onSilenceClick}
|
||||
title={this.state.silenced ? _t("Sound on"): _t("Silence call")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_IncomingCallBox_buttons">
|
||||
<AccessibleButton
|
||||
className="mx_IncomingCallBox_decline"
|
||||
onClick={this.onRejectClick}
|
||||
kind="danger"
|
||||
>
|
||||
{ _t("Decline") }
|
||||
</AccessibleButton>
|
||||
<div className="mx_IncomingCallBox_spacer" />
|
||||
<AccessibleButton
|
||||
className="mx_IncomingCallBox_accept"
|
||||
onClick={this.onAnswerClick}
|
||||
kind="primary"
|
||||
>
|
||||
{ _t("Accept") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -22,6 +22,7 @@ import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
|
|||
import { logger } from 'matrix-js-sdk/src/logger';
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
|
||||
|
||||
interface IProps {
|
||||
call: MatrixCall;
|
||||
|
@ -47,7 +48,7 @@ interface IState {
|
|||
}
|
||||
|
||||
@replaceableComponent("views.voip.VideoFeed")
|
||||
export default class VideoFeed extends React.Component<IProps, IState> {
|
||||
export default class VideoFeed extends React.PureComponent<IProps, IState> {
|
||||
private element: HTMLVideoElement;
|
||||
|
||||
constructor(props: IProps) {
|
||||
|
@ -68,8 +69,15 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
this.updateFeed(this.props.feed, null);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: IProps) {
|
||||
componentDidUpdate(prevProps: IProps, prevState: IState) {
|
||||
this.updateFeed(prevProps.feed, this.props.feed);
|
||||
// If the mutes state has changed, we try to playMedia()
|
||||
if (
|
||||
prevState.videoMuted !== this.state.videoMuted ||
|
||||
prevProps.feed.stream !== this.props.feed.stream
|
||||
) {
|
||||
this.playMedia();
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: IProps) {
|
||||
|
@ -94,10 +102,12 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
|
||||
if (oldFeed) {
|
||||
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
|
||||
this.props.feed.removeListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
|
||||
this.stopMedia();
|
||||
}
|
||||
if (newFeed) {
|
||||
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
|
||||
this.props.feed.addListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
|
||||
this.playMedia();
|
||||
}
|
||||
}
|
||||
|
@ -143,7 +153,13 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
audioMuted: this.props.feed.isAudioMuted(),
|
||||
videoMuted: this.props.feed.isVideoMuted(),
|
||||
});
|
||||
this.playMedia();
|
||||
};
|
||||
|
||||
private onMuteStateChanged = () => {
|
||||
this.setState({
|
||||
audioMuted: this.props.feed.isAudioMuted(),
|
||||
videoMuted: this.props.feed.isVideoMuted(),
|
||||
});
|
||||
};
|
||||
|
||||
private onResize = (e) => {
|
||||
|
@ -153,39 +169,59 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
render() {
|
||||
const videoClasses = {
|
||||
mx_VideoFeed: true,
|
||||
const { pipMode, primary, feed } = this.props;
|
||||
|
||||
const wrapperClasses = classnames("mx_VideoFeed", {
|
||||
mx_VideoFeed_voice: this.state.videoMuted,
|
||||
mx_VideoFeed_video: !this.state.videoMuted,
|
||||
mx_VideoFeed_mirror: (
|
||||
this.props.feed.isLocal() &&
|
||||
SettingsStore.getValue('VideoView.flipVideoHorizontally')
|
||||
),
|
||||
};
|
||||
});
|
||||
const micIconClasses = classnames("mx_VideoFeed_mic", {
|
||||
mx_VideoFeed_mic_muted: this.state.audioMuted,
|
||||
mx_VideoFeed_mic_unmuted: !this.state.audioMuted,
|
||||
});
|
||||
|
||||
const { pipMode, primary } = this.props;
|
||||
let micIcon;
|
||||
if (feed.purpose !== SDPStreamMetadataPurpose.Screenshare && !pipMode) {
|
||||
micIcon = (
|
||||
<div className={micIconClasses} />
|
||||
);
|
||||
}
|
||||
|
||||
let content;
|
||||
if (this.state.videoMuted) {
|
||||
const member = this.props.feed.getMember();
|
||||
|
||||
let avatarSize;
|
||||
if (pipMode && primary) avatarSize = 76;
|
||||
else if (pipMode && !primary) avatarSize = 16;
|
||||
else if (!pipMode && primary) avatarSize = 160;
|
||||
else; // TBD
|
||||
|
||||
return (
|
||||
<div className={classnames(videoClasses)}>
|
||||
<MemberAvatar
|
||||
member={member}
|
||||
height={avatarSize}
|
||||
width={avatarSize}
|
||||
/>
|
||||
</div>
|
||||
content =(
|
||||
<MemberAvatar
|
||||
member={member}
|
||||
height={avatarSize}
|
||||
width={avatarSize}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<video className={classnames(videoClasses)} ref={this.setElementRef} />
|
||||
const videoClasses = classnames("mx_VideoFeed_video", {
|
||||
mx_VideoFeed_video_mirror: (
|
||||
this.props.feed.isLocal() &&
|
||||
this.props.feed.purpose === SDPStreamMetadataPurpose.Usermedia &&
|
||||
SettingsStore.getValue('VideoView.flipVideoHorizontally')
|
||||
),
|
||||
});
|
||||
|
||||
content= (
|
||||
<video className={videoClasses} ref={this.setElementRef} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClasses}>
|
||||
{ micIcon }
|
||||
{ content }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,9 +18,15 @@ limitations under the License.
|
|||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { EventType, RoomCreateTypeField, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
|
||||
import { JoinRule, Preset, RestrictedAllowType, Visibility } from "matrix-js-sdk/src/@types/partials";
|
||||
import {
|
||||
HistoryVisibility,
|
||||
JoinRule,
|
||||
Preset,
|
||||
RestrictedAllowType,
|
||||
Visibility,
|
||||
} from "matrix-js-sdk/src/@types/partials";
|
||||
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import Modal from './Modal';
|
||||
|
@ -52,6 +58,9 @@ export interface IOpts {
|
|||
inlineErrors?: boolean;
|
||||
andView?: boolean;
|
||||
associatedWithCommunity?: string;
|
||||
avatar?: File | string; // will upload if given file, else mxcUrl is needed
|
||||
roomType?: RoomType | string;
|
||||
historyVisibility?: HistoryVisibility;
|
||||
parentSpace?: Room;
|
||||
joinRule?: JoinRule;
|
||||
}
|
||||
|
@ -112,6 +121,13 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
|
|||
createOpts.is_direct = true;
|
||||
}
|
||||
|
||||
if (opts.roomType) {
|
||||
createOpts.creation_content = {
|
||||
...createOpts.creation_content,
|
||||
[RoomCreateTypeField]: opts.roomType,
|
||||
};
|
||||
}
|
||||
|
||||
// By default, view the room after creating it
|
||||
if (opts.andView === undefined) {
|
||||
opts.andView = true;
|
||||
|
@ -144,12 +160,11 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
|
|||
|
||||
if (opts.parentSpace) {
|
||||
createOpts.initial_state.push(makeSpaceParentEvent(opts.parentSpace, true));
|
||||
createOpts.initial_state.push({
|
||||
type: EventType.RoomHistoryVisibility,
|
||||
content: {
|
||||
"history_visibility": createOpts.preset === Preset.PublicChat ? "world_readable" : "invited",
|
||||
},
|
||||
});
|
||||
if (!opts.historyVisibility) {
|
||||
opts.historyVisibility = createOpts.preset === Preset.PublicChat
|
||||
? HistoryVisibility.WorldReadable
|
||||
: HistoryVisibility.Invited;
|
||||
}
|
||||
|
||||
if (opts.joinRule === JoinRule.Restricted) {
|
||||
if (SpaceStore.instance.restrictedJoinRuleSupport?.preferred) {
|
||||
|
@ -169,13 +184,35 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
|
|||
}
|
||||
}
|
||||
|
||||
if (opts.joinRule !== JoinRule.Restricted) {
|
||||
// we handle the restricted join rule in the parentSpace handling block above
|
||||
if (opts.joinRule && opts.joinRule !== JoinRule.Restricted) {
|
||||
createOpts.initial_state.push({
|
||||
type: EventType.RoomJoinRules,
|
||||
content: { join_rule: opts.joinRule },
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.avatar) {
|
||||
let url = opts.avatar;
|
||||
if (opts.avatar instanceof File) {
|
||||
url = await client.uploadContent(opts.avatar);
|
||||
}
|
||||
|
||||
createOpts.initial_state.push({
|
||||
type: EventType.RoomAvatar,
|
||||
content: { url },
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.historyVisibility) {
|
||||
createOpts.initial_state.push({
|
||||
type: EventType.RoomHistoryVisibility,
|
||||
content: {
|
||||
"history_visibility": opts.historyVisibility,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let modal;
|
||||
if (opts.spinner) modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
|
||||
|
||||
|
|
53
src/customisations/WidgetVariables.ts
Normal file
53
src/customisations/WidgetVariables.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Populate this class with the details of your customisations when copying it.
|
||||
import { ITemplateParams } from "matrix-widget-api";
|
||||
|
||||
/**
|
||||
* Provides a partial set of the variables needed to render any widget. If
|
||||
* variables are missing or not provided then they will be filled with the
|
||||
* application-determined defaults.
|
||||
*
|
||||
* This will not be called until after isReady() resolves.
|
||||
* @returns {Partial<Omit<ITemplateParams, "widgetRoomId">>} The variables.
|
||||
*/
|
||||
function provideVariables(): Partial<Omit<ITemplateParams, "widgetRoomId">> {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves to whether or not the customisation point is ready for variables
|
||||
* to be provided. This will block widgets being rendered.
|
||||
* @returns {Promise<boolean>} Resolves when ready.
|
||||
*/
|
||||
async function isReady(): Promise<void> {
|
||||
return; // default no waiting
|
||||
}
|
||||
|
||||
// This interface summarises all available customisation points and also marks
|
||||
// them all as optional. This allows customisers to only define and export the
|
||||
// customisations they need while still maintaining type safety.
|
||||
export interface IWidgetVariablesCustomisations {
|
||||
provideVariables?: typeof provideVariables;
|
||||
|
||||
// If not provided, the app will assume that the customisation is always ready.
|
||||
isReady?: typeof isReady;
|
||||
}
|
||||
|
||||
// A real customisation module will define and export one or more of the
|
||||
// customisation points that make up the interface above.
|
||||
export const WidgetVariableCustomisations: IWidgetVariablesCustomisations = {};
|
|
@ -193,4 +193,16 @@ export enum Action {
|
|||
* Switches space. Should be used with SwitchSpacePayload.
|
||||
*/
|
||||
SwitchSpace = "switch_space",
|
||||
|
||||
/**
|
||||
* Signals to the visible space hierarchy that a change has occurred an that it should refresh.
|
||||
*/
|
||||
UpdateSpaceHierarchy = "update_space_hierarchy",
|
||||
|
||||
/**
|
||||
* Fires when a monitored setting is updated,
|
||||
* see SettingsStore::monitorSetting for more details.
|
||||
* Should be used with SettingUpdatedPayload.
|
||||
*/
|
||||
SettingUpdated = "setting_updated",
|
||||
}
|
||||
|
|
29
src/dispatcher/payloads/SettingUpdatedPayload.ts
Normal file
29
src/dispatcher/payloads/SettingUpdatedPayload.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
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 { ActionPayload } from "../payloads";
|
||||
import { Action } from "../actions";
|
||||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
|
||||
export interface SettingUpdatedPayload extends ActionPayload {
|
||||
action: Action.SettingUpdated;
|
||||
|
||||
settingName: string;
|
||||
roomId: string;
|
||||
level: SettingLevel;
|
||||
newValueAtLevel: SettingLevel;
|
||||
newValue: any;
|
||||
}
|
16
src/emoji.ts
16
src/emoji.ts
|
@ -35,6 +35,16 @@ export const EMOTICON_TO_EMOJI = new Map<string, IEmoji>();
|
|||
|
||||
export const getEmojiFromUnicode = unicode => UNICODE_TO_EMOJI.get(stripVariation(unicode));
|
||||
|
||||
const isRegionalIndicator = (x: string): boolean => {
|
||||
// First verify that the string is a single character. We use Array.from
|
||||
// to make sure we count by characters, not UTF-8 code units.
|
||||
return Array.from(x).length === 1 &&
|
||||
// Next verify that the character is within the code point range for
|
||||
// regional indicators.
|
||||
// http://unicode.org/charts/PDF/Unicode-6.0/U60-1F100.pdf
|
||||
x >= '\u{1f1e6}' && x <= '\u{1f1ff}';
|
||||
};
|
||||
|
||||
const EMOJIBASE_GROUP_ID_TO_CATEGORY = [
|
||||
"people", // smileys
|
||||
"people", // actually people
|
||||
|
@ -72,7 +82,11 @@ export const EMOJI: IEmoji[] = EMOJIBASE.map((emojiData: Omit<IEmoji, "shortcode
|
|||
shortcodes: typeof shortcodeData === "string" ? [shortcodeData] : shortcodeData,
|
||||
};
|
||||
|
||||
const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group];
|
||||
// We manually include regional indicators in the symbols group, since
|
||||
// Emojibase intentionally leaves them uncategorized
|
||||
const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group] ??
|
||||
(isRegionalIndicator(emoji.unicode) ? "symbols" : null);
|
||||
|
||||
if (DATA_BY_CATEGORY.hasOwnProperty(categoryId)) {
|
||||
DATA_BY_CATEGORY[categoryId].push(emoji);
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import type { EventEmitter } from "events";
|
||||
|
||||
type Handler = (...args: any[]) => void;
|
||||
|
@ -48,3 +48,14 @@ export const useEventEmitter = (emitter: EventEmitter, eventName: string | symbo
|
|||
[eventName, emitter], // Re-run if eventName or emitter changes
|
||||
);
|
||||
};
|
||||
|
||||
type Mapper<T> = (...args: any[]) => T;
|
||||
|
||||
export const useEventEmitterState = <T>(emitter: EventEmitter, eventName: string | symbol, fn: Mapper<T>): T => {
|
||||
const [value, setValue] = useState<T>(fn());
|
||||
const handler = useCallback((...args: any[]) => {
|
||||
setValue(fn(...args));
|
||||
}, [fn]);
|
||||
useEventEmitter(emitter, eventName, handler);
|
||||
return value;
|
||||
};
|
||||
|
|
|
@ -64,8 +64,6 @@
|
|||
"Unable to transfer call": "Unable to transfer call",
|
||||
"Transfer Failed": "Transfer Failed",
|
||||
"Failed to transfer call": "Failed to transfer call",
|
||||
"Call in Progress": "Call in Progress",
|
||||
"A call is currently being placed!": "A call is currently being placed!",
|
||||
"Permission Required": "Permission Required",
|
||||
"You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room",
|
||||
"End conference": "End conference",
|
||||
|
@ -487,6 +485,11 @@
|
|||
"Converts the room to a DM": "Converts the room to a DM",
|
||||
"Converts the DM to a room": "Converts the DM to a room",
|
||||
"Displays action": "Displays action",
|
||||
"Someone": "Someone",
|
||||
"%(senderName)s placed a voice call.": "%(senderName)s placed a voice call.",
|
||||
"%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s placed a voice call. (not supported by this browser)",
|
||||
"%(senderName)s placed a video call.": "%(senderName)s placed a video call.",
|
||||
"%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s placed a video call. (not supported by this browser)",
|
||||
"%(targetName)s accepted the invitation for %(displayName)s": "%(targetName)s accepted the invitation for %(displayName)s",
|
||||
"%(targetName)s accepted an invitation": "%(targetName)s accepted an invitation",
|
||||
"%(senderName)s invited %(targetName)s": "%(senderName)s invited %(targetName)s",
|
||||
|
@ -536,7 +539,6 @@
|
|||
"%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s changed the main and alternative addresses for this room.",
|
||||
"%(senderName)s changed the addresses for this room.": "%(senderName)s changed the addresses for this room.",
|
||||
"%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.",
|
||||
"Someone": "Someone",
|
||||
"%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.",
|
||||
"%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s made future room history visible to all room members, from the point they are invited.",
|
||||
"%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s made future room history visible to all room members, from the point they joined.",
|
||||
|
@ -730,6 +732,13 @@
|
|||
"Notifications": "Notifications",
|
||||
"Enable desktop notifications": "Enable desktop notifications",
|
||||
"Enable": "Enable",
|
||||
"Unknown caller": "Unknown caller",
|
||||
"Voice call": "Voice call",
|
||||
"Video call": "Video call",
|
||||
"Decline": "Decline",
|
||||
"Accept": "Accept",
|
||||
"Sound on": "Sound on",
|
||||
"Silence call": "Silence call",
|
||||
"Use app for a better experience": "Use app for a better experience",
|
||||
"Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.",
|
||||
"Use app": "Use app",
|
||||
|
@ -796,12 +805,7 @@
|
|||
"You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "You can leave the beta any time from settings or tapping on a beta badge, like the one above.",
|
||||
"Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.",
|
||||
"Your feedback will help make spaces better. The more detail you can go into, the better.": "Your feedback will help make spaces better. The more detail you can go into, the better.",
|
||||
"Show all rooms in Home": "Show all rooms in Home",
|
||||
"Show people in spaces": "Show people in spaces",
|
||||
"If disabled, you can still add Direct Messages to Personal Spaces. If enabled, you'll automatically see everyone who is a member of the Space.": "If disabled, you can still add Direct Messages to Personal Spaces. If enabled, you'll automatically see everyone who is a member of the Space.",
|
||||
"Show notification badges for People in Spaces": "Show notification badges for People in Spaces",
|
||||
"Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode",
|
||||
"Send and receive voice messages": "Send and receive voice messages",
|
||||
"Render LaTeX maths in messages": "Render LaTeX maths in messages",
|
||||
"Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.",
|
||||
"Message Pinning": "Message Pinning",
|
||||
|
@ -814,6 +818,7 @@
|
|||
"Show message previews for reactions in DMs": "Show message previews for reactions in DMs",
|
||||
"Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms",
|
||||
"Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices",
|
||||
"Send pseudonymous analytics data": "Send pseudonymous analytics data",
|
||||
"Enable advanced debugging for the room list": "Enable advanced debugging for the room list",
|
||||
"Show info about bridges in room settings": "Show info about bridges in room settings",
|
||||
"New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)",
|
||||
|
@ -842,6 +847,7 @@
|
|||
"Use Ctrl + F to search timeline": "Use Ctrl + F to search timeline",
|
||||
"Use Command + Enter to send a message": "Use Command + Enter to send a message",
|
||||
"Use Ctrl + Enter to send a message": "Use Ctrl + Enter to send a message",
|
||||
"Surround selected text when typing special characters": "Surround selected text when typing special characters",
|
||||
"Automatically replace plain text Emoji": "Automatically replace plain text Emoji",
|
||||
"Mirror local video feed": "Mirror local video feed",
|
||||
"Enable Community Filter Panel": "Enable Community Filter Panel",
|
||||
|
@ -870,6 +876,8 @@
|
|||
"Manually verify all remote sessions": "Manually verify all remote sessions",
|
||||
"IRC display name width": "IRC display name width",
|
||||
"Show chat effects (animations when receiving e.g. confetti)": "Show chat effects (animations when receiving e.g. confetti)",
|
||||
"Show all rooms in Home": "Show all rooms in Home",
|
||||
"All rooms you're in will appear in Home.": "All rooms you're in will appear in Home.",
|
||||
"Collecting app version information": "Collecting app version information",
|
||||
"Collecting logs": "Collecting logs",
|
||||
"Uploading logs": "Uploading logs",
|
||||
|
@ -896,6 +904,17 @@
|
|||
"sends snowfall": "sends snowfall",
|
||||
"Sends the given message with a space themed effect": "Sends the given message with a space themed effect",
|
||||
"sends space invaders": "sends space invaders",
|
||||
"Start the camera": "Start the camera",
|
||||
"Stop the camera": "Stop the camera",
|
||||
"Stop sharing your screen": "Stop sharing your screen",
|
||||
"Start sharing your screen": "Start sharing your screen",
|
||||
"Hide sidebar": "Hide sidebar",
|
||||
"Show sidebar": "Show sidebar",
|
||||
"More": "More",
|
||||
"Dialpad": "Dialpad",
|
||||
"Unmute the microphone": "Unmute the microphone",
|
||||
"Mute the microphone": "Mute the microphone",
|
||||
"Hangup": "Hangup",
|
||||
"unknown person": "unknown person",
|
||||
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
|
||||
"You held the call <a>Switch</a>": "You held the call <a>Switch</a>",
|
||||
|
@ -911,14 +930,6 @@
|
|||
"Fill Screen": "Fill Screen",
|
||||
"Return to call": "Return to call",
|
||||
"%(name)s on hold": "%(name)s on hold",
|
||||
"Unknown caller": "Unknown caller",
|
||||
"Incoming voice call": "Incoming voice call",
|
||||
"Incoming video call": "Incoming video call",
|
||||
"Incoming call": "Incoming call",
|
||||
"Sound on": "Sound on",
|
||||
"Silence call": "Silence call",
|
||||
"Decline": "Decline",
|
||||
"Accept": "Accept",
|
||||
"The other party cancelled the verification.": "The other party cancelled the verification.",
|
||||
"Verified!": "Verified!",
|
||||
"You've successfully verified this user.": "You've successfully verified this user.",
|
||||
|
@ -1008,6 +1019,12 @@
|
|||
"Name": "Name",
|
||||
"Description": "Description",
|
||||
"Please enter a name for the space": "Please enter a name for the space",
|
||||
"Spaces are a new feature.": "Spaces are a new feature.",
|
||||
"Spaces feedback": "Spaces feedback",
|
||||
"Thank you for trying Spaces. Your feedback will help inform the next versions.": "Thank you for trying Spaces. Your feedback will help inform the next versions.",
|
||||
"Give feedback.": "Give feedback.",
|
||||
"e.g. my-space": "e.g. my-space",
|
||||
"Address": "Address",
|
||||
"Create a space": "Create a space",
|
||||
"Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.",
|
||||
"Public": "Public",
|
||||
|
@ -1020,12 +1037,12 @@
|
|||
"Your private space": "Your private space",
|
||||
"Add some details to help people recognise it.": "Add some details to help people recognise it.",
|
||||
"You can change these anytime.": "You can change these anytime.",
|
||||
"e.g. my-space": "e.g. my-space",
|
||||
"Address": "Address",
|
||||
"Creating...": "Creating...",
|
||||
"Create": "Create",
|
||||
"All rooms": "All rooms",
|
||||
"Home": "Home",
|
||||
"Show all rooms": "Show all rooms",
|
||||
"All rooms": "All rooms",
|
||||
"Options": "Options",
|
||||
"Expand space panel": "Expand space panel",
|
||||
"Collapse space panel": "Collapse space panel",
|
||||
"Click to copy": "Click to copy",
|
||||
|
@ -1055,16 +1072,9 @@
|
|||
"Preview Space": "Preview Space",
|
||||
"Allow people to preview your space before they join.": "Allow people to preview your space before they join.",
|
||||
"Recommended for public spaces.": "Recommended for public spaces.",
|
||||
"Settings": "Settings",
|
||||
"Leave space": "Leave space",
|
||||
"Create new room": "Create new room",
|
||||
"Add existing room": "Add existing room",
|
||||
"Members": "Members",
|
||||
"Manage & explore rooms": "Manage & explore rooms",
|
||||
"Explore rooms": "Explore rooms",
|
||||
"Space options": "Space options",
|
||||
"Expand": "Expand",
|
||||
"Collapse": "Collapse",
|
||||
"Space options": "Space options",
|
||||
"Remove": "Remove",
|
||||
"This bridge was provisioned by <user />.": "This bridge was provisioned by <user />.",
|
||||
"This bridge is managed by <user />.": "This bridge is managed by <user />.",
|
||||
|
@ -1588,16 +1598,17 @@
|
|||
"Hide Widgets": "Hide Widgets",
|
||||
"Show Widgets": "Show Widgets",
|
||||
"Search": "Search",
|
||||
"Voice call": "Voice call",
|
||||
"Video call": "Video call",
|
||||
"Invites": "Invites",
|
||||
"Favourites": "Favourites",
|
||||
"People": "People",
|
||||
"Start chat": "Start chat",
|
||||
"Rooms": "Rooms",
|
||||
"Add room": "Add room",
|
||||
"Create new room": "Create new room",
|
||||
"You do not have permissions to create new rooms in this space": "You do not have permissions to create new rooms in this space",
|
||||
"Add existing room": "Add existing room",
|
||||
"You do not have permissions to add rooms to this space": "You do not have permissions to add rooms to this space",
|
||||
"Explore rooms": "Explore rooms",
|
||||
"Explore community rooms": "Explore community rooms",
|
||||
"Explore public rooms": "Explore public rooms",
|
||||
"Low priority": "Low priority",
|
||||
|
@ -1675,6 +1686,7 @@
|
|||
"Low Priority": "Low Priority",
|
||||
"Invite People": "Invite People",
|
||||
"Copy Room Link": "Copy Room Link",
|
||||
"Settings": "Settings",
|
||||
"Leave Room": "Leave Room",
|
||||
"Room options": "Room options",
|
||||
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
|
||||
|
@ -1702,14 +1714,12 @@
|
|||
"Invited by %(sender)s": "Invited by %(sender)s",
|
||||
"Jump to first unread message.": "Jump to first unread message.",
|
||||
"Mark all as read": "Mark all as read",
|
||||
"The voice message failed to upload.": "The voice message failed to upload.",
|
||||
"Unable to access your microphone": "Unable to access your microphone",
|
||||
"We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.",
|
||||
"No microphone found": "No microphone found",
|
||||
"We didn't find a microphone on your device. Please check your settings and try again.": "We didn't find a microphone on your device. Please check your settings and try again.",
|
||||
"Record a voice message": "Record a voice message",
|
||||
"Stop the recording": "Stop the recording",
|
||||
"Delete recording": "Delete recording",
|
||||
"Send voice message": "Send voice message",
|
||||
"Stop recording": "Stop recording",
|
||||
"Error updating main address": "Error updating main address",
|
||||
"There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.",
|
||||
"There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.",
|
||||
|
@ -1768,13 +1778,13 @@
|
|||
"The homeserver the user you’re verifying is connected to": "The homeserver the user you’re verifying is connected to",
|
||||
"Yours, or the other users’ internet connection": "Yours, or the other users’ internet connection",
|
||||
"Yours, or the other users’ session": "Yours, or the other users’ session",
|
||||
"Members": "Members",
|
||||
"Nothing pinned, yet": "Nothing pinned, yet",
|
||||
"If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
|
||||
"Pinned messages": "Pinned messages",
|
||||
"Room Info": "Room Info",
|
||||
"You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets",
|
||||
"Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel",
|
||||
"Options": "Options",
|
||||
"Set my room layout for everyone": "Set my room layout for everyone",
|
||||
"Widgets": "Widgets",
|
||||
"Edit widgets, bridges & bots": "Edit widgets, bridges & bots",
|
||||
|
@ -1870,19 +1880,15 @@
|
|||
"Verification cancelled": "Verification cancelled",
|
||||
"Compare emoji": "Compare emoji",
|
||||
"Connected": "Connected",
|
||||
"You declined this call": "You declined this call",
|
||||
"They declined this call": "They declined this call",
|
||||
"Call declined": "Call declined",
|
||||
"Call back": "Call back",
|
||||
"Call again": "Call again",
|
||||
"This call has ended": "This call has ended",
|
||||
"They didn't pick up": "They didn't pick up",
|
||||
"Missed call": "Missed call",
|
||||
"Could not connect media": "Could not connect media",
|
||||
"Connection failed": "Connection failed",
|
||||
"Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone",
|
||||
"An unknown error occurred": "An unknown error occurred",
|
||||
"Unknown failure: %(reason)s)": "Unknown failure: %(reason)s)",
|
||||
"This call has failed": "This call has failed",
|
||||
"You missed this call": "You missed this call",
|
||||
"Retry": "Retry",
|
||||
"The call is in an unknown state!": "The call is in an unknown state!",
|
||||
"Sunday": "Sunday",
|
||||
"Monday": "Monday",
|
||||
|
@ -1893,7 +1899,7 @@
|
|||
"Saturday": "Saturday",
|
||||
"Today": "Today",
|
||||
"Yesterday": "Yesterday",
|
||||
"Downloading": "Downloading",
|
||||
"Decrypting": "Decrypting",
|
||||
"Download": "Download",
|
||||
"View Source": "View Source",
|
||||
"Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.",
|
||||
|
@ -1905,12 +1911,11 @@
|
|||
"Error processing audio message": "Error processing audio message",
|
||||
"React": "React",
|
||||
"Edit": "Edit",
|
||||
"Retry": "Retry",
|
||||
"Reply": "Reply",
|
||||
"Message Actions": "Message Actions",
|
||||
"Download %(text)s": "Download %(text)s",
|
||||
"Error decrypting attachment": "Error decrypting attachment",
|
||||
"Decrypt %(text)s": "Decrypt %(text)s",
|
||||
"Download %(text)s": "Download %(text)s",
|
||||
"Invalid file%(extra)s": "Invalid file%(extra)s",
|
||||
"Error decrypting image": "Error decrypting image",
|
||||
"Show image": "Show image",
|
||||
|
@ -2119,17 +2124,20 @@
|
|||
"Add a new server...": "Add a new server...",
|
||||
"%(networkName)s rooms": "%(networkName)s rooms",
|
||||
"Matrix rooms": "Matrix rooms",
|
||||
"Add existing space": "Add existing space",
|
||||
"Want to add a new space instead?": "Want to add a new space instead?",
|
||||
"Create a new space": "Create a new space",
|
||||
"Search for spaces": "Search for spaces",
|
||||
"Not all selected were added": "Not all selected were added",
|
||||
"Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)",
|
||||
"Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...",
|
||||
"Filter your rooms and spaces": "Filter your rooms and spaces",
|
||||
"Feeling experimental?": "Feeling experimental?",
|
||||
"You can add existing spaces to a space.": "You can add existing spaces to a space.",
|
||||
"Direct Messages": "Direct Messages",
|
||||
"Space selection": "Space selection",
|
||||
"Add existing rooms": "Add existing rooms",
|
||||
"Want to add a new room instead?": "Want to add a new room instead?",
|
||||
"Create a new room": "Create a new room",
|
||||
"Search for rooms": "Search for rooms",
|
||||
"Adding spaces has moved.": "Adding spaces has moved.",
|
||||
"Matrix ID": "Matrix ID",
|
||||
"Matrix Room ID": "Matrix Room ID",
|
||||
"email address": "email address",
|
||||
|
@ -2143,15 +2151,8 @@
|
|||
"Invite anyway and never warn me again": "Invite anyway and never warn me again",
|
||||
"Invite anyway": "Invite anyway",
|
||||
"Close dialog": "Close dialog",
|
||||
"Beta feedback": "Beta feedback",
|
||||
"Thank you for your feedback, we really appreciate it.": "Thank you for your feedback, we really appreciate it.",
|
||||
"Done": "Done",
|
||||
"%(featureName)s beta feedback": "%(featureName)s beta feedback",
|
||||
"Your platform and username will be noted to help us use your feedback as much as we can.": "Your platform and username will be noted to help us use your feedback as much as we can.",
|
||||
"To leave the beta, visit your settings.": "To leave the beta, visit your settings.",
|
||||
"Feedback": "Feedback",
|
||||
"You may contact me if you have any follow up questions": "You may contact me if you have any follow up questions",
|
||||
"Send feedback": "Send feedback",
|
||||
"Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.",
|
||||
"Preparing to send logs": "Preparing to send logs",
|
||||
"Logs sent": "Logs sent",
|
||||
|
@ -2217,13 +2218,22 @@
|
|||
"Create a room in %(communityName)s": "Create a room in %(communityName)s",
|
||||
"Create a public room": "Create a public room",
|
||||
"Create a private room": "Create a private room",
|
||||
"Topic (optional)": "Topic (optional)",
|
||||
"Room visibility": "Room visibility",
|
||||
"Private room (invite only)": "Private room (invite only)",
|
||||
"Public room": "Public room",
|
||||
"Visible to space members": "Visible to space members",
|
||||
"Topic (optional)": "Topic (optional)",
|
||||
"Room visibility": "Room visibility",
|
||||
"Block anyone not part of %(serverName)s from ever joining this room.": "Block anyone not part of %(serverName)s from ever joining this room.",
|
||||
"Create Room": "Create Room",
|
||||
"Anyone in <SpaceName/> will be able to find and join.": "Anyone in <SpaceName/> will be able to find and join.",
|
||||
"Anyone will be able to find and join this space, not just members of <SpaceName/>.": "Anyone will be able to find and join this space, not just members of <SpaceName/>.",
|
||||
"Only people invited will be able to find and join this space.": "Only people invited will be able to find and join this space.",
|
||||
"Add a space to a space you manage.": "Add a space to a space you manage.",
|
||||
"Space visibility": "Space visibility",
|
||||
"Private space (invite only)": "Private space (invite only)",
|
||||
"Public space": "Public space",
|
||||
"Want to add an existing space instead?": "Want to add an existing space instead?",
|
||||
"Adding...": "Adding...",
|
||||
"Sign out": "Sign out",
|
||||
"To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this",
|
||||
"You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.",
|
||||
|
@ -2289,8 +2299,10 @@
|
|||
"Comment": "Comment",
|
||||
"There are two ways you can provide feedback and help us improve %(brand)s.": "There are two ways you can provide feedback and help us improve %(brand)s.",
|
||||
"PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> to help us track down the problem.": "PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> to help us track down the problem.",
|
||||
"Feedback": "Feedback",
|
||||
"Report a bug": "Report a bug",
|
||||
"Please view <existingIssuesLink>existing bugs on Github</existingIssuesLink> first. No match? <newIssueLink>Start a new one</newIssueLink>.": "Please view <existingIssuesLink>existing bugs on Github</existingIssuesLink> first. No match? <newIssueLink>Start a new one</newIssueLink>.",
|
||||
"Send feedback": "Send feedback",
|
||||
"You don't have permission to do this": "You don't have permission to do this",
|
||||
"Sending": "Sending",
|
||||
"Sent": "Sent",
|
||||
|
@ -2298,6 +2310,10 @@
|
|||
"Forward message": "Forward message",
|
||||
"Message preview": "Message preview",
|
||||
"Search for rooms or people": "Search for rooms or people",
|
||||
"Thank you for your feedback, we really appreciate it.": "Thank you for your feedback, we really appreciate it.",
|
||||
"Done": "Done",
|
||||
"Your platform and username will be noted to help us use your feedback as much as we can.": "Your platform and username will be noted to help us use your feedback as much as we can.",
|
||||
"You may contact me if you have any follow up questions": "You may contact me if you have any follow up questions",
|
||||
"Confirm abort of host creation": "Confirm abort of host creation",
|
||||
"Are you sure you wish to abort creation of the host? The process cannot be continued.": "Are you sure you wish to abort creation of the host? The process cannot be continued.",
|
||||
"Abort": "Abort",
|
||||
|
@ -2369,6 +2385,16 @@
|
|||
"Clear cache and resync": "Clear cache and resync",
|
||||
"%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!",
|
||||
"Updating %(brand)s": "Updating %(brand)s",
|
||||
"Leave all rooms and spaces": "Leave all rooms and spaces",
|
||||
"Don't leave any": "Don't leave any",
|
||||
"Leave specific rooms and spaces": "Leave specific rooms and spaces",
|
||||
"Search %(spaceName)s": "Search %(spaceName)s",
|
||||
"You won't be able to rejoin unless you are re-invited.": "You won't be able to rejoin unless you are re-invited.",
|
||||
"You're the only admin of this space. Leaving it will mean no one has control over it.": "You're the only admin of this space. Leaving it will mean no one has control over it.",
|
||||
"You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.",
|
||||
"Leave %(spaceName)s": "Leave %(spaceName)s",
|
||||
"Are you sure you want to leave <spaceName/>?": "Are you sure you want to leave <spaceName/>?",
|
||||
"Leave space": "Leave space",
|
||||
"Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.",
|
||||
"Start using Key Backup": "Start using Key Backup",
|
||||
"I don't want my encrypted messages": "I don't want my encrypted messages",
|
||||
|
@ -2577,6 +2603,8 @@
|
|||
"Source URL": "Source URL",
|
||||
"Collapse reply thread": "Collapse reply thread",
|
||||
"Report": "Report",
|
||||
"Add space": "Add space",
|
||||
"Manage & explore rooms": "Manage & explore rooms",
|
||||
"Clear status": "Clear status",
|
||||
"Update status": "Update status",
|
||||
"Set status": "Set status",
|
||||
|
@ -2809,8 +2837,6 @@
|
|||
"Search names and descriptions": "Search names and descriptions",
|
||||
"If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
|
||||
"Create room": "Create room",
|
||||
"Spaces are a beta feature.": "Spaces are a beta feature.",
|
||||
"Public space": "Public space",
|
||||
"Private space": "Private space",
|
||||
"<inviter/> invites you": "<inviter/> invites you",
|
||||
"To view %(spaceName)s, turn on the <a>Spaces beta</a>": "To view %(spaceName)s, turn on the <a>Spaces beta</a>",
|
||||
|
@ -2825,6 +2851,7 @@
|
|||
"Creating rooms...": "Creating rooms...",
|
||||
"What do you want to organise?": "What do you want to organise?",
|
||||
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.",
|
||||
"Search for rooms or spaces": "Search for rooms or spaces",
|
||||
"Share %(name)s": "Share %(name)s",
|
||||
"It's just you at the moment, it will be even better with others.": "It's just you at the moment, it will be even better with others.",
|
||||
"Go to my first room": "Go to my first room",
|
||||
|
@ -2836,7 +2863,7 @@
|
|||
"Me and my teammates": "Me and my teammates",
|
||||
"A private space for you and your teammates": "A private space for you and your teammates",
|
||||
"Teammates might not be able to view or join any private rooms you make.": "Teammates might not be able to view or join any private rooms you make.",
|
||||
"We're working on this as part of the beta, but just want to let you know.": "We're working on this as part of the beta, but just want to let you know.",
|
||||
"We're working on this, but just want to let you know.": "We're working on this, but just want to let you know.",
|
||||
"Failed to invite the following users to your space: %(csvUsers)s": "Failed to invite the following users to your space: %(csvUsers)s",
|
||||
"Inviting...": "Inviting...",
|
||||
"Invite your teammates": "Invite your teammates",
|
||||
|
|
|
@ -160,6 +160,17 @@ export function _t(text: string, variables?: IVariables, tags?: Tags): Translate
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes unsafe text for the sanitizer, ensuring references to variables will not be considered
|
||||
* replaceable by the translation functions.
|
||||
* @param {string} text The text to sanitize.
|
||||
* @returns {string} The sanitized text.
|
||||
*/
|
||||
export function sanitizeForTranslation(text: string): string {
|
||||
// Add a non-breaking space so the regex doesn't trigger when translating.
|
||||
return text.replace(/%\(([^)]*)\)/g, '%\xa0($1)');
|
||||
}
|
||||
|
||||
/*
|
||||
* Similar to _t(), except only does substitutions, and no translation
|
||||
* @param {string} text The text, e.g "click <a>here</a> now to %(foo)s".
|
||||
|
|
|
@ -41,6 +41,7 @@ import { Layout } from "./Layout";
|
|||
import ReducedMotionController from './controllers/ReducedMotionController';
|
||||
import IncompatibleController from "./controllers/IncompatibleController";
|
||||
import SdkConfig from "../SdkConfig";
|
||||
import PseudonymousAnalyticsController from './controllers/PseudonymousAnalyticsController';
|
||||
import NewLayoutSwitcherController from './controllers/NewLayoutSwitcherController';
|
||||
|
||||
// These are just a bunch of helper arrays to avoid copy/pasting a bunch of times
|
||||
|
@ -124,6 +125,7 @@ export interface ISetting {
|
|||
// not use this for new settings.
|
||||
invertedSettingName?: string;
|
||||
|
||||
// XXX: Keep this around for re-use in future Betas
|
||||
betaInfo?: {
|
||||
title: string; // _td
|
||||
caption: string; // _td
|
||||
|
@ -179,45 +181,14 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
|||
feedbackSubheading: _td("Your feedback will help make spaces better. " +
|
||||
"The more detail you can go into, the better."),
|
||||
feedbackLabel: "spaces-feedback",
|
||||
extraSettings: [
|
||||
"feature_spaces.all_rooms",
|
||||
"feature_spaces.space_member_dms",
|
||||
"feature_spaces.space_dm_badges",
|
||||
],
|
||||
},
|
||||
},
|
||||
"feature_spaces.all_rooms": {
|
||||
displayName: _td("Show all rooms in Home"),
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: true,
|
||||
controller: new ReloadOnChangeController(),
|
||||
},
|
||||
"feature_spaces.space_member_dms": {
|
||||
displayName: _td("Show people in spaces"),
|
||||
description: _td("If disabled, you can still add Direct Messages to Personal Spaces. " +
|
||||
"If enabled, you'll automatically see everyone who is a member of the Space."),
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: true,
|
||||
controller: new ReloadOnChangeController(),
|
||||
},
|
||||
"feature_spaces.space_dm_badges": {
|
||||
displayName: _td("Show notification badges for People in Spaces"),
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
controller: new ReloadOnChangeController(),
|
||||
},
|
||||
"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"),
|
||||
|
@ -298,6 +269,13 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
|||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
},
|
||||
"feature_pseudonymous_analytics_opt_in": {
|
||||
isFeature: true,
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
displayName: _td('Send pseudonymous analytics data'),
|
||||
default: false,
|
||||
controller: new PseudonymousAnalyticsController(),
|
||||
},
|
||||
"advancedRoomListLogging": {
|
||||
// TODO: Remove flag before launch: https://github.com/vector-im/element-web/issues/14231
|
||||
displayName: _td("Enable advanced debugging for the room list"),
|
||||
|
@ -471,6 +449,11 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
|||
displayName: isMac ? _td("Use Command + Enter to send a message") : _td("Use Ctrl + Enter to send a message"),
|
||||
default: false,
|
||||
},
|
||||
"MessageComposerInput.surroundWith": {
|
||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||
displayName: _td("Surround selected text when typing special characters"),
|
||||
default: false,
|
||||
},
|
||||
"MessageComposerInput.autoReplaceEmoji": {
|
||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||
displayName: _td('Automatically replace plain text Emoji'),
|
||||
|
@ -773,6 +756,12 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
|||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||
default: null,
|
||||
},
|
||||
"Spaces.allRoomsInHome": {
|
||||
displayName: _td("Show all rooms in Home"),
|
||||
description: _td("All rooms you're in will appear in Home."),
|
||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||
default: false,
|
||||
},
|
||||
[UIFeature.RoomHistorySettings]: {
|
||||
supportedLevels: LEVELS_UI_FEATURE,
|
||||
default: true,
|
||||
|
|
|
@ -29,6 +29,8 @@ import LocalEchoWrapper from "./handlers/LocalEchoWrapper";
|
|||
import { WatchManager, CallbackFn as WatchCallbackFn } from "./WatchManager";
|
||||
import { SettingLevel } from "./SettingLevel";
|
||||
import SettingsHandler from "./handlers/SettingsHandler";
|
||||
import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
|
||||
const defaultWatchManager = new WatchManager();
|
||||
|
||||
|
@ -147,7 +149,7 @@ export default class SettingsStore {
|
|||
* if the change in value is worthwhile enough to react upon.
|
||||
* @returns {string} A reference to the watcher that was employed.
|
||||
*/
|
||||
public static watchSetting(settingName: string, roomId: string, callbackFn: CallbackFn): string {
|
||||
public static watchSetting(settingName: string, roomId: string | null, callbackFn: CallbackFn): string {
|
||||
const setting = SETTINGS[settingName];
|
||||
const originalSettingName = settingName;
|
||||
if (!setting) throw new Error(`${settingName} is not a setting`);
|
||||
|
@ -193,7 +195,7 @@ export default class SettingsStore {
|
|||
* @param {string} settingName The setting name to monitor.
|
||||
* @param {String} roomId The room ID to monitor for changes in. Use null for all rooms.
|
||||
*/
|
||||
public static monitorSetting(settingName: string, roomId: string) {
|
||||
public static monitorSetting(settingName: string, roomId: string | null) {
|
||||
roomId = roomId || null; // the thing wants null specifically to work, so appease it.
|
||||
|
||||
if (!this.monitors.has(settingName)) this.monitors.set(settingName, new Map());
|
||||
|
@ -201,8 +203,8 @@ export default class SettingsStore {
|
|||
const registerWatcher = () => {
|
||||
this.monitors.get(settingName).set(roomId, SettingsStore.watchSetting(
|
||||
settingName, roomId, (settingName, inRoomId, level, newValueAtLevel, newValue) => {
|
||||
dis.dispatch({
|
||||
action: 'setting_updated',
|
||||
dis.dispatch<SettingUpdatedPayload>({
|
||||
action: Action.SettingUpdated,
|
||||
settingName,
|
||||
roomId: inRoomId,
|
||||
level,
|
||||
|
|
26
src/settings/controllers/PseudonymousAnalyticsController.ts
Normal file
26
src/settings/controllers/PseudonymousAnalyticsController.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
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 { PosthogAnalytics } from "../../PosthogAnalytics";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
|
||||
export default class PseudonymousAnalyticsController extends SettingController {
|
||||
public onChange(level: SettingLevel, roomId: string, newValue: any) {
|
||||
PosthogAnalytics.instance.updateAnonymityFromSettings(MatrixClientPeg.get().getUserId());
|
||||
}
|
||||
}
|
|
@ -23,6 +23,8 @@ import { arrayHasDiff } from "../utils/arrays";
|
|||
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
|
||||
import { SettingLevel } from "../settings/SettingLevel";
|
||||
import SpaceStore from "./SpaceStore";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload";
|
||||
|
||||
const MAX_ROOMS = 20; // arbitrary
|
||||
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up
|
||||
|
@ -63,10 +65,11 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
|
|||
protected async onAction(payload: ActionPayload) {
|
||||
if (!this.matrixClient) return;
|
||||
|
||||
if (payload.action === 'setting_updated') {
|
||||
if (payload.settingName === 'breadcrumb_rooms') {
|
||||
if (payload.action === Action.SettingUpdated) {
|
||||
const settingUpdatedPayload = payload as SettingUpdatedPayload;
|
||||
if (settingUpdatedPayload.settingName === 'breadcrumb_rooms') {
|
||||
await this.updateRooms();
|
||||
} else if (payload.settingName === 'breadcrumbs') {
|
||||
} else if (settingUpdatedPayload.settingName === 'breadcrumbs') {
|
||||
await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) });
|
||||
}
|
||||
} else if (payload.action === 'view_room') {
|
||||
|
|
|
@ -56,7 +56,7 @@ class LifecycleStore extends Store<ActionPayload> {
|
|||
deferredAction: null,
|
||||
});
|
||||
break;
|
||||
case 'syncstate': {
|
||||
case 'sync_state': {
|
||||
if (payload.state !== 'PREPARED') {
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -37,9 +37,8 @@ import { EnhancedMap, mapDiff } from "../utils/maps";
|
|||
import { setHasDiff } from "../utils/sets";
|
||||
import RoomViewStore from "./RoomViewStore";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
import { arrayHasDiff } from "../utils/arrays";
|
||||
import { arrayHasDiff, arrayHasOrderChange } from "../utils/arrays";
|
||||
import { objectDiff } from "../utils/objects";
|
||||
import { arrayHasOrderChange } from "../utils/arrays";
|
||||
import { reorderLexicographically } from "../utils/stringOrderField";
|
||||
import { TAG_ORDER } from "../components/views/rooms/RoomList";
|
||||
import { shouldShowSpaceSettings } from "../utils/space";
|
||||
|
@ -48,6 +47,7 @@ import { _t } from "../languageHandler";
|
|||
import GenericToast from "../components/views/toasts/GenericToast";
|
||||
import Modal from "../Modal";
|
||||
import InfoDialog from "../components/views/dialogs/InfoDialog";
|
||||
import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload";
|
||||
|
||||
type SpaceKey = string | symbol;
|
||||
|
||||
|
@ -61,6 +61,7 @@ export const SUGGESTED_ROOMS = Symbol("suggested-rooms");
|
|||
export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces");
|
||||
export const UPDATE_INVITED_SPACES = Symbol("invited-spaces");
|
||||
export const UPDATE_SELECTED_SPACE = Symbol("selected-space");
|
||||
export const UPDATE_HOME_BEHAVIOUR = Symbol("home-behaviour");
|
||||
// Space Room ID/HOME_SPACE will be emitted when a Space's children change
|
||||
|
||||
export interface ISuggestedRoom extends ISpaceSummaryRoom {
|
||||
|
@ -69,14 +70,10 @@ export interface ISuggestedRoom extends ISpaceSummaryRoom {
|
|||
|
||||
const MAX_SUGGESTED_ROOMS = 20;
|
||||
|
||||
// All of these settings cause the page to reload and can be costly if read frequently, so read them here only
|
||||
// This setting causes the page to reload and can be costly if read frequently, so read it here only
|
||||
const spacesEnabled = SettingsStore.getValue("feature_spaces");
|
||||
const spacesTweakAllRoomsEnabled = SettingsStore.getValue("feature_spaces.all_rooms");
|
||||
const spacesTweakSpaceMemberDMsEnabled = SettingsStore.getValue("feature_spaces.space_member_dms");
|
||||
const spacesTweakSpaceDMBadgesEnabled = SettingsStore.getValue("feature_spaces.space_dm_badges");
|
||||
|
||||
const homeSpaceKey = spacesTweakAllRoomsEnabled ? "ALL_ROOMS" : "HOME_SPACE";
|
||||
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || homeSpaceKey}`;
|
||||
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "HOME_SPACE"}`;
|
||||
|
||||
const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms]
|
||||
return arr.reduce((result, room: Room) => {
|
||||
|
@ -104,10 +101,6 @@ const getRoomFn: FetchRoomFn = (room: Room) => {
|
|||
};
|
||||
|
||||
export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
constructor() {
|
||||
super(defaultDispatcher, {});
|
||||
}
|
||||
|
||||
// The spaces representing the roots of the various tree-like hierarchies
|
||||
private rootSpaces: Room[] = [];
|
||||
// The list of rooms not present in any currently joined spaces
|
||||
|
@ -124,6 +117,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
private _invitedSpaces = new Set<Room>();
|
||||
private spaceOrderLocalEchoMap = new Map<string, string>();
|
||||
private _restrictedJoinRuleSupport?: IRoomCapability;
|
||||
private _allRoomsInHome: boolean = SettingsStore.getValue("Spaces.allRoomsInHome");
|
||||
|
||||
constructor() {
|
||||
super(defaultDispatcher, {});
|
||||
|
||||
SettingsStore.monitorSetting("Spaces.allRoomsInHome", null);
|
||||
}
|
||||
|
||||
public get invitedSpaces(): Room[] {
|
||||
return Array.from(this._invitedSpaces);
|
||||
|
@ -141,13 +141,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
return this._suggestedRooms;
|
||||
}
|
||||
|
||||
public get allRoomsInHome(): boolean {
|
||||
return this._allRoomsInHome;
|
||||
}
|
||||
|
||||
public async setActiveRoomInSpace(space: Room | null): Promise<void> {
|
||||
if (space && !space.isSpaceRoom()) return;
|
||||
if (space !== this.activeSpace) await this.setActiveSpace(space);
|
||||
|
||||
if (space) {
|
||||
const notificationState = this.getNotificationState(space.roomId);
|
||||
const roomId = notificationState.getFirstRoomWithNotifications();
|
||||
const roomId = this.getNotificationState(space.roomId).getFirstRoomWithNotifications();
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: roomId,
|
||||
|
@ -202,7 +205,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
// else if the last viewed room in this space is joined then view that
|
||||
// else view space home or home depending on what is being clicked on
|
||||
if (space?.getMyMembership() !== "invite" &&
|
||||
this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join"
|
||||
this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" &&
|
||||
this.getSpaceFilteredRoomIds(space).has(roomId)
|
||||
) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
|
@ -331,7 +335,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
}, roomId);
|
||||
}
|
||||
|
||||
private getChildren(spaceId: string): Room[] {
|
||||
public getChildren(spaceId: string): Room[] {
|
||||
const room = this.matrixClient?.getRoom(spaceId);
|
||||
const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via);
|
||||
return sortBy(childEvents, ev => {
|
||||
|
@ -379,7 +383,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
}
|
||||
|
||||
public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => {
|
||||
if (!space && spacesTweakAllRoomsEnabled) {
|
||||
if (!space && this.allRoomsInHome) {
|
||||
return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId));
|
||||
}
|
||||
return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set();
|
||||
|
@ -476,7 +480,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
};
|
||||
|
||||
private showInHomeSpace = (room: Room) => {
|
||||
if (spacesTweakAllRoomsEnabled) return true;
|
||||
if (this.allRoomsInHome) return true;
|
||||
if (room.isSpaceRoom()) return false;
|
||||
return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space
|
||||
|| DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space
|
||||
|
@ -508,7 +512,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
const oldFilteredRooms = this.spaceFilteredRooms;
|
||||
this.spaceFilteredRooms = new Map();
|
||||
|
||||
if (!spacesTweakAllRoomsEnabled) {
|
||||
if (!this.allRoomsInHome) {
|
||||
// put all room invites in the Home Space
|
||||
const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite");
|
||||
this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId)));
|
||||
|
@ -535,15 +539,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
const roomIds = new Set(childRooms.map(r => r.roomId));
|
||||
const space = this.matrixClient?.getRoom(spaceId);
|
||||
|
||||
if (spacesTweakSpaceMemberDMsEnabled) {
|
||||
// Add relevant DMs
|
||||
space?.getMembers().forEach(member => {
|
||||
if (member.membership !== "join" && member.membership !== "invite") return;
|
||||
DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => {
|
||||
roomIds.add(roomId);
|
||||
});
|
||||
// Add relevant DMs
|
||||
space?.getMembers().forEach(member => {
|
||||
if (member.membership !== "join" && member.membership !== "invite") return;
|
||||
DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => {
|
||||
roomIds.add(roomId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const newPath = new Set(parentPath).add(spaceId);
|
||||
childSpaces.forEach(childSpace => {
|
||||
|
@ -566,16 +568,17 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
});
|
||||
|
||||
this.spaceFilteredRooms.forEach((roomIds, s) => {
|
||||
// Update NotificationStates
|
||||
this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => {
|
||||
if (roomIds.has(room.roomId)) {
|
||||
if (s !== HOME_SPACE && spacesTweakSpaceDMBadgesEnabled) return true;
|
||||
if (this.allRoomsInHome && s === HOME_SPACE) return; // we'll be using the global notification state, skip
|
||||
|
||||
return !DMRoomMap.shared().getUserIdForRoomId(room.roomId)
|
||||
|| RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite);
|
||||
// Update NotificationStates
|
||||
this.getNotificationState(s).setRooms(visibleRooms.filter(room => {
|
||||
if (!roomIds.has(room.roomId)) return false;
|
||||
|
||||
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
|
||||
return s === HOME_SPACE;
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}));
|
||||
});
|
||||
}, 100, { trailing: true, leading: true });
|
||||
|
@ -668,7 +671,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
// TODO confirm this after implementing parenting behaviour
|
||||
if (room.isSpaceRoom()) {
|
||||
this.onSpaceUpdate();
|
||||
} else if (!spacesTweakAllRoomsEnabled) {
|
||||
} else if (!this.allRoomsInHome) {
|
||||
this.onRoomUpdate(room);
|
||||
}
|
||||
this.emit(room.roomId);
|
||||
|
@ -692,7 +695,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
if (order !== lastOrder) {
|
||||
this.notifyIfOrderChanged();
|
||||
}
|
||||
} else if (ev.getType() === EventType.Tag && !spacesTweakAllRoomsEnabled) {
|
||||
} else if (ev.getType() === EventType.Tag && !this.allRoomsInHome) {
|
||||
// If the room was in favourites and now isn't or the opposite then update its position in the trees
|
||||
const oldTags = lastEv?.getContent()?.tags || {};
|
||||
const newTags = ev.getContent()?.tags || {};
|
||||
|
@ -703,7 +706,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
};
|
||||
|
||||
private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => {
|
||||
if (ev.getType() === EventType.Direct) {
|
||||
if (!this.allRoomsInHome && ev.getType() === EventType.Direct) {
|
||||
const lastContent = lastEvent.getContent();
|
||||
const content = ev.getContent();
|
||||
|
||||
|
@ -738,9 +741,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
this.matrixClient.removeListener("Room.myMembership", this.onRoom);
|
||||
this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
|
||||
this.matrixClient.removeListener("RoomState.events", this.onRoomState);
|
||||
if (!spacesTweakAllRoomsEnabled) {
|
||||
this.matrixClient.removeListener("accountData", this.onAccountData);
|
||||
}
|
||||
this.matrixClient.removeListener("accountData", this.onAccountData);
|
||||
}
|
||||
await this.reset();
|
||||
}
|
||||
|
@ -751,9 +752,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
this.matrixClient.on("Room.myMembership", this.onRoom);
|
||||
this.matrixClient.on("Room.accountData", this.onRoomAccountData);
|
||||
this.matrixClient.on("RoomState.events", this.onRoomState);
|
||||
if (!spacesTweakAllRoomsEnabled) {
|
||||
this.matrixClient.on("accountData", this.onAccountData);
|
||||
}
|
||||
this.matrixClient.on("accountData", this.onAccountData);
|
||||
|
||||
this.matrixClient.getCapabilities().then(capabilities => {
|
||||
this._restrictedJoinRuleSupport = capabilities
|
||||
|
@ -784,7 +783,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
// as it will cause you to end up in the wrong room
|
||||
this.setActiveSpace(room, false);
|
||||
} else if (
|
||||
(!spacesTweakAllRoomsEnabled || this.activeSpace) &&
|
||||
(!this.allRoomsInHome || this.activeSpace) &&
|
||||
!this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)
|
||||
) {
|
||||
this.switchToRelatedSpace(roomId);
|
||||
|
@ -796,17 +795,33 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id);
|
||||
break;
|
||||
}
|
||||
|
||||
case "after_leave_room":
|
||||
if (this._activeSpace && payload.room_id === this._activeSpace.roomId) {
|
||||
this.setActiveSpace(null, false);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action.SwitchSpace:
|
||||
if (payload.num === 0) {
|
||||
this.setActiveSpace(null);
|
||||
} else if (this.spacePanelSpaces.length >= payload.num) {
|
||||
this.setActiveSpace(this.spacePanelSpaces[payload.num - 1]);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action.SettingUpdated: {
|
||||
const settingUpdatedPayload = payload as SettingUpdatedPayload;
|
||||
if (settingUpdatedPayload.settingName === "Spaces.allRoomsInHome") {
|
||||
const newValue = SettingsStore.getValue("Spaces.allRoomsInHome");
|
||||
if (this.allRoomsInHome !== newValue) {
|
||||
this._allRoomsInHome = newValue;
|
||||
this.emit(UPDATE_HOME_BEHAVIOUR, this.allRoomsInHome);
|
||||
this.rebuild(); // rebuild everything
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -877,9 +892,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
|
||||
export default class SpaceStore {
|
||||
public static spacesEnabled = spacesEnabled;
|
||||
public static spacesTweakAllRoomsEnabled = spacesTweakAllRoomsEnabled;
|
||||
public static spacesTweakSpaceMemberDMsEnabled = spacesTweakSpaceMemberDMsEnabled;
|
||||
public static spacesTweakSpaceDMBadgesEnabled = spacesTweakSpaceDMBadgesEnabled;
|
||||
|
||||
private static internalInstance = new SpaceStoreClass();
|
||||
|
||||
|
|
|
@ -22,10 +22,11 @@ export interface IToast<C extends ComponentClass> {
|
|||
key: string;
|
||||
// higher priority number will be shown on top of lower priority
|
||||
priority: number;
|
||||
title: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
component: C;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
props?: Omit<React.ComponentProps<C>, "toastKey">; // toastKey is injected by ToastContainer
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationStat
|
|||
import { FetchRoomFn } from "./ListNotificationState";
|
||||
|
||||
export class SpaceNotificationState extends NotificationState {
|
||||
private rooms: Room[] = [];
|
||||
public rooms: Room[] = []; // exposed only for tests
|
||||
private states: { [spaceId: string]: RoomNotificationState } = {};
|
||||
|
||||
constructor(private spaceId: string | symbol, private getRoomFn: FetchRoomFn) {
|
||||
|
|
|
@ -36,6 +36,8 @@ import { RoomNotificationStateStore } from "../notifications/RoomNotificationSta
|
|||
import { VisibilityProvider } from "./filters/VisibilityProvider";
|
||||
import { SpaceWatcher } from "./SpaceWatcher";
|
||||
import SpaceStore from "../SpaceStore";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload";
|
||||
|
||||
interface IState {
|
||||
tagsEnabled?: boolean;
|
||||
|
@ -213,10 +215,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
|
|||
const logicallyReady = this.matrixClient && this.initialListsGenerated;
|
||||
if (!logicallyReady) return;
|
||||
|
||||
if (payload.action === 'setting_updated') {
|
||||
if (this.watchedSettings.includes(payload.settingName)) {
|
||||
if (payload.action === Action.SettingUpdated) {
|
||||
const settingUpdatedPayload = payload as SettingUpdatedPayload;
|
||||
if (this.watchedSettings.includes(settingUpdatedPayload.settingName)) {
|
||||
// TODO: Remove with https://github.com/vector-im/element-web/issues/14602
|
||||
if (payload.settingName === "advancedRoomListLogging") {
|
||||
if (settingUpdatedPayload.settingName === "advancedRoomListLogging") {
|
||||
// Log when the setting changes so we know when it was turned on in the rageshake
|
||||
const enabled = SettingsStore.getValue("advancedRoomListLogging");
|
||||
console.warn("Advanced room list logging is enabled? " + enabled);
|
||||
|
@ -708,6 +711,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
|
|||
}
|
||||
let promise = Promise.resolve();
|
||||
let idx = this.filterConditions.indexOf(filter);
|
||||
let removed = false;
|
||||
if (idx >= 0) {
|
||||
this.filterConditions.splice(idx, 1);
|
||||
|
||||
|
@ -718,14 +722,20 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
|
|||
if (SpaceStore.spacesEnabled) {
|
||||
promise = this.recalculatePrefiltering();
|
||||
}
|
||||
removed = true;
|
||||
}
|
||||
|
||||
idx = this.prefilterConditions.indexOf(filter);
|
||||
if (idx >= 0) {
|
||||
filter.off(FILTER_CHANGED, this.onPrefilterUpdated);
|
||||
this.prefilterConditions.splice(idx, 1);
|
||||
promise = this.recalculatePrefiltering();
|
||||
removed = true;
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
promise.then(() => this.updateFn.trigger());
|
||||
}
|
||||
promise.then(() => this.updateFn.trigger());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -18,39 +18,47 @@ import { Room } from "matrix-js-sdk/src/models/room";
|
|||
|
||||
import { RoomListStoreClass } from "./RoomListStore";
|
||||
import { SpaceFilterCondition } from "./filters/SpaceFilterCondition";
|
||||
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore";
|
||||
import SpaceStore, { UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../SpaceStore";
|
||||
|
||||
/**
|
||||
* Watches for changes in spaces to manage the filter on the provided RoomListStore
|
||||
*/
|
||||
export class SpaceWatcher {
|
||||
private filter: SpaceFilterCondition;
|
||||
private readonly filter = new SpaceFilterCondition();
|
||||
// we track these separately to the SpaceStore as we need to observe transitions
|
||||
private activeSpace: Room = SpaceStore.instance.activeSpace;
|
||||
private allRoomsInHome: boolean = SpaceStore.instance.allRoomsInHome;
|
||||
|
||||
constructor(private store: RoomListStoreClass) {
|
||||
if (!SpaceStore.spacesTweakAllRoomsEnabled) {
|
||||
this.filter = new SpaceFilterCondition();
|
||||
if (!this.allRoomsInHome || this.activeSpace) {
|
||||
this.updateFilter();
|
||||
store.addFilter(this.filter);
|
||||
}
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated);
|
||||
SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, this.onHomeBehaviourUpdated);
|
||||
}
|
||||
|
||||
private onSelectedSpaceUpdated = (activeSpace?: Room) => {
|
||||
this.activeSpace = activeSpace;
|
||||
private onSelectedSpaceUpdated = (activeSpace?: Room, allRoomsInHome = this.allRoomsInHome) => {
|
||||
if (activeSpace === this.activeSpace && allRoomsInHome === this.allRoomsInHome) return; // nop
|
||||
|
||||
if (this.filter) {
|
||||
if (activeSpace || !SpaceStore.spacesTweakAllRoomsEnabled) {
|
||||
this.updateFilter();
|
||||
} else {
|
||||
this.store.removeFilter(this.filter);
|
||||
this.filter = null;
|
||||
}
|
||||
} else if (activeSpace) {
|
||||
this.filter = new SpaceFilterCondition();
|
||||
const oldActiveSpace = this.activeSpace;
|
||||
const oldAllRoomsInHome = this.allRoomsInHome;
|
||||
this.activeSpace = activeSpace;
|
||||
this.allRoomsInHome = allRoomsInHome;
|
||||
|
||||
if (activeSpace || !allRoomsInHome) {
|
||||
this.updateFilter();
|
||||
this.store.addFilter(this.filter);
|
||||
}
|
||||
|
||||
if (oldAllRoomsInHome && !oldActiveSpace) {
|
||||
this.store.addFilter(this.filter);
|
||||
} else if (allRoomsInHome && !activeSpace) {
|
||||
this.store.removeFilter(this.filter);
|
||||
}
|
||||
};
|
||||
|
||||
private onHomeBehaviourUpdated = (allRoomsInHome: boolean) => {
|
||||
this.onSelectedSpaceUpdated(this.activeSpace, allRoomsInHome);
|
||||
};
|
||||
|
||||
private updateFilter = () => {
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import { IPreview } from "./IPreview";
|
||||
import { TagID } from "../models";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { _t, sanitizeForTranslation } from "../../../languageHandler";
|
||||
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
|
||||
import ReplyThread from "../../../components/views/elements/ReplyThread";
|
||||
import { getHtmlText } from "../../../HtmlUtils";
|
||||
|
@ -58,6 +58,8 @@ export class MessageEventPreview implements IPreview {
|
|||
body = getHtmlText(body);
|
||||
}
|
||||
|
||||
body = sanitizeForTranslation(body);
|
||||
|
||||
if (msgtype === 'm.emote') {
|
||||
return _t("* %(senderName)s %(emote)s", { senderName: getSenderName(event), emote: body });
|
||||
}
|
||||
|
|
|
@ -54,6 +54,7 @@ import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
|
|||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { ELEMENT_CLIENT_ID } from "../../identifiers";
|
||||
import { getUserLanguage } from "../../languageHandler";
|
||||
import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables";
|
||||
|
||||
// TODO: Destroy all of this code
|
||||
|
||||
|
@ -191,7 +192,8 @@ export class StopGapWidget extends EventEmitter {
|
|||
}
|
||||
|
||||
private runUrlTemplate(opts = { asPopout: false }): string {
|
||||
const templated = this.mockWidget.getCompleteUrl({
|
||||
const fromCustomisation = WidgetVariableCustomisations?.provideVariables?.() ?? {};
|
||||
const defaults: ITemplateParams = {
|
||||
widgetRoomId: this.roomId,
|
||||
currentUserId: MatrixClientPeg.get().getUserId(),
|
||||
userDisplayName: OwnProfileStore.instance.displayName,
|
||||
|
@ -199,7 +201,8 @@ export class StopGapWidget extends EventEmitter {
|
|||
clientId: ELEMENT_CLIENT_ID,
|
||||
clientTheme: SettingsStore.getValue("theme"),
|
||||
clientLanguage: getUserLanguage(),
|
||||
}, opts?.asPopout);
|
||||
};
|
||||
const templated = this.mockWidget.getCompleteUrl(Object.assign(defaults, fromCustomisation), opts?.asPopout);
|
||||
|
||||
const parsed = new URL(templated);
|
||||
|
||||
|
@ -363,6 +366,9 @@ export class StopGapWidget extends EventEmitter {
|
|||
}
|
||||
|
||||
public async prepare(): Promise<void> {
|
||||
// Ensure the variables are ready for us to be rendered before continuing
|
||||
await (WidgetVariableCustomisations?.isReady?.() ?? Promise.resolve());
|
||||
|
||||
if (this.scalarToken) return;
|
||||
const existingMessaging = WidgetMessagingStore.instance.getMessaging(this.mockWidget);
|
||||
if (existingMessaging) this.messaging = existingMessaging;
|
||||
|
|
13
src/theme.js
13
src/theme.js
|
@ -171,15 +171,10 @@ export async function setTheme(theme) {
|
|||
// look for the stylesheet elements.
|
||||
// styleElements is a map from style name to HTMLLinkElement.
|
||||
const styleElements = Object.create(null);
|
||||
let a;
|
||||
for (let i = 0; (a = document.getElementsByTagName("link")[i]); i++) {
|
||||
const href = a.getAttribute("href");
|
||||
// shouldn't we be using the 'title' tag rather than the href?
|
||||
const match = href && href.match(/^bundles\/.*\/theme-(.*)\.css$/);
|
||||
if (match) {
|
||||
styleElements[match[1]] = a;
|
||||
}
|
||||
}
|
||||
const themes = Array.from(document.querySelectorAll('[data-mx-theme]'));
|
||||
themes.forEach(theme => {
|
||||
styleElements[theme.attributes['data-mx-theme'].value.toLowerCase()] = theme;
|
||||
});
|
||||
|
||||
if (!(stylesheetName in styleElements)) {
|
||||
throw new Error("Unknown theme " + stylesheetName);
|
||||
|
|
140
src/toasts/IncomingCallToast.tsx
Normal file
140
src/toasts/IncomingCallToast.tsx
Normal file
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
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 React from 'react';
|
||||
import { CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import classNames from 'classnames';
|
||||
import { replaceableComponent } from '../utils/replaceableComponent';
|
||||
import CallHandler, { CallHandlerEvent } from '../CallHandler';
|
||||
import dis from '../dispatcher/dispatcher';
|
||||
import { MatrixClientPeg } from '../MatrixClientPeg';
|
||||
import { _t } from '../languageHandler';
|
||||
import RoomAvatar from '../components/views/avatars/RoomAvatar';
|
||||
import AccessibleTooltipButton from '../components/views/elements/AccessibleTooltipButton';
|
||||
import AccessibleButton from '../components/views/elements/AccessibleButton';
|
||||
|
||||
export const getIncomingCallToastKey = (callId: string) => `call_${callId}`;
|
||||
|
||||
interface IProps {
|
||||
call: MatrixCall;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
silenced: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.voip.IncomingCallToast")
|
||||
export default class IncomingCallToast extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
silenced: CallHandler.sharedInstance().isCallSilenced(this.props.call.callId),
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount = (): void => {
|
||||
CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
|
||||
};
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
CallHandler.sharedInstance().removeListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
|
||||
}
|
||||
|
||||
private onSilencedCallsChanged = (): void => {
|
||||
this.setState({ silenced: CallHandler.sharedInstance().isCallSilenced(this.props.call.callId) });
|
||||
};
|
||||
|
||||
private onAnswerClick = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
dis.dispatch({
|
||||
action: 'answer',
|
||||
room_id: CallHandler.sharedInstance().roomIdForCall(this.props.call),
|
||||
});
|
||||
};
|
||||
|
||||
private onRejectClick= (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
dis.dispatch({
|
||||
action: 'reject',
|
||||
room_id: CallHandler.sharedInstance().roomIdForCall(this.props.call),
|
||||
});
|
||||
};
|
||||
|
||||
private onSilenceClick = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
const callId = this.props.call.callId;
|
||||
this.state.silenced ?
|
||||
CallHandler.sharedInstance().unSilenceCall(callId) :
|
||||
CallHandler.sharedInstance().silenceCall(callId);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const call = this.props.call;
|
||||
const room = MatrixClientPeg.get().getRoom(CallHandler.sharedInstance().roomIdForCall(call));
|
||||
const isVoice = call.type === CallType.Voice;
|
||||
|
||||
const contentClass = classNames("mx_IncomingCallToast_content", {
|
||||
"mx_IncomingCallToast_content_voice": isVoice,
|
||||
"mx_IncomingCallToast_content_video": !isVoice,
|
||||
});
|
||||
const silenceClass = classNames("mx_IncomingCallToast_iconButton", {
|
||||
"mx_IncomingCallToast_unSilence": this.state.silenced,
|
||||
"mx_IncomingCallToast_silence": !this.state.silenced,
|
||||
});
|
||||
|
||||
return <React.Fragment>
|
||||
<RoomAvatar
|
||||
room={room}
|
||||
height={32}
|
||||
width={32}
|
||||
/>
|
||||
<div className={contentClass}>
|
||||
<span className="mx_CallEvent_caller">
|
||||
{ room ? room.name : _t("Unknown caller") }
|
||||
</span>
|
||||
<div className="mx_CallEvent_type">
|
||||
<div className="mx_CallEvent_type_icon" />
|
||||
{ isVoice ? _t("Voice call") : _t("Video call") }
|
||||
</div>
|
||||
<div className="mx_IncomingCallToast_buttons">
|
||||
<AccessibleButton
|
||||
className="mx_IncomingCallToast_button mx_IncomingCallToast_button_decline"
|
||||
onClick={this.onRejectClick}
|
||||
kind="danger"
|
||||
>
|
||||
<span> { _t("Decline") } </span>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
className="mx_IncomingCallToast_button mx_IncomingCallToast_button_accept"
|
||||
onClick={this.onAnswerClick}
|
||||
kind="primary"
|
||||
>
|
||||
<span> { _t("Accept") } </span>
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
<AccessibleTooltipButton
|
||||
className={silenceClass}
|
||||
onClick={this.onSilenceClick}
|
||||
title={this.state.silenced ? _t("Sound on") : _t("Silence call")}
|
||||
/>
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
102
src/utils/FileDownloader.ts
Normal file
102
src/utils/FileDownloader.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
export type getIframeFn = () => HTMLIFrameElement; // eslint-disable-line @typescript-eslint/naming-convention
|
||||
|
||||
export const DEFAULT_STYLES = {
|
||||
imgSrc: "",
|
||||
imgStyle: null, // css props
|
||||
style: "",
|
||||
textContent: "",
|
||||
};
|
||||
|
||||
type DownloadOptions = {
|
||||
blob: Blob;
|
||||
name: string;
|
||||
autoDownload?: boolean;
|
||||
opts?: typeof DEFAULT_STYLES;
|
||||
};
|
||||
|
||||
// set up the iframe as a singleton so we don't have to figure out destruction of it down the line.
|
||||
let managedIframe: HTMLIFrameElement;
|
||||
let onLoadPromise: Promise<void>;
|
||||
function getManagedIframe(): { iframe: HTMLIFrameElement, onLoadPromise: Promise<void> } {
|
||||
if (managedIframe) return { iframe: managedIframe, onLoadPromise };
|
||||
|
||||
managedIframe = document.createElement("iframe");
|
||||
|
||||
// Need to append the iframe in order for the browser to load it.
|
||||
document.body.appendChild(managedIframe);
|
||||
|
||||
// Dev note: the reassignment warnings are entirely incorrect here.
|
||||
|
||||
managedIframe.style.display = "none";
|
||||
|
||||
// @ts-ignore
|
||||
// noinspection JSConstantReassignment
|
||||
managedIframe.sandbox = "allow-scripts allow-downloads allow-downloads-without-user-activation";
|
||||
|
||||
onLoadPromise = new Promise(resolve => {
|
||||
managedIframe.onload = () => {
|
||||
resolve();
|
||||
};
|
||||
managedIframe.src = "usercontent/"; // XXX: Should come from the skin
|
||||
});
|
||||
|
||||
return { iframe: managedIframe, onLoadPromise };
|
||||
}
|
||||
|
||||
// TODO: If we decide to keep the download link behaviour, we should bring the style management into here.
|
||||
|
||||
/**
|
||||
* Helper to handle safe file downloads. This operates off an iframe for reasons described
|
||||
* by the blob helpers. By default, this will use a hidden iframe to manage the download
|
||||
* through a user content wrapper, but can be given an iframe reference if the caller needs
|
||||
* additional control over the styling/position of the iframe itself.
|
||||
*/
|
||||
export class FileDownloader {
|
||||
private onLoadPromise: Promise<void>;
|
||||
|
||||
/**
|
||||
* Creates a new file downloader
|
||||
* @param iframeFn Function to get a pre-configured iframe. Set to null to have the downloader
|
||||
* use a generic, hidden, iframe.
|
||||
*/
|
||||
constructor(private iframeFn: getIframeFn = null) {
|
||||
}
|
||||
|
||||
private get iframe(): HTMLIFrameElement {
|
||||
const iframe = this.iframeFn?.();
|
||||
if (!iframe) {
|
||||
const managed = getManagedIframe();
|
||||
this.onLoadPromise = managed.onLoadPromise;
|
||||
return managed.iframe;
|
||||
}
|
||||
this.onLoadPromise = null;
|
||||
return iframe;
|
||||
}
|
||||
|
||||
public async download({ blob, name, autoDownload = true, opts = DEFAULT_STYLES }: DownloadOptions) {
|
||||
const iframe = this.iframe; // get the iframe first just in case we need to await onload
|
||||
if (this.onLoadPromise) await this.onLoadPromise;
|
||||
iframe.contentWindow.postMessage({
|
||||
...opts,
|
||||
blob: blob,
|
||||
download: name,
|
||||
auto: autoDownload,
|
||||
}, '*');
|
||||
}
|
||||
}
|
|
@ -26,12 +26,14 @@ import { _t } from '../languageHandler';
|
|||
* @param {IMediaEventContent} content The "content" key of the matrix event.
|
||||
* @param {string} fallbackText The fallback text
|
||||
* @param {boolean} withSize Whether to include size information. Default true.
|
||||
* @param {boolean} shortened Ensure the extension of the file name is visible. Default false.
|
||||
* @return {string} the human readable link text for the attachment.
|
||||
*/
|
||||
export function presentableTextForFile(
|
||||
content: IMediaEventContent,
|
||||
fallbackText = _t("Attachment"),
|
||||
withSize = true,
|
||||
shortened = false,
|
||||
): string {
|
||||
let text = fallbackText;
|
||||
if (content.body && content.body.length > 0) {
|
||||
|
@ -40,6 +42,21 @@ export function presentableTextForFile(
|
|||
text = content.body;
|
||||
}
|
||||
|
||||
// We shorten to 15 characters somewhat arbitrarily, and assume most files
|
||||
// will have a 3 character (plus full stop) extension. The goal is to knock
|
||||
// the label down to 15-25 characters, not perfect accuracy.
|
||||
if (shortened && text.length > 19) {
|
||||
const parts = text.split('.');
|
||||
let fileName = parts.slice(0, parts.length - 1).join('.').substring(0, 15);
|
||||
const extension = parts[parts.length - 1];
|
||||
|
||||
// Trim off any full stops from the file name to avoid a case where we
|
||||
// add an ellipsis that looks really funky.
|
||||
fileName = fileName.replace(/\.*$/g, '');
|
||||
|
||||
text = `${fileName}...${extension}`;
|
||||
}
|
||||
|
||||
if (content.info && content.info.size && withSize) {
|
||||
// If we know the size of the file then add it as human readable
|
||||
// string to the end of the link text so that the user knows how
|
||||
|
|
|
@ -141,21 +141,3 @@ export function objectKeyChanges<O extends {}>(a: O, b: O): (keyof O)[] {
|
|||
export function objectClone<O extends {}>(obj: O): O {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a series of entries to an object.
|
||||
* @param entries The entries to convert.
|
||||
* @returns The converted object.
|
||||
*/
|
||||
// NOTE: Deprecated once we have Object.fromEntries() support.
|
||||
// @ts-ignore - return type is complaining about non-string keys, but we know better
|
||||
export function objectFromEntries<K, V>(entries: Iterable<[K, V]>): {[k: K]: V} {
|
||||
const obj: {
|
||||
// @ts-ignore - same as return type
|
||||
[k: K]: V;} = {};
|
||||
for (const e of entries) {
|
||||
// @ts-ignore - same as return type
|
||||
obj[e[0]] = e[1];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
|
|
@ -28,6 +28,15 @@ import { _t } from "../languageHandler";
|
|||
import SpacePublicShare from "../components/views/spaces/SpacePublicShare";
|
||||
import InfoDialog from "../components/views/dialogs/InfoDialog";
|
||||
import { showRoomInviteDialog } from "../RoomInvite";
|
||||
import CreateSubspaceDialog from "../components/views/dialogs/CreateSubspaceDialog";
|
||||
import AddExistingSubspaceDialog from "../components/views/dialogs/AddExistingSubspaceDialog";
|
||||
import defaultDispatcher from "../dispatcher/dispatcher";
|
||||
import RoomViewStore from "../stores/RoomViewStore";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
import { leaveRoomBehaviour } from "./membership";
|
||||
import Spinner from "../components/views/elements/Spinner";
|
||||
import dis from "../dispatcher/dispatcher";
|
||||
import LeaveSpaceDialog from "../components/views/dialogs/LeaveSpaceDialog";
|
||||
|
||||
export const shouldShowSpaceSettings = (space: Room) => {
|
||||
const userId = space.client.getUserId();
|
||||
|
@ -54,21 +63,26 @@ export const showSpaceSettings = (space: Room) => {
|
|||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
};
|
||||
|
||||
export const showAddExistingRooms = async (space: Room) => {
|
||||
return Modal.createTrackedDialog(
|
||||
export const showAddExistingRooms = (space: Room): void => {
|
||||
Modal.createTrackedDialog(
|
||||
"Space Landing",
|
||||
"Add Existing",
|
||||
AddExistingToSpaceDialog,
|
||||
{
|
||||
matrixClient: space.client,
|
||||
onCreateRoomClick: showCreateNewRoom,
|
||||
onCreateRoomClick: () => showCreateNewRoom(space),
|
||||
onAddSubspaceClick: () => showAddExistingSubspace(space),
|
||||
space,
|
||||
onFinished: (added: boolean) => {
|
||||
if (added && RoomViewStore.getRoomId() === space.roomId) {
|
||||
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
||||
}
|
||||
},
|
||||
},
|
||||
"mx_AddExistingToSpaceDialog_wrapper",
|
||||
).finished;
|
||||
);
|
||||
};
|
||||
|
||||
export const showCreateNewRoom = async (space: Room) => {
|
||||
export const showCreateNewRoom = async (space: Room): Promise<boolean> => {
|
||||
const modal = Modal.createTrackedDialog<[boolean, IOpts]>(
|
||||
"Space Landing",
|
||||
"Create Room",
|
||||
|
@ -85,7 +99,7 @@ export const showCreateNewRoom = async (space: Room) => {
|
|||
return shouldCreate;
|
||||
};
|
||||
|
||||
export const showSpaceInvite = (space: Room, initialText = "") => {
|
||||
export const showSpaceInvite = (space: Room, initialText = ""): void => {
|
||||
if (space.getJoinRule() === "public") {
|
||||
const modal = Modal.createTrackedDialog("Space Invite", "User Menu", InfoDialog, {
|
||||
title: _t("Invite to %(spaceName)s", { spaceName: space.name }),
|
||||
|
@ -102,3 +116,60 @@ export const showSpaceInvite = (space: Room, initialText = "") => {
|
|||
showRoomInviteDialog(space.roomId, initialText);
|
||||
}
|
||||
};
|
||||
|
||||
export const showAddExistingSubspace = (space: Room): void => {
|
||||
Modal.createTrackedDialog(
|
||||
"Space Landing",
|
||||
"Create Subspace",
|
||||
AddExistingSubspaceDialog,
|
||||
{
|
||||
space,
|
||||
onCreateSubspaceClick: () => showCreateNewSubspace(space),
|
||||
onFinished: (added: boolean) => {
|
||||
if (added && RoomViewStore.getRoomId() === space.roomId) {
|
||||
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
||||
}
|
||||
},
|
||||
},
|
||||
"mx_AddExistingToSpaceDialog_wrapper",
|
||||
);
|
||||
};
|
||||
|
||||
export const showCreateNewSubspace = (space: Room): void => {
|
||||
Modal.createTrackedDialog(
|
||||
"Space Landing",
|
||||
"Create Subspace",
|
||||
CreateSubspaceDialog,
|
||||
{
|
||||
space,
|
||||
onAddExistingSpaceClick: () => showAddExistingSubspace(space),
|
||||
onFinished: (added: boolean) => {
|
||||
if (added && RoomViewStore.getRoomId() === space.roomId) {
|
||||
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
||||
}
|
||||
},
|
||||
},
|
||||
"mx_CreateSubspaceDialog_wrapper",
|
||||
);
|
||||
};
|
||||
|
||||
export const leaveSpace = (space: Room) => {
|
||||
Modal.createTrackedDialog("Leave Space", "", LeaveSpaceDialog, {
|
||||
space,
|
||||
onFinished: async (leave: boolean, rooms: Room[]) => {
|
||||
if (!leave) return;
|
||||
const modal = Modal.createDialog(Spinner, null, "mx_Dialog_spinner");
|
||||
try {
|
||||
await Promise.all(rooms.map(r => leaveRoomBehaviour(r.roomId)));
|
||||
await leaveRoomBehaviour(space.roomId);
|
||||
} finally {
|
||||
modal.close();
|
||||
}
|
||||
|
||||
dis.dispatch({
|
||||
action: "after_leave_room",
|
||||
room_id: space.roomId,
|
||||
});
|
||||
},
|
||||
}, "mx_LeaveSpaceDialog_wrapper");
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue