Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/18089

 Conflicts:
	res/css/structures/_SpaceHierarchy.scss
	src/components/structures/SpaceHierarchy.tsx
	src/i18n/strings/en_EN.json
This commit is contained in:
Michael Telatynski 2021-08-12 11:41:03 +01:00
commit 0a209afdc2
196 changed files with 8563 additions and 2976 deletions

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import RoomViewStore from './stores/RoomViewStore';
import { EventSubscription } from 'fbemitter';
import RoomViewStore from './stores/RoomViewStore';
type Listener = (isActive: boolean) => void;

View file

@ -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,26 +482,44 @@ 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);
if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) {
this.play(AudioID.Busy);
// Don't show a modal when we got rejected/the call was hung up
if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break;
let title;
let description;
// TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...)
if (call.hangupReason === CallErrorCode.UserBusy) {
title = _t("User Busy");
description = _t("The user you called is busy.");
} else if (hangupReason && ![CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) {
} else {
title = _t("Call Failed");
description = _t("The call could not be established");
}
@ -624,6 +648,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 +951,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 +1032,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;
}

View file

@ -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;
}
/**

View file

@ -123,6 +123,19 @@ export function formatTime(date: Date, showTwelveHour = false): string {
return pad(date.getHours()) + ':' + pad(date.getMinutes());
}
export function formatCallTime(delta: Date): string {
const hours = delta.getUTCHours();
const minutes = delta.getUTCMinutes();
const seconds = delta.getUTCSeconds();
let output = "";
if (hours) output += `${hours}h `;
if (minutes || output) output += `${minutes}m `;
if (seconds || output) output += `${seconds}s`;
return output;
}
const MILLIS_IN_DAY = 86400000;
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
if (!nextEventDate || !prevEventDate) {

View file

@ -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)\/(.+?)\/(.+?)(?:[?/]|$)/;

View file

@ -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) {

View file

@ -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
View 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);
}
}
}

View file

@ -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 = {

View file

@ -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],

View file

@ -150,13 +150,14 @@ const reducer = (state: IState, action: IAction) => {
interface IProps {
handleHomeEnd?: boolean;
handleUpDown?: boolean;
children(renderProps: {
onKeyDownHandler(ev: React.KeyboardEvent);
});
onKeyDown?(ev: React.KeyboardEvent, state: IState);
}
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, onKeyDown }) => {
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => {
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
activeRef: null,
refs: [],
@ -167,21 +168,50 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
const onKeyDownHandler = useCallback((ev) => {
let handled = false;
// Don't interfere with input default keydown behaviour
if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
// check if we actually have any items
switch (ev.key) {
case Key.HOME:
handled = true;
// move focus to first item
if (context.state.refs.length > 0) {
context.state.refs[0].current.focus();
if (handleHomeEnd) {
handled = true;
// move focus to first item
if (context.state.refs.length > 0) {
context.state.refs[0].current.focus();
}
}
break;
case Key.END:
handled = true;
// move focus to last item
if (context.state.refs.length > 0) {
context.state.refs[context.state.refs.length - 1].current.focus();
if (handleHomeEnd) {
handled = true;
// move focus to last item
if (context.state.refs.length > 0) {
context.state.refs[context.state.refs.length - 1].current.focus();
}
}
break;
case Key.ARROW_UP:
if (handleUpDown) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
if (idx > 0) {
context.state.refs[idx - 1].current.focus();
}
}
}
break;
case Key.ARROW_DOWN:
if (handleUpDown) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
if (idx < context.state.refs.length - 1) {
context.state.refs[idx + 1].current.focus();
}
}
}
break;
}
@ -193,7 +223,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
} else if (onKeyDown) {
return onKeyDown(ev, context.state);
}
}, [context.state, onKeyDown, handleHomeEnd]);
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
return <RovingTabIndexContext.Provider value={context}>
{ children({ onKeyDownHandler }) }

View file

@ -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 {

View file

@ -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);

View file

@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
style={style}
className={["mx_AutoHideScrollbar", className].join(" ")}
onWheel={onWheel}
tabIndex={tabIndex}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order by default.
tabIndex={tabIndex ?? -1}
>
{ children }
</div>);

View file

@ -27,9 +27,15 @@ export enum CallEventGrouperEvent {
SilencedChanged = "silenced_changed",
}
const CONNECTING_STATES = [
CallState.Connecting,
CallState.WaitLocalMedia,
CallState.CreateOffer,
CallState.CreateAnswer,
];
const SUPPORTED_STATES = [
CallState.Connected,
CallState.Connecting,
CallState.Ringing,
];
@ -61,6 +67,10 @@ export default class CallEventGrouper extends EventEmitter {
return [...this.events].find((event) => event.getType() === EventType.CallReject);
}
private get selectAnswer(): MatrixEvent {
return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer);
}
public get isVoice(): boolean {
const invite = this.invite;
if (!invite) return;
@ -82,6 +92,11 @@ export default class CallEventGrouper extends EventEmitter {
return Boolean(this.reject);
}
public get duration(): Date {
if (!this.hangup || !this.selectAnswer) return;
return new Date(this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime());
}
/**
* Returns true if there are only events from the other side - we missed the call
*/
@ -127,7 +142,9 @@ export default class CallEventGrouper extends EventEmitter {
}
private setState = () => {
if (SUPPORTED_STATES.includes(this.call?.state)) {
if (CONNECTING_STATES.includes(this.call?.state)) {
this.state = CallState.Connecting;
} else if (SUPPORTED_STATES.includes(this.call?.state)) {
this.state = this.call.state;
} else {
if (this.callWasMissed) this.state = CustomCallState.Missed;

View file

@ -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";
@ -471,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);
};

View file

@ -41,6 +41,9 @@ import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { mediaFromMxc } from "../../customisations/Media";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { createSpaceFromCommunity } from "../../utils/space";
import { Action } from "../../dispatcher/actions";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
@ -399,6 +402,8 @@ class FeaturedUser extends React.Component {
const GROUP_JOINPOLICY_OPEN = "open";
const GROUP_JOINPOLICY_INVITE = "invite";
const UPGRADE_NOTICE_LS_KEY = "mx_hide_community_upgrade_notice";
@replaceableComponent("structures.GroupView")
export default class GroupView extends React.Component {
static propTypes = {
@ -422,6 +427,7 @@ export default class GroupView extends React.Component {
publicityBusy: false,
inviterProfile: null,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
showUpgradeNotice: !localStorage.getItem(UPGRADE_NOTICE_LS_KEY),
};
componentDidMount() {
@ -807,6 +813,22 @@ export default class GroupView extends React.Component {
showGroupAddRoomDialog(this.props.groupId);
};
_dismissUpgradeNotice = () => {
localStorage.setItem(UPGRADE_NOTICE_LS_KEY, "true");
this.setState({ showUpgradeNotice: false });
}
_onCreateSpaceClick = () => {
createSpaceFromCommunity(this._matrixClient, this.props.groupId);
};
_onAdminsLinkClick = () => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.GroupMemberList,
});
};
_getGroupSection() {
const groupSettingsSectionClasses = classnames({
"mx_GroupView_group": this.state.editing,
@ -843,10 +865,46 @@ export default class GroupView extends React.Component {
},
) }
</div> : <div />;
let communitiesUpgradeNotice;
if (this.state.showUpgradeNotice) {
let text;
if (this.state.isUserPrivileged) {
text = _t("You can create a Space from this community <a>here</a>.", {}, {
a: sub => <AccessibleButton onClick={this._onCreateSpaceClick} kind="link">
{ sub }
</AccessibleButton>,
});
} else {
text = _t("Ask the <a>admins</a> of this community to make it into a Space " +
"and keep a look out for the invite.", {}, {
a: sub => <AccessibleButton onClick={this._onAdminsLinkClick} kind="link">
{ sub }
</AccessibleButton>,
});
}
communitiesUpgradeNotice = <div className="mx_GroupView_spaceUpgradePrompt">
<h2>{ _t("Communities can now be made into Spaces") }</h2>
<p>
{ _t("Spaces are a new way to make a community, with new features coming.") }
&nbsp;
{ text }
&nbsp;
{ _t("Communities won't receive further updates.") }
</p>
<AccessibleButton
className="mx_GroupView_spaceUpgradePrompt_close"
onClick={this._dismissUpgradeNotice}
/>
</div>;
}
return <div className={groupSettingsSectionClasses}>
{ header }
{ hostingSignup }
{ changeDelayWarning }
{ communitiesUpgradeNotice }
{ this._getJoinableNode() }
{ this._getLongDescriptionNode() }
{ this._getRoomsNode() }

View file

@ -392,9 +392,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
<IndicatorScrollbar
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
verticalScrollsHorizontally={true}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
<RoomBreadcrumbs />
</IndicatorScrollbar>

View file

@ -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);

View file

@ -51,7 +51,12 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl];
const groupedEvents = [
EventType.RoomMember,
EventType.RoomThirdPartyInvite,
EventType.RoomServerAcl,
EventType.RoomPinnedEvents,
];
// check if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
@ -1234,7 +1239,7 @@ class RedactionGrouper extends BaseGrouper {
// Wrap consecutive member events in a ListSummary, ignore if redacted
class MemberGrouper extends BaseGrouper {
static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType);
return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType);
};
constructor(
@ -1252,7 +1257,7 @@ class MemberGrouper extends BaseGrouper {
if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
return false;
}
return membershipTypes.includes(ev.getType() as EventType);
return groupedEvents.includes(ev.getType() as EventType);
}
public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {

View file

@ -14,7 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
KeyboardEvent,
KeyboardEventHandler,
} from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
@ -41,6 +50,8 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils";
import { useDispatcher } from "../../hooks/useDispatcher";
import { Action } from "../../dispatcher/actions";
import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
import { getDisplayAliasForRoom } from "./RoomDirectory";
interface IProps {
@ -76,6 +87,7 @@ const Tile: React.FC<ITileProps> = ({
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex();
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
@ -90,11 +102,21 @@ const Tile: React.FC<ITileProps> = ({
let button;
if (joinedRoom) {
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
button = <AccessibleButton
onClick={onPreviewClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
button = <AccessibleButton onClick={onJoinClick} kind="primary">
button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("Join") }
</AccessibleButton>;
}
@ -102,13 +124,13 @@ const Tile: React.FC<ITileProps> = ({
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation(); }}
>
<StyledCheckbox disabled={true} />
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>;
}
}
@ -168,8 +190,9 @@ const Tile: React.FC<ITileProps> = ({
</div>
</React.Fragment>;
let childToggle;
let childSection;
let childToggle: JSX.Element;
let childSection: JSX.Element;
let onKeyDown: KeyboardEventHandler;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
@ -181,25 +204,74 @@ const Tile: React.FC<ITileProps> = ({
toggleShowChildren();
}}
/>;
if (showChildren) {
childSection = <div className="mx_SpaceHierarchy_subspace_children">
const onChildrenKeyDown = (e) => {
if (e.key === Key.ARROW_LEFT) {
e.preventDefault();
e.stopPropagation();
ref.current?.focus();
}
};
childSection = <div
className="mx_SpaceHierarchy_subspace_children"
onKeyDown={onChildrenKeyDown}
role="group"
>
{ children }
</div>;
}
onKeyDown = (e) => {
let handled = false;
switch (e.key) {
case Key.ARROW_LEFT:
if (showChildren) {
handled = true;
toggleShowChildren();
}
break;
case Key.ARROW_RIGHT:
handled = true;
if (showChildren) {
const childSection = ref.current?.nextElementSibling;
childSection?.querySelector<HTMLDivElement>(".mx_SpaceRoomDirectory_roomTile")?.focus();
} else {
toggleShowChildren();
}
break;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
}
return <>
return <li
className="mx_SpaceRoomDirectory_roomTileWrapper"
role="treeitem"
aria-expanded={children ? showChildren : undefined}
>
<AccessibleButton
className={classNames("mx_SpaceHierarchy_roomTile", {
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ content }
{ childToggle }
</AccessibleButton>
{ childSection }
</>;
</li>;
};
export const showRoom = (hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => {
@ -439,165 +511,181 @@ const SpaceHierarchy = ({
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
}
let content: JSX.Element;
let loader: JSX.Element;
if (loading && !rooms.length) {
content = <Spinner />;
} else {
let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
});
const onKeyDown = (ev: KeyboardEvent, state: IState) => {
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
state.refs[0]?.current?.focus();
}
};
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return hierarchy.isSuggested(parentId, childId);
});
// TODO loading state/error state
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => {
let content: JSX.Element;
let loader: JSX.Element;
const disabled = !selectedRelations.length || removing || saving;
if (loading && !rooms.length) {
content = <Spinner />;
} else {
let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [
...selected.get(parentId).values(),
].map(childId => [parentId, childId]) as [string, string][];
});
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return hierarchy.isSuggested(parentId, childId);
});
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
const disabled = !selectedRelations.length || removing || saving;
hierarchy.removeRelation(parentId, childId);
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
setSelected(new Map());
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
// mutate the local state to save us having to refetch the world
existingContent.suggested = content.suggested;
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
</Button>
</>;
}
let results: JSX.Element;
if (filteredRoomSet.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
results = <>
<HierarchyLevel
root={hierarchy.roomMap.get(space.roomId)}
roomSet={filteredRoomSet}
hierarchy={hierarchy}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
hierarchy.removeRelation(parentId, childId);
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
setSelected(new Map());
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(hierarchy, roomId, autoJoin);
}}
/>
</>;
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
// mutate the local state to save us having to refetch the world
existingContent.suggested = content.suggested;
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
}
let results: JSX.Element;
if (filteredRoomSet.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
results = <>
<HierarchyLevel
root={hierarchy.roomMap.get(space.roomId)}
roomSet={filteredRoomSet}
hierarchy={hierarchy}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(hierarchy, roomId, autoJoin);
}}
/>
</>;
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
}
} else {
results = <div className="mx_SpaceHierarchy_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceHierarchy_listHeader">
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceHierarchy_error">
{ error }
</div> }
<ul onKeyDown={onKeyDownHandler} role="tree" aria-label={_t("Space")}>
{ results }
</ul>
{ loader }
</>;
}
} else {
results = <div className="mx_SpaceHierarchy_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceHierarchy_listHeader">
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceHierarchy_error">
{ error }
</div> }
return <>
<SearchBox
className="mx_SpaceRoomDirectory_search mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
onKeyDown={onKeyDownHandler}
/>
{ results }
{ loader }
</>;
}
return <>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
/>
{ content }
</>;
{ content }
</>;
} }
</RovingTabIndexProvider>;
};
export default SpaceHierarchy;

View file

@ -74,6 +74,10 @@ import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
interface IProps {
space: Room;
@ -158,7 +162,33 @@ const onBetaClick = () => {
});
};
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
// XXX: temporary community migration component
const GroupTile = ({ groupId }: { groupId: string }) => {
const cli = useContext(MatrixClientContext);
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
if (!groupSummary) return <Spinner />;
return <>
<GroupAvatar
groupId={groupId}
groupName={groupSummary.profile.name}
groupAvatarUrl={groupSummary.profile.avatar_url}
width={16}
height={16}
resizeMethod='crop'
/>
{ groupSummary.profile.name }
</>;
};
interface ISpacePreviewProps {
space: Room;
onJoinButtonClicked(): void;
onRejectButtonClicked(): void;
}
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
@ -192,11 +222,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">
@ -270,8 +300,18 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</div>;
}
let migratedCommunitySection: JSX.Element;
const createContent = space.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
if (createContent[CreateEventField]) {
migratedCommunitySection = <div className="mx_SpaceRoomView_preview_migratedCommunity">
{ _t("Created from <Community />", {}, {
Community: () => <GroupTile groupId={createContent[CreateEventField]} />,
}) }
</div>;
}
return <div className="mx_SpaceRoomView_preview">
<BetaPill onClick={onBetaClick} />
{ migratedCommunitySection }
{ inviterSection }
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<h1 className="mx_SpaceRoomView_preview_name">

View file

@ -757,16 +757,20 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
this.lastRMSentEventId = this.state.readMarkerEventId;
const roomId = this.props.timelineSet.room.roomId;
const hiddenRR = SettingsStore.getValue("feature_hidden_read_receipts", roomId);
debuglog('TimelinePanel: Sending Read Markers for ',
this.props.timelineSet.room.roomId,
'rm', this.state.readMarkerEventId,
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
' hidden:' + hiddenRR,
);
MatrixClientPeg.get().setRoomReadMarkers(
this.props.timelineSet.room.roomId,
roomId,
this.state.readMarkerEventId,
lastReadEvent, // Could be null, in which case no RR is sent
{},
{ hidden: hiddenRR },
).catch((e) => {
// /read_markers API is not implemented on this HS, fallback to just RR
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {

View file

@ -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,

View file

@ -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();
});
}

View file

@ -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>

View file

@ -86,7 +86,10 @@ 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>;
};

View 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;

View file

@ -24,6 +24,8 @@ import { MenuItem } from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
import { createSpaceFromCommunity } from "../../../utils/space";
import GroupStore from "../../../stores/GroupStore";
@replaceableComponent("views.context_menus.TagTileContextMenu")
export default class TagTileContextMenu extends React.Component {
@ -49,6 +51,11 @@ export default class TagTileContextMenu extends React.Component {
this.props.onFinished();
};
_onCreateSpaceClick = () => {
createSpaceFromCommunity(this.context, this.props.tag);
this.props.onFinished();
};
_onMoveUp = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
this.props.onFinished();
@ -77,6 +84,16 @@ export default class TagTileContextMenu extends React.Component {
);
}
let createSpaceOption;
if (GroupStore.isUserPrivileged(this.props.tag)) {
createSpaceOption = <>
<hr className="mx_TagTileContextMenu_separator" role="separator" />
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_createSpace" onClick={this._onCreateSpaceClick}>
{ _t("Create Space") }
</MenuItem>
</>;
}
return <div>
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
{ _t('View Community') }
@ -88,6 +105,7 @@ export default class TagTileContextMenu extends React.Component {
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
{ _t("Unpin") }
</MenuItem>
{ createSpaceOption }
</div>;
}
}

View file

@ -116,8 +116,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
}
opts.parentSpace = this.props.parentSpace;
if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) {
opts.parentSpace = this.props.parentSpace;
opts.joinRule = JoinRule.Restricted;
}

View file

@ -0,0 +1,340 @@
/*
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, useRef, useState } from "react";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import { GroupMember } from "../right_panel/UserInfo";
import { parseMembersResponse, parseRoomsResponse } from "../../../stores/GroupStore";
import { calculateRoomVia, makeRoomPermalink } from "../../../utils/permalinks/Permalinks";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import Spinner from "../elements/Spinner";
import { mediaFromMxc } from "../../../customisations/Media";
import SpaceStore from "../../../stores/SpaceStore";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog";
import TagOrderActions from "../../../actions/TagOrderActions";
interface IProps {
matrixClient: MatrixClient;
groupId: string;
onFinished(spaceId?: string): void;
}
export const CreateEventField = "io.element.migrated_from_community";
interface IGroupRoom {
displayname: string;
name?: string;
roomId: string;
canonicalAlias?: string;
avatarUrl?: string;
topic?: string;
numJoinedMembers?: number;
worldReadable?: boolean;
guestCanJoin?: boolean;
isPublic?: boolean;
}
/* eslint-disable camelcase */
export interface IGroupSummary {
profile: {
avatar_url?: string;
is_openly_joinable?: boolean;
is_public?: boolean;
long_description: string;
name: string;
short_description: string;
};
rooms_section: {
rooms: unknown[];
categories: Record<string, unknown>;
total_room_count_estimate: number;
};
user: {
is_privileged: boolean;
is_public: boolean;
is_publicised: boolean;
membership: string;
};
users_section: {
users: unknown[];
roles: Record<string, unknown>;
total_user_count_estimate: number;
};
}
/* eslint-enable camelcase */
const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, groupId, onFinished }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>(null);
const [busy, setBusy] = useState(false);
const [avatar, setAvatar] = useState<File>(null); // undefined means to remove avatar
const [name, setName] = useState("");
const spaceNameField = useRef<Field>();
const [alias, setAlias] = useState("#" + groupId.substring(1, groupId.indexOf(":")) + ":" + cli.getDomain());
const spaceAliasField = useRef<RoomAliasField>();
const [topic, setTopic] = useState("");
const [joinRule, setJoinRule] = useState<JoinRule>(JoinRule.Public);
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [groupId]);
useEffect(() => {
if (groupSummary) {
setName(groupSummary.profile.name || "");
setTopic(groupSummary.profile.short_description || "");
setJoinRule(groupSummary.profile.is_openly_joinable ? JoinRule.Public : JoinRule.Invite);
setLoading(false);
}
}, [groupSummary]);
if (loading) {
return <Spinner />;
}
const onCreateSpaceClick = async (e) => {
e.preventDefault();
if (busy) return;
setError(null);
setBusy(true);
// require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
setBusy(false);
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
return;
}
// validate the space name alias field but do not require it
if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
setBusy(false);
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
return;
}
try {
const [rooms, members, invitedMembers] = await Promise.all([
cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise<IGroupRoom[]>,
cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
]);
const viaMap = new Map<string, string[]>();
for (const { roomId, canonicalAlias } of rooms) {
const room = cli.getRoom(roomId);
if (room) {
viaMap.set(roomId, calculateRoomVia(room));
} else if (canonicalAlias) {
try {
const { servers } = await cli.getRoomIdForAlias(canonicalAlias);
viaMap.set(roomId, servers);
} catch (e) {
console.warn("Failed to resolve alias during community migration", e);
}
}
if (!viaMap.get(roomId)?.length) {
// XXX: lets guess the via, this might end up being incorrect.
const str = canonicalAlias || roomId;
viaMap.set(roomId, [str.substring(1, str.indexOf(":"))]);
}
}
const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url;
const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, {
creation_content: {
[CreateEventField]: groupId,
},
initial_state: rooms.map(({ roomId }) => ({
type: EventType.SpaceChild,
state_key: roomId,
content: {
via: viaMap.get(roomId) || [],
},
})),
invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()),
}, {
andView: false,
});
// eagerly remove it from the community panel
dis.dispatch(TagOrderActions.removeTag(cli, groupId));
// don't bother awaiting this, as we don't hugely care if it fails
cli.setGroupProfile(groupId, {
...groupSummary.profile,
long_description: `<a href="${makeRoomPermalink(roomId)}"><h1>` +
_t("This community has been upgraded into a Space") + `</h1></a><br />`
+ groupSummary.profile.long_description,
} as IGroupSummary["profile"]).catch(e => {
console.warn("Failed to update community profile during migration", e);
});
onFinished(roomId);
const onSpaceClick = () => {
dis.dispatch({
action: "view_room",
room_id: roomId,
});
};
const onPreferencesClick = () => {
dis.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Preferences,
});
};
let spacesDisabledCopy;
if (!SpaceStore.spacesEnabled) {
spacesDisabledCopy = _t("To view Spaces, hide communities in <a>Preferences</a>", {}, {
a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
});
}
Modal.createDialog(InfoDialog, {
title: _t("Space created"),
description: <>
<div className="mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog_checkmark" />
<p>
{ _t("<SpaceName/> has been made and everyone who was a part of the community has " +
"been invited to it.", {}, {
SpaceName: () => <AccessibleButton onClick={onSpaceClick} kind="link">
{ name }
</AccessibleButton>,
}) }
&nbsp;
{ spacesDisabledCopy }
</p>
<p>
{ _t("To create a Space from another community, just pick the community in Preferences.") }
</p>
</>,
button: _t("Preferences"),
onFinished: (openPreferences: boolean) => {
if (openPreferences) {
onPreferencesClick();
}
},
}, "mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog");
} catch (e) {
console.error(e);
setError(e);
}
setBusy(false);
};
let footer;
if (error) {
footer = <>
<img src={require("../../../../res/img/element-icons/warning-badge.svg")} height="24" width="24" alt="" />
<span className="mx_CreateSpaceFromCommunityDialog_error">
<div className="mx_CreateSpaceFromCommunityDialog_errorHeading">{ _t("Failed to migrate community") }</div>
<div className="mx_CreateSpaceFromCommunityDialog_errorCaption">{ _t("Try again") }</div>
</span>
<AccessibleButton className="mx_CreateSpaceFromCommunityDialog_retryButton" onClick={onCreateSpaceClick}>
{ _t("Retry") }
</AccessibleButton>
</>;
} else {
footer = <>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished()}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSpaceClick}>
{ busy ? _t("Creating...") : _t("Create Space") }
</AccessibleButton>
</>;
}
return <BaseDialog
title={_t("Create Space from community")}
className="mx_CreateSpaceFromCommunityDialog"
onFinished={onFinished}
fixedWidth={false}
>
<div className="mx_CreateSpaceFromCommunityDialog_content">
<p>
{ _t("A link to the Space will be put in your community description.") }
&nbsp;
{ _t("All rooms will be added and all community members will be invited.") }
</p>
<p className="mx_CreateSpaceFromCommunityDialog_flairNotice">
{ _t("Flair won't be available in Spaces for the foreseeable future.") }
</p>
<SpaceCreateForm
busy={busy}
onSubmit={onCreateSpaceClick}
avatarUrl={groupSummary.profile.avatar_url
? mediaFromMxc(groupSummary.profile.avatar_url).getThumbnailOfSourceHttp(80, 80, "crop")
: undefined
}
setAvatar={setAvatar}
name={name}
setName={setName}
nameFieldRef={spaceNameField}
topic={topic}
setTopic={setTopic}
alias={alias}
setAlias={setAlias}
showAliasField={joinRule === JoinRule.Public}
aliasFieldRef={spaceAliasField}
>
<p>{ _t("This description will be shown to people when they view your space") }</p>
<JoinRuleDropdown
label={_t("Space visibility")}
labelInvite={_t("Private space (invite only)")}
labelPublic={_t("Public space")}
value={joinRule}
onChange={setJoinRule}
/>
<p>{ joinRule === JoinRule.Public
? _t("Open space for anyone, best for communities")
: _t("Invite only, best for yourself or teams")
}</p>
{ joinRule !== JoinRule.Public &&
<div className="mx_CreateSpaceFromCommunityDialog_nonPublicSpacer" />
}
</SpaceCreateForm>
</div>
<div className="mx_CreateSpaceFromCommunityDialog_footer">
{ footer }
</div>
</BaseDialog>;
};
export default CreateSpaceFromCommunityDialog;

View file

@ -16,8 +16,7 @@ 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 { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
@ -27,8 +26,7 @@ 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 { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
@ -81,28 +79,7 @@ const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick
}
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,
});
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace });
onFinished(true);
} catch (e) {

View file

@ -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(),
}}
/>;
}

View file

@ -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');

View file

@ -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}
/>);
}
}

View file

@ -114,7 +114,7 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
UserTab.Preferences,
_td("Preferences"),
"mx_UserSettingsDialog_preferencesIcon",
<PreferencesUserSettingsTab />,
<PreferencesUserSettingsTab closeSettingsFn={this.props.onFinished} />,
));
if (SettingsStore.getValue(UIFeature.Voip)) {

View file

@ -67,7 +67,9 @@ export default function AccessibleButton({
...restProps
}: IProps) {
const newProps: IAccessibleButtonProps = restProps;
if (!disabled) {
if (disabled) {
newProps["aria-disabled"] = true;
} else {
newProps.onClick = onClick;
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
@ -118,7 +120,7 @@ export default function AccessibleButton({
);
// React.createElement expects InputHTMLAttributes
return React.createElement(element, restProps, children);
return React.createElement(element, newProps, children);
}
AccessibleButton.defaultProps = {

View file

@ -62,10 +62,10 @@ export default class AppPermission extends React.Component<IProps, IState> {
// Set all this into the initial state
this.state = {
...urlInfo,
roomMember,
isWrapped: null,
widgetDomain: null,
isWrapped: null,
roomMember,
...urlInfo,
};
}

View file

@ -18,7 +18,7 @@ limitations under the License.
import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
import classnames from 'classnames';
import AccessibleButton from './AccessibleButton';
import AccessibleButton, { ButtonEvent } from './AccessibleButton';
import { _t } from '../../../languageHandler';
import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -178,7 +178,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.ignoreEvent = ev;
};
private onInputClick = (ev: React.MouseEvent) => {
private onAccessibleButtonClick = (ev: ButtonEvent) => {
if (this.props.disabled) return;
if (!this.state.expanded) {
@ -186,6 +186,10 @@ export default class Dropdown extends React.Component<IProps, IState> {
expanded: true,
});
ev.preventDefault();
} else if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// the accessible button consumes enter onKeyDown for firing onClick, so handle it here
this.props.onOptionChange(this.state.highlightedOption);
this.close();
}
};
@ -204,7 +208,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.props.onOptionChange(dropdownKey);
};
private onInputKeyDown = (e: React.KeyboardEvent) => {
private onKeyDown = (e: React.KeyboardEvent) => {
let handled = true;
// These keys don't generate keypress events and so needs to be on keyup
@ -269,7 +273,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
private prevOption(optionKey: string): string {
const keys = Object.keys(this.childrenByKey);
const index = keys.indexOf(optionKey);
return keys[(index - 1) % keys.length];
return keys[index <= 0 ? keys.length - 1 : (index - 1) % keys.length];
}
private scrollIntoView(node: Element) {
@ -320,7 +324,6 @@ export default class Dropdown extends React.Component<IProps, IState> {
type="text"
autoFocus={true}
className="mx_Dropdown_option"
onKeyDown={this.onInputKeyDown}
onChange={this.onInputChange}
value={this.state.searchQuery}
role="combobox"
@ -329,6 +332,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
aria-owns={`${this.props.id}_listbox`}
aria-disabled={this.props.disabled}
aria-label={this.props.label}
onKeyDown={this.onKeyDown}
/>
);
}
@ -361,13 +365,14 @@ export default class Dropdown extends React.Component<IProps, IState> {
return <div className={classnames(dropdownClasses)} ref={this.collectRoot}>
<AccessibleButton
className="mx_Dropdown_input mx_no_textinput"
onClick={this.onInputClick}
onClick={this.onAccessibleButtonClick}
aria-haspopup="listbox"
aria-expanded={this.state.expanded}
disabled={this.props.disabled}
inputRef={this.buttonRef}
aria-label={this.props.label}
aria-describedby={`${this.props.id}_value`}
onKeyDown={this.onKeyDown}
>
{ currentValue }
<span className="mx_Dropdown_arrow" />

View file

@ -34,7 +34,7 @@ interface IProps {
// The list of room members for which to show avatars next to the summary
summaryMembers?: RoomMember[];
// The text to show as the summary of this event list
summaryText?: string;
summaryText?: string | JSX.Element;
// An array of EventTiles to render when expanded
children: ReactNode[];
// Called when the event list expansion is toggled

View file

@ -25,8 +25,24 @@ import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import { isValid3pidInvite } from "../../../RoomInvite";
import EventListSummary from "./EventListSummary";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import defaultDispatcher from '../../../dispatcher/dispatcher';
import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
import { Action } from '../../../dispatcher/actions';
import { SetRightPanelPhasePayload } from '../../../dispatcher/payloads/SetRightPanelPhasePayload';
import { jsxJoin } from '../../../utils/ReactUtils';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { Layout } from '../../../settings/Layout';
const onPinnedMessagesClick = (): void => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.PinnedMessages,
allowClose: false,
});
};
const SENDER_AS_DISPLAY_NAME_EVENTS = [EventType.RoomServerAcl, EventType.RoomPinnedEvents];
interface IProps extends Omit<ComponentProps<typeof EventListSummary>, "summaryText" | "summaryMembers"> {
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
summaryLength?: number;
@ -60,6 +76,7 @@ enum TransitionType {
ChangedAvatar = "changed_avatar",
NoChange = "no_change",
ServerAcl = "server_acl",
ChangedPins = "pinned_messages"
}
const SEP = ",";
@ -93,7 +110,10 @@ export default class MemberEventListSummary extends React.Component<IProps> {
* `Object.keys(eventAggregates)`.
* @returns {string} the textual summary of the aggregated events that occurred.
*/
private generateSummary(eventAggregates: Record<string, string[]>, orderedTransitionSequences: string[]) {
private generateSummary(
eventAggregates: Record<string, string[]>,
orderedTransitionSequences: string[],
): string | JSX.Element {
const summaries = orderedTransitionSequences.map((transitions) => {
const userNames = eventAggregates[transitions];
const nameList = this.renderNameList(userNames);
@ -122,7 +142,7 @@ export default class MemberEventListSummary extends React.Component<IProps> {
return null;
}
return summaries.join(", ");
return jsxJoin(summaries, ", ");
}
/**
@ -216,7 +236,11 @@ export default class MemberEventListSummary extends React.Component<IProps> {
* @param {number} repeats the number of times the transition was repeated in a row.
* @returns {string} the written Human Readable equivalent of the transition.
*/
private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) {
private static getDescriptionForTransition(
t: TransitionType,
userCount: number,
repeats: number,
): string | JSX.Element {
// The empty interpolations 'severalUsers' and 'oneUser'
// are there only to show translators to non-English languages
// that the verb is conjugated to plural or singular Subject.
@ -299,6 +323,15 @@ export default class MemberEventListSummary extends React.Component<IProps> {
{ severalUsers: "", count: repeats })
: _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count: repeats });
break;
case "pinned_messages":
res = (userCount > 1)
? _t("%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.",
{ severalUsers: "", count: repeats },
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> })
: _t("%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.",
{ oneUser: "", count: repeats },
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> });
break;
}
return res;
@ -317,16 +350,18 @@ export default class MemberEventListSummary extends React.Component<IProps> {
* if a transition is not recognised.
*/
private static getTransition(e: IUserEvents): TransitionType {
if (e.mxEvent.getType() === 'm.room.third_party_invite') {
const type = e.mxEvent.getType();
if (type === EventType.RoomThirdPartyInvite) {
// Handle 3pid invites the same as invites so they get bundled together
if (!isValid3pidInvite(e.mxEvent)) {
return TransitionType.InviteWithdrawal;
}
return TransitionType.Invited;
}
if (e.mxEvent.getType() === 'm.room.server_acl') {
} else if (type === EventType.RoomServerAcl) {
return TransitionType.ServerAcl;
} else if (type === EventType.RoomPinnedEvents) {
return TransitionType.ChangedPins;
}
switch (e.mxEvent.getContent().membership) {
@ -415,22 +450,23 @@ export default class MemberEventListSummary extends React.Component<IProps> {
// Object mapping user IDs to an array of IUserEvents
const userEvents: Record<string, IUserEvents[]> = {};
eventsToRender.forEach((e, index) => {
const userId = e.getType() === 'm.room.server_acl' ? e.getSender() : e.getStateKey();
const type = e.getType();
const userId = type === EventType.RoomServerAcl ? e.getSender() : e.getStateKey();
// Initialise a user's events
if (!userEvents[userId]) {
userEvents[userId] = [];
}
if (e.getType() === 'm.room.server_acl') {
if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
latestUserAvatarMember.set(userId, e.sender);
} else if (e.target) {
latestUserAvatarMember.set(userId, e.target);
}
let displayName = userId;
if (e.getType() === 'm.room.third_party_invite') {
if (type === EventType.RoomThirdPartyInvite) {
displayName = e.getContent().display_name;
} else if (e.getType() === 'm.room.server_acl') {
} else if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
displayName = e.sender.name;
} else if (e.target) {
displayName = e.target.name;

View file

@ -25,7 +25,7 @@ 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';
import { formatCallTime } from "../../../DateUtils";
interface IProps {
mxEvent: MatrixEvent;
@ -117,14 +117,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)) {
@ -134,16 +132,21 @@ export default class CallEvent extends React.Component<IProps, IState> {
// https://github.com/vector-im/riot-android/issues/2623
// Also the correct hangup code as of VoIP v1 (with underscore)
// Also, if we don't have a reason
const duration = this.props.callEventGrouper.duration;
let text = _t("Call ended");
if (duration) {
text += " • " + formatCallTime(duration);
}
return (
<div className="mx_CallEvent_content">
{ _t("This call has ended") }
{ text }
</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 +179,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 +194,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>
);

View file

@ -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>;
}
}

View file

@ -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 }) }

View file

@ -347,12 +347,21 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
className="mx_MImageBody_thumbnail"
src={thumbUrl}
ref={this.image}
style={{ maxWidth: `min(100%, ${maxWidth}px)` }}
// Force the image to be the full size of the container, even if the
// pixel size is smaller. The problem here is that we don't know what
// thumbnail size the HS is going to give us, but we have to commit to
// a container size immediately and not change it when the image loads
// or we'll get a scroll jump (or have to leave blank space).
// This will obviously result in an upscaled image which will be a bit
// blurry. The best fix would be for the HS to advertise what size thumbnails
// it guarantees to produce.
style={{ height: '100%' }}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
onMouseLeave={this.onImageLeave}
/>
);
}
@ -379,7 +388,10 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
</div>
}
<div style={{ display: !showPlaceholder ? undefined : 'none' }}>
<div style={{
display: !showPlaceholder ? undefined : 'none',
height: '100%', // Also force to size of a parent to prevent scroll-jumps (see above)
}}>
{ img }
{ gifLabel }
</div>

View file

@ -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} />;

View file

@ -136,7 +136,8 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
// Calculate how many percent does the pre element take up.
// If it's less than 30% we don't add the expansion button.
const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
// We also round the number as it sometimes can be 29.99...
const percentageOfViewport = Math.round(pre.offsetHeight / UIStore.instance.windowHeight * 100);
if (percentageOfViewport < 30) return;
const button = document.createElement("span");

View file

@ -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>;

View file

@ -851,7 +851,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
return <div />;
};
interface GroupMember {
export interface GroupMember {
userId: string;
displayname?: string; // XXX: GroupMember objects are inconsistent :((
avatarUrl?: string;
@ -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,
});
}}
>

View file

@ -1,6 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019 - 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.
@ -32,7 +31,7 @@ import {
} from '../../../editor/operations';
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
import { getAutoCompleteCreator } from '../../../editor/parts';
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
import { renderModel } from '../../../editor/render';
import TypingStore from "../../../stores/TypingStore";
@ -55,6 +54,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 +106,7 @@ interface IState {
showVisualBell?: boolean;
autoComplete?: AutocompleteWrapperModel;
completionIndex?: number;
surroundWith: boolean;
}
@replaceableComponent("views.rooms.BasicMessageEditor")
@ -117,12 +125,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 +140,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) {
@ -157,7 +169,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index];
n -= 1;
return n >= 0 && (part.type === "plain" || part.type === "pill-candidate");
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate);
});
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
if (emoticonMatch) {
@ -422,6 +434,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:
@ -524,9 +558,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
const range = model.startRange(position);
range.expandBackwardsWhile((index, offset, part) => {
return part.text[offset] !== " " && part.text[offset] !== "+" && (
part.type === "plain" ||
part.type === "pill-candidate" ||
part.type === "command"
part.type === Type.Plain ||
part.type === Type.PillCandidate ||
part.type === Type.Command
);
});
const { partCreator } = model;
@ -574,6 +608,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 +620,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() {
@ -684,7 +724,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
<MessageComposerFormatBar ref={this.formatBarRef} onAction={this.onFormatAction} shortcuts={shortcuts} />
<div
className={classes}
contentEditable="true"
contentEditable={this.props.disabled ? null : true}
tabIndex={0}
onBlur={this.onBlur}
onFocus={this.onFocus}

View file

@ -25,7 +25,7 @@ import { getCaretOffsetAndText } from '../../../editor/dom';
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
import { findEditableEvent } from '../../../utils/EventUtils';
import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
@ -242,12 +242,12 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}
@ -268,7 +268,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
if (part.type === "user-pill") {
if (part.type === Type.UserPill) {
return text + part.resourceId;
}
return text + part.text;

View file

@ -58,6 +58,7 @@ function ComposerAvatar(props: IComposerAvatarProps) {
interface ISendButtonProps {
onClick: () => void;
title?: string; // defaults to something generic
}
function SendButton(props: ISendButtonProps) {
@ -65,7 +66,7 @@ function SendButton(props: ISendButtonProps) {
<AccessibleTooltipButton
className="mx_MessageComposer_sendMessage"
onClick={props.onClick}
title={_t('Send message')}
title={props.title ?? _t('Send message')}
/>
);
}
@ -394,16 +395,18 @@ 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(
<SendButton key="controls_send" onClick={this.sendMessage} />,
<SendButton
key="controls_send"
onClick={this.sendMessage}
title={this.state.haveRecording ? _t("Send voice message") : undefined}
/>,
);
}
} else if (this.state.tombstone) {

View file

@ -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,
});
}}
/>

View file

@ -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 },
);
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { createRef } from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
@ -38,6 +38,8 @@ interface IProps {
@replaceableComponent("views.rooms.ReplyTile")
export default class ReplyTile extends React.PureComponent<IProps> {
private anchorElement = createRef<HTMLAnchorElement>();
static defaultProps = {
onHeightChanged: () => {},
};
@ -71,7 +73,11 @@ export default class ReplyTile extends React.PureComponent<IProps> {
// Following a link within a reply should not dispatch the `view_room` action
// so that the browser can direct the user to the correct location
// The exception being the link wrapping the reply
if (clickTarget.tagName.toLowerCase() !== "a" || clickTarget.closest("a") === null) {
if (
clickTarget.tagName.toLowerCase() !== "a" ||
clickTarget.closest("a") === null ||
clickTarget === this.anchorElement.current
) {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Riot when clicked.
e.preventDefault();
@ -141,7 +147,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
return (
<div className={classes}>
<a href={permalink} onClick={this.onClick}>
<a href={permalink} onClick={this.onClick} ref={this.anchorElement}>
{ sender }
<EventTileType
ref="tile"

View file

@ -31,7 +31,7 @@ import {
textSerialize,
unescapeMessage,
} from '../../../editor/serialize';
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils';
@ -240,14 +240,14 @@ export default class SendMessageComposer extends React.Component<IProps> {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
// be extra resilient when somehow the AutocompleteWrapperModel or
// CommandPartCreator fails to insert a command part, so we don't send
// a command as a message
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}

View file

@ -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();
@ -200,7 +215,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
}
public render(): ReactNode {
let recordingInfo;
let stopOrRecordBtn;
let deleteButton;
if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
const classes = classNames({
@ -209,12 +224,12 @@ 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
stopOrRecordBtn = <AccessibleTooltipButton
className={classes}
onClick={this.onRecordStartEndClick}
title={tooltip}
@ -222,22 +237,41 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
if (this.state.recorder && !this.state.recorder?.isRecording) {
stopOrRecordBtn = null;
}
recordingInfo = stopOrRecordBtn;
}
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>;
}
// The record button (mic icon) is meant to be on the right edge, but we also want the
// stop button to be left of the waveform area. Luckily, none of the surrounding UI is
// rendered when we're not recording, so the record button ends up in the correct spot.
return (<>
{ uploadIndicator }
{ deleteButton }
{ stopOrRecordBtn }
{ this.renderWaveformArea() }
{ recordingInfo }
</>);
}
}

View file

@ -0,0 +1,133 @@
/*
Copyright 2019 New Vector Ltd
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.
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 classNames from "classnames";
import SettingsStore from "../../../settings/SettingsStore";
import EventTilePreview from "../elements/EventTilePreview";
import StyledRadioButton from "../elements/StyledRadioButton";
import { _t } from "../../../languageHandler";
import { Layout } from "../../../settings/Layout";
import { SettingLevel } from "../../../settings/SettingLevel";
interface IProps {
userId: string;
displayName: string;
avatarUrl: string;
messagePreviewText: string;
onLayoutChanged?: (layout: Layout) => void;
}
interface IState {
layout: Layout;
}
export default class LayoutSwitcher extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
layout: SettingsStore.getValue("layout"),
};
}
private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const layout = e.target.value as Layout;
this.setState({ layout: layout });
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout);
this.props.onLayoutChanged(layout);
};
public render(): JSX.Element {
const ircClasses = classNames("mx_LayoutSwitcher_RadioButton", {
mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.IRC,
});
const groupClasses = classNames("mx_LayoutSwitcher_RadioButton", {
mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.Group,
});
const bubbleClasses = classNames("mx_LayoutSwitcher_RadioButton", {
mx_LayoutSwitcher_RadioButton_selected: this.state.layout === Layout.Bubble,
});
return (
<div className="mx_SettingsTab_section mx_LayoutSwitcher">
<span className="mx_SettingsTab_subheading">
{ _t("Message layout") }
</span>
<div className="mx_LayoutSwitcher_RadioButtons">
<label className={ircClasses}>
<EventTilePreview
className="mx_LayoutSwitcher_RadioButton_preview"
message={this.props.messagePreviewText}
layout={Layout.IRC}
userId={this.props.userId}
displayName={this.props.displayName}
avatarUrl={this.props.avatarUrl}
/>
<StyledRadioButton
name="layout"
value={Layout.IRC}
checked={this.state.layout === Layout.IRC}
onChange={this.onLayoutChange}
>
{ _t("IRC") }
</StyledRadioButton>
</label>
<label className={groupClasses}>
<EventTilePreview
className="mx_LayoutSwitcher_RadioButton_preview"
message={this.props.messagePreviewText}
layout={Layout.Group}
userId={this.props.userId}
displayName={this.props.displayName}
avatarUrl={this.props.avatarUrl}
/>
<StyledRadioButton
name="layout"
value={Layout.Group}
checked={this.state.layout == Layout.Group}
onChange={this.onLayoutChange}
>
{ _t("Modern") }
</StyledRadioButton>
</label>
<label className={bubbleClasses}>
<EventTilePreview
className="mx_LayoutSwitcher_RadioButton_preview"
message={this.props.messagePreviewText}
layout={Layout.Bubble}
userId={this.props.userId}
displayName={this.props.displayName}
avatarUrl={this.props.avatarUrl}
/>
<StyledRadioButton
name="layout"
value={Layout.Bubble}
checked={this.state.layout == Layout.Bubble}
onChange={this.onLayoutChange}
>
{ _t("Message bubbles") }
</StyledRadioButton>
</label>
</div>
</div>
);
}
}

View file

@ -1,6 +1,6 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 - 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.
@ -37,10 +37,9 @@ import StyledRadioGroup from "../../../elements/StyledRadioGroup";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import { UIFeature } from "../../../../../settings/UIFeature";
import { Layout } from "../../../../../settings/Layout";
import classNames from 'classnames';
import StyledRadioButton from '../../../elements/StyledRadioButton';
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import { compare } from "../../../../../utils/strings";
import LayoutSwitcher from "../../LayoutSwitcher";
interface IProps {
}
@ -243,17 +242,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
this.setState({ customThemeUrl: e.target.value });
};
private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let layout;
switch (e.target.value) {
case "irc": layout = Layout.IRC; break;
case "group": layout = Layout.Group; break;
case "bubble": layout = Layout.Bubble; break;
}
private onLayoutChanged = (layout: Layout): void => {
this.setState({ layout: layout });
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout);
};
private onIRCLayoutChange = (enabled: boolean) => {
@ -391,75 +381,6 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
</div>;
}
private renderLayoutSection = () => {
return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_Layout">
<span className="mx_SettingsTab_subheading">{ _t("Message layout") }</span>
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons">
<label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.IRC,
})}>
<EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT}
layout={Layout.IRC}
userId={this.state.userId}
displayName={this.state.displayName}
avatarUrl={this.state.avatarUrl}
/>
<StyledRadioButton
name="layout"
value="irc"
checked={this.state.layout === Layout.IRC}
onChange={this.onLayoutChange}
>
{ _t("IRC") }
</StyledRadioButton>
</label>
<label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.Group,
})}>
<EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT}
layout={Layout.Group}
userId={this.state.userId}
displayName={this.state.displayName}
avatarUrl={this.state.avatarUrl}
/>
<StyledRadioButton
name="layout"
value="group"
checked={this.state.layout == Layout.Group}
onChange={this.onLayoutChange}
>
{ _t("Modern") }
</StyledRadioButton>
</label>
<label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout === Layout.Bubble,
})}>
<EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT}
layout={Layout.Bubble}
userId={this.state.userId}
displayName={this.state.displayName}
avatarUrl={this.state.avatarUrl}
/>
<StyledRadioButton
name="layout"
value="bubble"
checked={this.state.layout == Layout.Bubble}
onChange={this.onLayoutChange}
>
{ _t("Message bubbles") }
</StyledRadioButton>
</label>
</div>
</div>;
};
private renderAdvancedSection() {
if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null;
@ -527,6 +448,19 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
render() {
const brand = SdkConfig.get().brand;
let layoutSection;
if (SettingsStore.getValue("feature_new_layout_switcher")) {
layoutSection = (
<LayoutSwitcher
userId={this.state.userId}
displayName={this.state.displayName}
avatarUrl={this.state.avatarUrl}
messagePreviewText={this.MESSAGE_PREVIEW_TEXT}
onLayoutChanged={this.onLayoutChanged}
/>
);
}
return (
<div className="mx_SettingsTab mx_AppearanceUserSettingsTab">
<div className="mx_SettingsTab_heading">{ _t("Customise your appearance") }</div>
@ -534,7 +468,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
{ _t("Appearance Settings only affect this %(brand)s session.", { brand }) }
</div>
{ this.renderThemeSection() }
{ SettingsStore.getValue("feature_new_layout_switcher") ? this.renderLayoutSection() : null }
{ layoutSection }
{ this.renderFontSection() }
{ this.renderAdvancedSection() }
</div>

View file

@ -15,9 +15,9 @@ limitations under the License.
*/
import React from 'react';
import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton";
import { _t, getCurrentLanguage } from "../../../../../languageHandler";
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton";
import AccessibleTooltipButton from '../../../elements/AccessibleTooltipButton';
import SdkConfig from "../../../../../SdkConfig";
import createRoom from "../../../../../createRoom";
@ -69,6 +69,18 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
if (this.closeCopiedTooltip) this.closeCopiedTooltip();
}
private getVersionInfo(): { appVersion: string, olmVersion: string } {
const brand = SdkConfig.get().brand;
const appVersion = this.state.appVersion || 'unknown';
let olmVersion = MatrixClientPeg.get().olmVersion;
olmVersion = olmVersion ? `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}` : '<not-enabled>';
return {
appVersion: `${_t("%(brand)s version:", { brand })} ${appVersion}`,
olmVersion: `${_t("Olm version:")} ${olmVersion}`,
};
}
private onClearCacheAndReload = (e) => {
if (!PlatformPeg.get()) return;
@ -173,17 +185,26 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
);
}
onAccessTokenCopyClick = async (e) => {
private async copy(text: string, e: ButtonEvent) {
e.preventDefault();
const target = e.target; // copy target before we go async and React throws it away
const target = e.target as HTMLDivElement; // copy target before we go async and React throws it away
const successful = await copyPlaintext(MatrixClientPeg.get().getAccessToken());
const successful = await copyPlaintext(text);
const buttonRect = target.getBoundingClientRect();
const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'),
});
this.closeCopiedTooltip = target.onmouseleave = close;
}
private onAccessTokenCopyClick = (e: ButtonEvent) => {
this.copy(MatrixClientPeg.get().getAccessToken(), e);
};
private onCopyVersionClicked = (e: ButtonEvent) => {
const { appVersion, olmVersion } = this.getVersionInfo();
this.copy(`${appVersion}\n${olmVersion}`, e);
};
render() {
@ -232,11 +253,6 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
);
}
const appVersion = this.state.appVersion || 'unknown';
let olmVersion = MatrixClientPeg.get().olmVersion;
olmVersion = olmVersion ? `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}` : '<not-enabled>';
let updateButton = null;
if (this.state.canUpdate) {
updateButton = <UpdateCheckButton />;
@ -275,6 +291,8 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
);
}
const { appVersion, olmVersion } = this.getVersionInfo();
return (
<div className="mx_SettingsTab mx_HelpUserSettingsTab">
<div className="mx_SettingsTab_heading">{ _t("Help & About") }</div>
@ -291,8 +309,15 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
<span className='mx_SettingsTab_subheading'>{ _t("Versions") }</span>
<div className='mx_SettingsTab_subsectionText'>
{ _t("%(brand)s version:", { brand }) } { appVersion }<br />
{ _t("olm version:") } { olmVersion }<br />
<div className="mx_HelpUserSettingsTab_copy">
{ appVersion }<br />
{ olmVersion }<br />
<AccessibleTooltipButton
title={_t("Copy")}
onClick={this.onCopyVersionClicked}
className="mx_HelpUserSettingsTab_copyButton"
/>
</div>
{ updateButton }
</div>
</div>
@ -308,12 +333,12 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
<summary>{ _t("Access Token") }</summary><br />
<b>{ _t("Your access token gives full access to your account."
+ " Do not share it with anyone." ) }</b>
<div className="mx_HelpUserSettingsTab_accessToken">
<div className="mx_HelpUserSettingsTab_copy">
<code>{ MatrixClientPeg.get().getAccessToken() }</code>
<AccessibleTooltipButton
title={_t("Copy")}
onClick={this.onAccessTokenCopyClick}
className="mx_HelpUserSettingsTab_accessToken_copy"
className="mx_HelpUserSettingsTab_copyButton"
/>
</div>
</details><br />

View file

@ -19,11 +19,12 @@ import { _t } from "../../../../../languageHandler";
import PropTypes from "prop-types";
import SettingsStore from "../../../../../settings/SettingsStore";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import * as sdk from "../../../../../index";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import SdkConfig from "../../../../../SdkConfig";
import BetaCard from "../../../beta/BetaCard";
import SettingsFlag from '../../../elements/SettingsFlag';
import { MatrixClientPeg } from '../../../../../MatrixClientPeg';
export class LabsSettingToggle extends React.Component {
static propTypes = {
@ -47,6 +48,14 @@ export class LabsSettingToggle extends React.Component {
export default class LabsUserSettingsTab extends React.Component {
constructor() {
super();
MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285").then((showHiddenReadReceipts) => {
this.setState({ showHiddenReadReceipts });
});
this.state = {
showHiddenReadReceipts: false,
};
}
render() {
@ -65,15 +74,22 @@ export default class LabsUserSettingsTab extends React.Component {
let labsSection;
if (SdkConfig.get()['showLabsSettings']) {
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
const flags = labs.map(f => <LabsSettingToggle featureId={f} key={f} />);
let hiddenReadReceipts;
if (this.state.showHiddenReadReceipts) {
hiddenReadReceipts = (
<SettingsFlag name="feature_hidden_read_receipts" level={SettingLevel.DEVICE} />
);
}
labsSection = <div className="mx_SettingsTab_section">
{ flags }
<SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} />
<SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} />
<SettingsFlag name="lowBandwidth" level={SettingLevel.DEVICE} />
<SettingsFlag name="advancedRoomListLogging" level={SettingLevel.DEVICE} />
{ hiddenReadReceipts }
</div>;
}

View file

@ -15,7 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { useContext, useEffect, useState } from 'react';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { _t } from "../../../../../languageHandler";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import SettingsStore from "../../../../../settings/SettingsStore";
@ -26,6 +28,19 @@ 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";
import GroupAvatar from "../../../avatars/GroupAvatar";
import dis from "../../../../../dispatcher/dispatcher";
import GroupActions from "../../../../../actions/GroupActions";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import { useDispatcher } from "../../../../../hooks/useDispatcher";
import { CreateEventField, IGroupSummary } from "../../../dialogs/CreateSpaceFromCommunityDialog";
import { createSpaceFromCommunity } from "../../../../../utils/space";
import Spinner from "../../../elements/Spinner";
interface IProps {
closeSettingsFn(success: boolean): void;
}
interface IState {
autoLaunch: boolean;
@ -41,12 +56,98 @@ interface IState {
readMarkerOutOfViewThresholdMs: string;
}
type Community = IGroupSummary & {
groupId: string;
spaceId?: string;
};
const CommunityMigrator = ({ onFinished }) => {
const cli = useContext(MatrixClientContext);
const [communities, setCommunities] = useState<Community[]>(null);
useEffect(() => {
dis.dispatch(GroupActions.fetchJoinedGroups(cli));
}, [cli]);
useDispatcher(dis, async payload => {
if (payload.action === "GroupActions.fetchJoinedGroups.success") {
const communities: Community[] = [];
const migratedSpaceMap = new Map(cli.getRooms().map(room => {
const createContent = room.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
if (createContent?.[CreateEventField]) {
return [createContent[CreateEventField], room.roomId] as [string, string];
}
}).filter(Boolean));
for (const groupId of payload.result.groups) {
const summary = await cli.getGroupSummary(groupId) as IGroupSummary;
if (summary.user.is_privileged) {
communities.push({
...summary,
groupId,
spaceId: migratedSpaceMap.get(groupId),
});
}
}
setCommunities(communities);
}
});
if (!communities) {
return <Spinner />;
}
return <div className="mx_PreferencesUserSettingsTab_CommunityMigrator">
{ communities.map(community => (
<div key={community.groupId}>
<GroupAvatar
groupId={community.groupId}
groupAvatarUrl={community.profile.avatar_url}
groupName={community.profile.name}
width={32}
height={32}
/>
{ community.profile.name }
<AccessibleButton
kind="primary_outline"
onClick={() => {
if (community.spaceId) {
dis.dispatch({
action: "view_room",
room_id: community.spaceId,
});
onFinished();
} else {
createSpaceFromCommunity(cli, community.groupId).then(([spaceId]) => {
if (spaceId) {
community.spaceId = spaceId;
setCommunities([...communities]); // force component re-render
}
});
}
}}
>
{ community.spaceId ? _t("Open Space") : _t("Create Space") }
</AccessibleButton>
</div>
)) }
</div>;
};
@replaceableComponent("views.settings.tabs.user.PreferencesUserSettingsTab")
export default class PreferencesUserSettingsTab extends React.Component<{}, IState> {
export default class PreferencesUserSettingsTab extends React.Component<IProps, IState> {
static ROOM_LIST_SETTINGS = [
'breadcrumbs',
];
static SPACES_SETTINGS = [
"Spaces.allRoomsInHome",
];
static COMMUNITIES_SETTINGS = [
// TODO: part of delabsing move the toggle here - https://github.com/vector-im/element-web/issues/18088
];
static KEYBINDINGS_SETTINGS = [
'ctrlFForSearch',
];
@ -56,6 +157,7 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
'MessageComposerInput.suggestEmoji',
'sendTypingNotifications',
'MessageComposerInput.ctrlEnterToSend',
'MessageComposerInput.surroundWith',
'MessageComposerInput.showStickersButton',
];
@ -231,6 +333,24 @@ 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("Communities") }</span>
<p>{ _t("Communities have been archived to make way for Spaces but you can convert your " +
"communities into Spaces below. Converting will ensure your conversations get the latest " +
"features.") }</p>
<details>
<summary>{ _t("Show my Communities") }</summary>
<p>{ _t("If a community isn't shown you may not have permission to convert it.") }</p>
<CommunityMigrator onFinished={this.props.closeSettingsFn} />
</details>
{ this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS) }
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Keyboard shortcuts") }</span>
<AccessibleButton className="mx_SettingsFlag" onClick={KeyboardShortcuts.toggleDialog}>

View file

@ -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 = () => {

View file

@ -65,6 +65,7 @@ export const SpaceAvatar = ({
}}
kind="link"
className="mx_SpaceBasicSettings_avatar_remove"
aria-label={_t("Delete avatar")}
>
{ _t("Delete") }
</AccessibleButton>
@ -72,7 +73,11 @@ export const SpaceAvatar = ({
} else {
avatarSection = <React.Fragment>
<div className="mx_SpaceBasicSettings_avatar" onClick={() => avatarUploadRef.current?.click()} />
<AccessibleButton onClick={() => avatarUploadRef.current?.click()} kind="link">
<AccessibleButton
onClick={() => avatarUploadRef.current?.click()}
kind="link"
aria-label={_t("Upload avatar")}
>
{ _t("Upload") }
</AccessibleButton>
</React.Fragment>;

View file

@ -18,22 +18,59 @@ import React, { ComponentProps, RefObject, SyntheticEvent, useContext, useRef, u
import classNames from "classnames";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import FocusLock from "react-focus-lock";
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
import { _t } from "../../../languageHandler";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
import createRoom from "../../../createRoom";
import createRoom, { IOpts as ICreateOpts } from "../../../createRoom";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import SpaceBasicSettings, { SpaceAvatar } from "./SpaceBasicSettings";
import AccessibleButton from "../elements/AccessibleButton";
import Field from "../elements/Field";
import withValidation from "../elements/Validation";
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";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserSettingsDialog";
export const createSpace = async (
name: string,
isPublic: boolean,
alias?: string,
topic?: string,
avatar?: string | File,
createOpts: Partial<ICreateRoomOpts> = {},
otherOpts: Partial<Omit<ICreateOpts, "createOpts">> = {},
) => {
return createRoom({
createOpts: {
name,
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
power_level_content_override: {
// Only allow Admins to write to the timeline to prevent hidden sync spam
events_default: 100,
...isPublic ? { invite: 0 } : {},
},
room_alias_name: isPublic && alias ? alias.substr(1, alias.indexOf(":") - 1) : undefined,
topic,
...createOpts,
},
avatar,
roomType: RoomType.Space,
historyVisibility: isPublic ? HistoryVisibility.WorldReadable : HistoryVisibility.Invited,
spinner: false,
encryption: false,
andView: true,
inlineErrors: true,
...otherOpts,
});
};
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
return (
@ -92,7 +129,7 @@ export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
</div>;
};
type BProps = Pick<ComponentProps<typeof SpaceBasicSettings>, "setAvatar" | "name" | "setName" | "topic" | "setTopic">;
type BProps = Omit<ComponentProps<typeof SpaceBasicSettings>, "nameDisabled" | "topicDisabled" | "avatarDisabled">;
interface ISpaceCreateFormProps extends BProps {
busy: boolean;
alias: string;
@ -106,6 +143,7 @@ interface ISpaceCreateFormProps extends BProps {
export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
busy,
onSubmit,
avatarUrl,
setAvatar,
name,
setName,
@ -122,7 +160,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
const domain = cli.getDomain();
return <form className="mx_SpaceBasicSettings" onSubmit={onSubmit}>
<SpaceAvatar setAvatar={setAvatar} avatarDisabled={busy} />
<SpaceAvatar avatarUrl={avatarUrl} setAvatar={setAvatar} avatarDisabled={busy} />
<Field
name="spaceName"
@ -200,30 +238,7 @@ const SpaceCreateMenu = ({ onFinished }) => {
}
try {
await createRoom({
createOpts: {
preset: visibility === Visibility.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,
...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,
inlineErrors: true,
});
await createSpace(name, visibility === Visibility.Public, alias, topic, avatar);
onFinished();
} catch (e) {
@ -233,10 +248,23 @@ const SpaceCreateMenu = ({ onFinished }) => {
let body;
if (visibility === null) {
const onCreateSpaceFromCommunityClick = () => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Preferences,
});
onFinished();
};
body = <React.Fragment>
<h2>{ _t("Create a space") }</h2>
<p>{ _t("Spaces are a new way to group rooms and people. " +
"To join an existing space you'll need an invite.") }</p>
<p>
{ _t("Spaces are a new way to group rooms and people.") }
&nbsp;
{ _t("What kind of Space do you want to create?") }
&nbsp;
{ _t("You can change this later.") }
</p>
<SpaceCreateMenuType
title={_t("Public")}
@ -251,7 +279,15 @@ const SpaceCreateMenu = ({ onFinished }) => {
onClick={() => setVisibility(Visibility.Private)}
/>
<p>{ _t("You can change this later") }</p>
<p>
{ _t("You can also create a Space from a <a>community</a>.", {}, {
a: sub => <AccessibleButton kind="link" onClick={onCreateSpaceFromCommunityClick}>
{ sub }
</AccessibleButton>,
}) }
<br />
{ _t("To join an existing space you'll need an invite.") }
</p>
<SpaceFeedbackPrompt onClick={onFinished} />
</React.Fragment>;

View file

@ -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,114 @@ 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,
})}
role="treeitem"
>
<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,
})}
role="treeitem"
>
<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 +200,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 +268,6 @@ const SpacePanel = () => {
}
};
const onNewClick = menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
return (
<DragDropContext onDragEnd={result => {
if (!result.destination) return; // dropped outside the list
@ -274,6 +278,8 @@ const SpacePanel = () => {
<ul
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Spaces")}
>
<Droppable droppableId="top-level-spaces">
{ (provided, snapshot) => (
@ -291,15 +297,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 +305,6 @@ const SpacePanel = () => {
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")}
/>
{ contextMenu }
</ul>
) }
</RovingTabIndexProvider>

View file

@ -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,34 +30,109 @@ 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 {
leaveSpace,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
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 { BetaPill } from "../beta/BetaCard";
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) {
let ariaLabel = _t("Jump to first unread room.");
if (space?.getMyMembership() === "invite") {
ariaLabel = _t("Jump to first invite.");
}
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space || null)}
forceCount={false}
notification={notificationState}
aria-label={ariaLabel}
/>
</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}
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;
@ -64,7 +146,6 @@ interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
interface IItemState {
collapsed: boolean;
contextMenuPosition: Pick<DOMRect, "right" | "top" | "height">;
childSpaces: Room[];
}
@ -84,7 +165,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
this.state = {
collapsed: collapsed,
contextMenuPosition: null,
childSpaces: this.childSpaces,
};
@ -127,19 +207,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);
@ -183,200 +250,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();
leaveSpace(this.props.space);
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 onNewSubspaceClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewSubspace(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}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Add space")}
onClick={this.onNewSubspaceClick}
>
<BetaPill />
</IconizedContextMenuOption>
</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,
@ -384,7 +257,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,
@ -393,12 +265,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);
@ -413,19 +280,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"
@ -435,26 +289,22 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
/> : null;
return (
<li {...otherProps} className={itemClasses} ref={innerRef}>
<RovingAccessibleTooltipButton
className={classes}
title={space.name}
<li {...otherProps} className={itemClasses} ref={innerRef} aria-expanded={!collapsed} role="treeitem">
<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}
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>
@ -475,7 +325,7 @@ const SpaceTreeLevel: React.FC<ITreeLevelProps> = ({
isNested,
parents,
}) => {
return <ul className="mx_SpaceTreeLevel">
return <ul className="mx_SpaceTreeLevel" role="group">
{ spaces.map(s => {
return (<SpaceItem
key={s.roomId}

View file

@ -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>;
}

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import React from 'react';
import CallView from "./CallView";
import RoomViewStore from '../../../stores/RoomViewStore';
@ -27,23 +27,8 @@ import SettingsStore from "../../../settings/SettingsStore";
import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import UIStore from '../../../stores/UIStore';
import { lerp } from '../../../utils/AnimationUtils';
import { MarkedExecution } from '../../../utils/MarkedExecution';
import { EventSubscription } from 'fbemitter';
const PIP_VIEW_WIDTH = 336;
const PIP_VIEW_HEIGHT = 232;
const MOVING_AMT = 0.2;
const SNAPPING_AMT = 0.05;
const PADDING = {
top: 58,
bottom: 58,
left: 76,
right: 8,
};
import PictureInPictureDragger from './PictureInPictureDragger';
const SHOW_CALL_IN_STATES = [
CallState.Connected,
@ -66,10 +51,6 @@ interface IState {
// Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms
// they belong to
secondaryCall: MatrixCall;
// Position of the CallPreview
translationX: number;
translationY: number;
}
// Splits a list of calls into one 'primary' one and a list
@ -112,16 +93,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
private roomStoreToken: EventSubscription;
private dispatcherRef: string;
private settingsWatcherRef: string;
private callViewWrapper = createRef<HTMLDivElement>();
private initX = 0;
private initY = 0;
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH;
private moving = false;
private scheduledUpdate = new MarkedExecution(
() => this.animationCallback(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
constructor(props: IProps) {
super(props);
@ -136,17 +107,12 @@ export default class CallPreview extends React.Component<IProps, IState> {
roomId,
primaryCall: primaryCall,
secondaryCall: secondaryCalls[0],
translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH,
translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH,
};
}
public componentDidMount() {
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
document.addEventListener("mousemove", this.onMoving);
document.addEventListener("mouseup", this.onEndMoving);
window.addEventListener("resize", this.onResize);
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
}
@ -154,9 +120,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
public componentWillUnmount() {
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
document.removeEventListener("mousemove", this.onMoving);
document.removeEventListener("mouseup", this.onEndMoving);
window.removeEventListener("resize", this.onResize);
if (this.roomStoreToken) {
this.roomStoreToken.remove();
}
@ -164,94 +127,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
SettingsStore.unwatchSetting(this.settingsWatcherRef);
}
private onResize = (): void => {
this.snap(false);
};
private animationCallback = () => {
// If the PiP isn't being dragged and there is only a tiny difference in
// the desiredTranslation and translation, quit the animationCallback
// loop. If that is the case, it means the PiP has snapped into its
// position and there is nothing to do. Not doing this would cause an
// infinite loop
if (
!this.moving &&
Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 &&
Math.abs(this.state.translationY - this.desiredTranslationY) <= 1
) return;
const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
this.setState({
translationX: lerp(this.state.translationX, this.desiredTranslationX, amt),
translationY: lerp(this.state.translationY, this.desiredTranslationY, amt),
});
this.scheduledUpdate.mark();
};
private setTranslation(inTranslationX: number, inTranslationY: number) {
const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
// Avoid overflow on the x axis
if (inTranslationX + width >= UIStore.instance.windowWidth) {
this.desiredTranslationX = UIStore.instance.windowWidth - width;
} else if (inTranslationX <= 0) {
this.desiredTranslationX = 0;
} else {
this.desiredTranslationX = inTranslationX;
}
// Avoid overflow on the y axis
if (inTranslationY + height >= UIStore.instance.windowHeight) {
this.desiredTranslationY = UIStore.instance.windowHeight - height;
} else if (inTranslationY <= 0) {
this.desiredTranslationY = 0;
} else {
this.desiredTranslationY = inTranslationY;
}
}
private snap(animate?: boolean): void {
const translationX = this.desiredTranslationX;
const translationY = this.desiredTranslationY;
// We subtract the PiP size from the window size in order to calculate
// the position to snap to from the PiP center and not its top-left
// corner
const windowWidth = (
UIStore.instance.windowWidth -
(this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH)
);
const windowHeight = (
UIStore.instance.windowHeight -
(this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT)
);
if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = PADDING.top;
} else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = PADDING.top;
}
if (animate) {
// We start animating here because we want the PiP to move when we're
// resizing the window
this.scheduledUpdate.mark();
} else {
this.setState({
translationX: this.desiredTranslationX,
translationY: this.desiredTranslationY,
});
}
}
private onRoomViewStoreUpdate = () => {
if (RoomViewStore.getRoomId() === this.state.roomId) return;
@ -269,9 +144,10 @@ export default class CallPreview extends React.Component<IProps, IState> {
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
// listen for call state changes to prod the render method, which
// may hide the global CallView if the call it is tracking is dead
case 'call_state': {
// listen for call state changes to prod the render method, which
// may hide the global CallView if the call it is tracking is dead
this.updateCalls();
break;
}
@ -300,57 +176,26 @@ export default class CallPreview extends React.Component<IProps, IState> {
});
};
private onStartMoving = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
this.moving = true;
this.initX = event.pageX - this.desiredTranslationX;
this.initY = event.pageY - this.desiredTranslationY;
this.scheduledUpdate.mark();
};
private onMoving = (event: React.MouseEvent | MouseEvent) => {
if (!this.moving) return;
event.preventDefault();
event.stopPropagation();
this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
};
private onEndMoving = () => {
this.moving = false;
this.snap(true);
};
public render() {
const pipMode = true;
if (this.state.primaryCall) {
const translatePixelsX = this.state.translationX + "px";
const translatePixelsY = this.state.translationY + "px";
const style = {
transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY})`,
};
return (
<div
<PictureInPictureDragger
className="mx_CallPreview"
style={style}
ref={this.callViewWrapper}
draggable={pipMode}
>
<CallView
{ ({ onStartMoving, onResize }) => <CallView
onMouseDownOnHeader={onStartMoving}
call={this.state.primaryCall}
secondaryCall={this.state.secondaryCall}
pipMode={true}
onMouseDownOnHeader={this.onStartMoving}
onResize={this.onResize}
/>
</div>
pipMode={pipMode}
onResize={onResize}
/> }
</PictureInPictureDragger>
);
}
return <PersistentApp />;
}
}

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket 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");
@ -23,40 +23,39 @@ 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 CallContextMenu from '../context_menus/CallContextMenu';
import { avatarUrlForMember } from '../../../Avatar';
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
import Modal from '../../../Modal';
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
import CallViewSidebar from './CallViewSidebar';
import CallViewHeader from './CallView/CallViewHeader';
import CallViewButtons from "./CallView/CallViewButtons";
interface IProps {
// The call for us to display
call: MatrixCall;
// The call for us to display
call: MatrixCall;
// Another ongoing call to display information about
secondaryCall?: MatrixCall;
// Another ongoing call to display information about
secondaryCall?: MatrixCall;
// a callback which is called when the content in the CallView changes
// in a way that is likely to cause a resize.
onResize?: any;
// a callback which is called when the content in the CallView changes
// in a way that is likely to cause a resize.
onResize?: (event: Event) => void;
// Whether this call view is for picture-in-picture mode
// otherwise, it's the larger call view when viewing the room the call is in.
// This is sort of a proxy for a number of things but we currently have no
// need to control those things separately, so this is simpler.
pipMode?: boolean;
// Whether this call view is for picture-in-picture mode
// otherwise, it's the larger call view when viewing the room the call is in.
// This is sort of a proxy for a number of things but we currently have no
// need to control those things separately, so this is simpler.
pipMode?: boolean;
// Used for dragging the PiP CallView
onMouseDownOnHeader?: (event: React.MouseEvent) => void;
// Used for dragging the PiP CallView
onMouseDownOnHeader?: (event: React.MouseEvent<Element, MouseEvent>) => void;
}
interface IState {
@ -103,19 +102,11 @@ function exitFullscreen() {
if (exitMethod) exitMethod.call(document);
}
const CONTROLS_HIDE_DELAY = 2000;
// Height of the header duplicated from CSS because we need to subtract it from our max
// height to get the max height of the video
const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px)
@replaceableComponent("views.voip.CallView")
export default class CallView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private contentRef = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>();
private contextMenu = createRef<HTMLDivElement>();
private buttonsRef = createRef<CallViewButtons>();
constructor(props: IProps) {
super(props);
@ -231,31 +222,8 @@ export default class CallView extends React.Component<IProps, IState> {
});
};
private onFullscreenClick = () => {
dis.dispatch({
action: 'video_fullscreen',
fullscreen: true,
});
};
private onExpandClick = () => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
dis.dispatch({
action: 'view_room',
room_id: userFacingRoomId,
});
};
private onControlsHideTimer = () => {
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
this.controlsHideTimer = null;
this.setState({
controlsVisible: false,
});
};
private onMouseMove = () => {
this.showControls();
this.buttonsRef.current?.showControls();
};
private getOrderedFeeds(feeds: Array<CallFeed>): { primary: CallFeed, secondary: Array<CallFeed> } {
@ -281,29 +249,6 @@ export default class CallView extends React.Component<IProps, IState> {
return { primary, secondary };
}
private showControls(): void {
if (this.state.showMoreMenu || this.state.showDialpad) return;
if (!this.state.controlsVisible) {
this.setState({
controlsVisible: true,
});
}
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onDialpadClick = (): void => {
if (!this.state.showDialpad) {
this.setState({ showDialpad: true });
this.showControls();
} else {
this.setState({ showDialpad: false });
}
};
private onMicMuteClick = (): void => {
const newVal = !this.state.micMuted;
@ -334,19 +279,6 @@ export default class CallView extends React.Component<IProps, IState> {
});
};
private onMoreClick = (): void => {
this.setState({ showMoreMenu: true });
this.showControls();
};
private closeDialpad = (): void => {
this.setState({ showDialpad: false });
};
private closeContextMenu = (): void => {
this.setState({ showMoreMenu: false });
};
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
// Note that this assumes we always have a CallView on screen at any given time
// CallHandler would probably be a better place for this
@ -359,7 +291,7 @@ export default class CallView extends React.Component<IProps, IState> {
if (ctrlCmdOnly) {
this.onMicMuteClick();
// show the controls to give feedback
this.showControls();
this.buttonsRef.current?.showControls();
handled = true;
}
break;
@ -368,7 +300,7 @@ export default class CallView extends React.Component<IProps, IState> {
if (ctrlCmdOnly) {
this.onVidMuteClick();
// show the controls to give feedback
this.showControls();
this.buttonsRef.current?.showControls();
handled = true;
}
break;
@ -380,32 +312,6 @@ export default class CallView extends React.Component<IProps, IState> {
}
};
private onCallControlsMouseEnter = (): void => {
this.setState({ hoveringControls: true });
this.showControls();
};
private onCallControlsMouseLeave = (): void => {
this.setState({ hoveringControls: false });
};
private onRoomAvatarClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
dis.dispatch({
action: 'view_room',
room_id: userFacingRoomId,
});
};
private onSecondaryRoomAvatarClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
dis.dispatch({
action: 'view_room',
room_id: userFacingRoomId,
});
};
private onCallResumeClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
@ -424,180 +330,60 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onToggleSidebar = (): void => {
this.setState({
sidebarShown: !this.state.sidebarShown,
});
this.setState({ sidebarShown: !this.state.sidebarShown });
};
private renderCallControls(): JSX.Element {
const micClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: !this.state.micMuted,
mx_CallView_callControls_button_micOff: this.state.micMuted,
});
const vidClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: !this.state.vidMuted,
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
});
const screensharingClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_screensharingOn: this.state.screensharing,
mx_CallView_callControls_button_screensharingOff: !this.state.screensharing,
});
const sidebarButtonClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_sidebarOn: this.state.sidebarShown,
mx_CallView_callControls_button_sidebarOff: !this.state.sidebarShown,
});
// Put the other states of the mic/video icons in the document to make sure they're cached
// (otherwise the icon disappears briefly when toggled)
const micCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: this.state.micMuted,
mx_CallView_callControls_button_micOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const vidCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: this.state.micMuted,
mx_CallView_callControls_button_vidOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const callControlsClasses = classNames({
mx_CallView_callControls: true,
mx_CallView_callControls_hidden: !this.state.controlsVisible,
});
// We don't support call upgrades (yet) so hide the video mute button in voice calls
let vidMuteButton;
if (this.props.call.type === CallType.Video) {
vidMuteButton = (
<AccessibleButton
className={vidClasses}
onClick={this.onVidMuteClick}
/>
);
}
const vidMuteButtonShown = this.props.call.type === CallType.Video;
// Screensharing is possible, if we can send a second stream and
// identify it using SDPStreamMetadata or if we can replace the already
// existing usermedia track by a screensharing track. We also need to be
// connected to know the state of the other side
let screensharingButton;
if (
const screensharingButtonShown = (
(this.props.call.opponentSupportsSDPStreamMetadata() || this.props.call.type === CallType.Video) &&
this.props.call.state === CallState.Connected
) {
screensharingButton = (
<AccessibleButton
className={screensharingClasses}
onClick={this.onScreenshareClick}
/>
);
}
);
// To show the sidebar we need secondary feeds, if we don't have them,
// we can hide this button. If we are in PiP, sidebar is also hidden, so
// we can hide the button too
let sidebarButton;
if (
!this.props.pipMode &&
(
this.state.primaryFeed?.purpose === SDPStreamMetadataPurpose.Screenshare ||
this.props.call.isScreensharing()
)
) {
sidebarButton = (
<AccessibleButton
className={sidebarButtonClasses}
onClick={this.onToggleSidebar}
/>
);
}
const sidebarButtonShown = (
this.state.primaryFeed?.purpose === SDPStreamMetadataPurpose.Screenshare ||
this.props.call.isScreensharing()
);
// The dial pad & 'more' button actions are only relevant in a connected call
let contextMenuButton;
if (this.state.callState === CallState.Connected) {
contextMenuButton = (
<ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
/>
);
}
let dialpadButton;
if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) {
dialpadButton = (
<ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
/>
);
}
let dialPad;
if (this.state.showDialpad) {
dialPad = <DialpadContextMenu
{...alwaysAboveRightOf(
this.dialpadButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
mountAsChild={true}
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={true}
onFinished={this.closeContextMenu}
call={this.props.call}
/>;
}
const contextMenuButtonShown = this.state.callState === CallState.Connected;
const dialpadButtonShown = (
this.state.callState === CallState.Connected &&
this.props.call.opponentSupportsDTMF()
);
return (
<div
className={callControlsClasses}
onMouseEnter={this.onCallControlsMouseEnter}
onMouseLeave={this.onCallControlsMouseLeave}
>
{ dialPad }
{ contextMenu }
{ dialpadButton }
<AccessibleButton
className={micClasses}
onClick={this.onMicMuteClick}
/>
{ vidMuteButton }
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
{ screensharingButton }
{ sidebarButton }
{ contextMenuButton }
<AccessibleButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={this.onHangupClick}
/>
</div>
<CallViewButtons
ref={this.buttonsRef}
call={this.props.call}
pipMode={this.props.pipMode}
handlers={{
onToggleSidebarClick: this.onToggleSidebar,
onScreenshareClick: this.onScreenshareClick,
onHangupClick: this.onHangupClick,
onMicMuteClick: this.onMicMuteClick,
onVidMuteClick: this.onVidMuteClick,
}}
buttonsState={{
micMuted: this.state.micMuted,
vidMuted: this.state.vidMuted,
sidebarShown: this.state.sidebarShown,
screensharing: this.state.screensharing,
}}
buttonsVisibility={{
vidMute: vidMuteButtonShown,
screensharing: screensharingButtonShown,
sidebar: sidebarButtonShown,
contextMenu: contextMenuButtonShown,
dialpad: dialpadButtonShown,
}}
/>
);
}
@ -692,7 +478,7 @@ export default class CallView extends React.Component<IProps, IState> {
let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
const backgroundAvatarUrl = avatarUrlForMember(
// is it worth getting the size of the div to pass here?
// is it worth getting the size of the div to pass here?
this.props.call.getOpponentMember(), 1024, 1024, 'crop',
);
backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')';
@ -712,7 +498,7 @@ export default class CallView extends React.Component<IProps, IState> {
mx_CallView_voice_hold: isOnHold,
});
contentView =(
contentView = (
<div className={classes} onMouseMove={this.onMouseMove}>
<div className="mx_CallView_voice_avatarsContainer">
<div
@ -814,83 +600,15 @@ export default class CallView extends React.Component<IProps, IState> {
);
}
const callTypeText = isVideoCall ? _t("Video Call") : _t("Voice Call");
let myClassName;
let fullScreenButton;
if (!this.props.pipMode) {
fullScreenButton = (
<div
className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
onClick={this.onFullscreenClick}
title={_t("Fill Screen")}
/>
);
}
let expandButton;
if (this.props.pipMode) {
expandButton = <div
className="mx_CallView_header_button mx_CallView_header_button_expand"
onClick={this.onExpandClick}
title={_t("Return to call")}
/>;
}
const headerControls = <div className="mx_CallView_header_controls">
{ fullScreenButton }
{ expandButton }
</div>;
const callTypeIconClassName = classNames("mx_CallView_header_callTypeIcon", {
"mx_CallView_header_callTypeIcon_voice": !isVideoCall,
"mx_CallView_header_callTypeIcon_video": isVideoCall,
});
let header: React.ReactNode;
if (!this.props.pipMode) {
header = <div className="mx_CallView_header">
<div className={callTypeIconClassName} />
<span className="mx_CallView_header_callType">{ callTypeText }</span>
{ headerControls }
</div>;
myClassName = 'mx_CallView_large';
} else {
let secondaryCallInfo;
if (this.props.secondaryCall) {
secondaryCallInfo = <span className="mx_CallView_header_secondaryCallInfo">
<AccessibleButton element='span' onClick={this.onSecondaryRoomAvatarClick}>
<RoomAvatar room={secCallRoom} height={16} width={16} />
<span className="mx_CallView_secondaryCall_roomName">
{ _t("%(name)s on hold", { name: secCallRoom.name }) }
</span>
</AccessibleButton>
</span>;
}
header = (
<div
className="mx_CallView_header"
onMouseDown={this.props.onMouseDownOnHeader}
>
<AccessibleButton onClick={this.onRoomAvatarClick}>
<RoomAvatar room={callRoom} height={32} width={32} />
</AccessibleButton>
<div className="mx_CallView_header_callInfo">
<div className="mx_CallView_header_roomName">{ callRoom.name }</div>
<div className="mx_CallView_header_callTypeSmall">
{ callTypeText }
{ secondaryCallInfo }
</div>
</div>
{ headerControls }
</div>
);
myClassName = 'mx_CallView_pip';
}
const myClassName = this.props.pipMode ? 'mx_CallView_pip' : 'mx_CallView_large';
return <div className={"mx_CallView " + myClassName}>
{ header }
<CallViewHeader
onPipMouseDown={this.props.onMouseDownOnHeader}
pipMode={this.props.pipMode}
type={this.props.call.type}
callRooms={[callRoom, secCallRoom]}
/>
{ contentView }
</div>;
}

View file

@ -0,0 +1,315 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from "react";
import classNames from "classnames";
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
import CallContextMenu from "../../context_menus/CallContextMenu";
import DialpadContextMenu from "../../context_menus/DialpadContextMenu";
import AccessibleButton from "../../elements/AccessibleButton";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { Alignment } from "../../elements/Tooltip";
import {
alwaysAboveLeftOf,
alwaysAboveRightOf,
ChevronFace,
ContextMenuTooltipButton,
} from '../../../structures/ContextMenu';
import { _t } from "../../../../languageHandler";
// Height of the header duplicated from CSS because we need to subtract it from our max
// height to get the max height of the video
const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px)
const TOOLTIP_Y_OFFSET = -24;
const CONTROLS_HIDE_DELAY = 2000;
interface IProps {
call: MatrixCall;
pipMode: boolean;
handlers: {
onHangupClick: () => void;
onScreenshareClick: () => void;
onToggleSidebarClick: () => void;
onMicMuteClick: () => void;
onVidMuteClick: () => void;
};
buttonsState: {
micMuted: boolean;
vidMuted: boolean;
sidebarShown: boolean;
screensharing: boolean;
};
buttonsVisibility: {
screensharing: boolean;
vidMute: boolean;
sidebar: boolean;
dialpad: boolean;
contextMenu: boolean;
};
}
interface IState {
visible: boolean;
showDialpad: boolean;
hoveringControls: boolean;
showMoreMenu: boolean;
}
export default class CallViewButtons extends React.Component<IProps, IState> {
private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
constructor(props: IProps) {
super(props);
this.state = {
showDialpad: false,
hoveringControls: false,
showMoreMenu: false,
visible: true,
};
}
public componentDidMount(): void {
this.showControls();
}
public showControls(): void {
if (this.state.showMoreMenu || this.state.showDialpad) return;
if (!this.state.visible) {
this.setState({
visible: true,
});
}
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onControlsHideTimer = (): void => {
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
this.controlsHideTimer = null;
this.setState({ visible: false });
};
private onMouseEnter = (): void => {
this.setState({ hoveringControls: true });
};
private onMouseLeave = (): void => {
this.setState({ hoveringControls: false });
};
private onDialpadClick = (): void => {
if (!this.state.showDialpad) {
this.setState({ showDialpad: true });
this.showControls();
} else {
this.setState({ showDialpad: false });
}
};
private onMoreClick = (): void => {
this.setState({ showMoreMenu: true });
this.showControls();
};
private closeDialpad = (): void => {
this.setState({ showDialpad: false });
};
private closeContextMenu = (): void => {
this.setState({ showMoreMenu: false });
};
public render(): JSX.Element {
const micClasses = classNames("mx_CallViewButtons_button", {
mx_CallViewButtons_button_micOn: !this.props.buttonsState.micMuted,
mx_CallViewButtons_button_micOff: this.props.buttonsState.micMuted,
});
const vidClasses = classNames("mx_CallViewButtons_button", {
mx_CallViewButtons_button_vidOn: !this.props.buttonsState.vidMuted,
mx_CallViewButtons_button_vidOff: this.props.buttonsState.vidMuted,
});
const screensharingClasses = classNames("mx_CallViewButtons_button", {
mx_CallViewButtons_button_screensharingOn: this.props.buttonsState.screensharing,
mx_CallViewButtons_button_screensharingOff: !this.props.buttonsState.screensharing,
});
const sidebarButtonClasses = classNames("mx_CallViewButtons_button", {
mx_CallViewButtons_button_sidebarOn: this.props.buttonsState.sidebarShown,
mx_CallViewButtons_button_sidebarOff: !this.props.buttonsState.sidebarShown,
});
// Put the other states of the mic/video icons in the document to make sure they're cached
// (otherwise the icon disappears briefly when toggled)
const micCacheClasses = classNames("mx_CallViewButtons_button", "mx_CallViewButtons_button_invisible", {
mx_CallViewButtons_button_micOn: this.props.buttonsState.micMuted,
mx_CallViewButtons_button_micOff: !this.props.buttonsState.micMuted,
});
const vidCacheClasses = classNames("mx_CallViewButtons_button", "mx_CallViewButtons_button_invisible", {
mx_CallViewButtons_button_vidOn: this.props.buttonsState.micMuted,
mx_CallViewButtons_button_vidOff: !this.props.buttonsState.micMuted,
});
const callControlsClasses = classNames("mx_CallViewButtons", {
mx_CallViewButtons_hidden: !this.state.visible,
});
let vidMuteButton;
if (this.props.buttonsVisibility.vidMute) {
vidMuteButton = (
<AccessibleTooltipButton
className={vidClasses}
onClick={this.props.handlers.onVidMuteClick}
title={this.props.buttonsState.vidMuted ? _t("Start the camera") : _t("Stop the camera")}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
);
}
let screensharingButton;
if (this.props.buttonsVisibility.screensharing) {
screensharingButton = (
<AccessibleTooltipButton
className={screensharingClasses}
onClick={this.props.handlers.onScreenshareClick}
title={this.props.buttonsState.screensharing
? _t("Stop sharing your screen")
: _t("Start sharing your screen")
}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
);
}
let sidebarButton;
if (this.props.buttonsVisibility.sidebar) {
sidebarButton = (
<AccessibleButton
className={sidebarButtonClasses}
onClick={this.props.handlers.onToggleSidebarClick}
aria-label={this.props.buttonsState.sidebarShown ? _t("Hide sidebar") : _t("Show sidebar")}
/>
);
}
let contextMenuButton;
if (this.props.buttonsVisibility.contextMenu) {
contextMenuButton = (
<ContextMenuTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
title={_t("More")}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
);
}
let dialpadButton;
if (this.props.buttonsVisibility.dialpad) {
dialpadButton = (
<ContextMenuTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
title={_t("Dialpad")}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
);
}
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.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
{ dialPad }
{ contextMenu }
{ dialpadButton }
<AccessibleTooltipButton
className={micClasses}
onClick={this.props.handlers.onMicMuteClick}
title={this.props.buttonsState.micMuted ? _t("Unmute the microphone") : _t("Mute the microphone")}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
{ vidMuteButton }
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
{ screensharingButton }
{ sidebarButton }
{ contextMenuButton }
<AccessibleTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_button_hangup"
onClick={this.props.handlers.onHangupClick}
title={_t("Hangup")}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
</div>
);
}
}

View file

@ -0,0 +1,135 @@
/*
Copyright 2021 New Vector Ltd
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 { CallType } from 'matrix-js-sdk/src/webrtc/call';
import { Room } from 'matrix-js-sdk/src/models/room';
import React from 'react';
import { _t, _td } from '../../../../languageHandler';
import RoomAvatar from '../../avatars/RoomAvatar';
import AccessibleButton from '../../elements/AccessibleButton';
import dis from '../../../../dispatcher/dispatcher';
import classNames from 'classnames';
import AccessibleTooltipButton from '../../elements/AccessibleTooltipButton';
const callTypeTranslationByType: Record<CallType, string> = {
[CallType.Video]: _td("Video Call"),
[CallType.Voice]: _td("Voice Call"),
};
interface CallViewHeaderProps {
pipMode: boolean;
type: CallType;
callRooms?: Room[];
onPipMouseDown: (event: React.MouseEvent<Element, MouseEvent>) => void;
}
const onRoomAvatarClick = (roomId: string) => {
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
};
const onFullscreenClick = () => {
dis.dispatch({
action: 'video_fullscreen',
fullscreen: true,
});
};
const onExpandClick = (roomId: string) => {
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
};
type CallControlsProps = Pick<CallViewHeaderProps, 'pipMode' | 'type'> & {
roomId: string;
};
const CallViewHeaderControls: React.FC<CallControlsProps> = ({ pipMode = false, type, roomId }) => {
return <div className="mx_CallViewHeader_controls">
{ !pipMode && <AccessibleTooltipButton
className="mx_CallViewHeader_button mx_CallViewHeader_button_fullscreen"
onClick={onFullscreenClick}
title={_t("Fill Screen")}
/> }
{ pipMode && <AccessibleTooltipButton
className="mx_CallViewHeader_button mx_CallViewHeader_button_expand"
onClick={() => onExpandClick(roomId)}
title={_t("Return to call")}
/> }
</div>;
};
const SecondaryCallInfo: React.FC<{ callRoom: Room }> = ({ callRoom }) => {
return <span className="mx_CallViewHeader_secondaryCallInfo">
<AccessibleButton element='span' onClick={() => onRoomAvatarClick(callRoom.roomId)}>
<RoomAvatar room={callRoom} height={16} width={16} />
<span className="mx_CallView_secondaryCall_roomName">
{ _t("%(name)s on hold", { name: callRoom.name }) }
</span>
</AccessibleButton>
</span>;
};
const CallTypeIcon: React.FC<{ type: CallType }> = ({ type }) => {
const classes = classNames({
'mx_CallViewHeader_callTypeIcon': true,
'mx_CallViewHeader_callTypeIcon_video': type === CallType.Video,
'mx_CallViewHeader_callTypeIcon_voice': type === CallType.Voice,
});
return <div className={classes} />;
};
const CallViewHeader: React.FC<CallViewHeaderProps> = ({
type,
pipMode = false,
callRooms = [],
onPipMouseDown,
}) => {
const [callRoom, onHoldCallRoom] = callRooms;
const callTypeText = _t(callTypeTranslationByType[type]);
const callRoomName = callRoom.name;
const { roomId } = callRoom;
if (!pipMode) {
return <div className="mx_CallViewHeader">
<CallTypeIcon type={type} />
<span className="mx_CallViewHeader_callType">{ callTypeText }</span>
<CallViewHeaderControls roomId={roomId} pipMode={pipMode} type={type} />
</div>;
}
return (
<div
className="mx_CallViewHeader"
onMouseDown={onPipMouseDown}
>
<AccessibleButton onClick={() => onRoomAvatarClick(roomId)}>
<RoomAvatar room={callRoom} height={32} width={32} />
</AccessibleButton>
<div className="mx_CallViewHeader_callInfo">
<div className="mx_CallViewHeader_roomName">{ callRoomName }</div>
<div className="mx_CallViewHeader_callTypeSmall">
{ callTypeText }
{ onHoldCallRoom && <SecondaryCallInfo callRoom={onHoldCallRoom} /> }
</div>
</div>
<CallViewHeaderControls roomId={roomId} pipMode={pipMode} type={type} />
</div>
);
};
export default CallViewHeader;

View file

@ -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>;
}
}

View file

@ -0,0 +1,229 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import UIStore from '../../../stores/UIStore';
import { lerp } from '../../../utils/AnimationUtils';
import { MarkedExecution } from '../../../utils/MarkedExecution';
import { replaceableComponent } from '../../../utils/replaceableComponent';
const PIP_VIEW_WIDTH = 336;
const PIP_VIEW_HEIGHT = 232;
const MOVING_AMT = 0.2;
const SNAPPING_AMT = 0.1;
const PADDING = {
top: 58,
bottom: 58,
left: 76,
right: 8,
};
interface IChildrenOptions {
onStartMoving: (event: React.MouseEvent<Element, MouseEvent>) => void;
onResize: (event: Event) => void;
}
interface IProps {
className?: string;
children: ({ onStartMoving, onResize }: IChildrenOptions) => React.ReactNode;
draggable: boolean;
}
interface IState {
// Position of the PictureInPictureDragger
translationX: number;
translationY: number;
}
/**
* PictureInPictureDragger shows a small version of CallView hovering over the UI in 'picture-in-picture'
* (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing.
*/
@replaceableComponent("views.voip.PictureInPictureDragger")
export default class PictureInPictureDragger extends React.Component<IProps, IState> {
private callViewWrapper = createRef<HTMLDivElement>();
private initX = 0;
private initY = 0;
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT;
private moving = false;
private scheduledUpdate = new MarkedExecution(
() => this.animationCallback(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
constructor(props: IProps) {
super(props);
this.state = {
translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH,
translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT,
};
}
public componentDidMount() {
document.addEventListener("mousemove", this.onMoving);
document.addEventListener("mouseup", this.onEndMoving);
window.addEventListener("resize", this.onResize);
}
public componentWillUnmount() {
document.removeEventListener("mousemove", this.onMoving);
document.removeEventListener("mouseup", this.onEndMoving);
window.removeEventListener("resize", this.onResize);
}
private animationCallback = () => {
// If the PiP isn't being dragged and there is only a tiny difference in
// the desiredTranslation and translation, quit the animationCallback
// loop. If that is the case, it means the PiP has snapped into its
// position and there is nothing to do. Not doing this would cause an
// infinite loop
if (
!this.moving &&
Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 &&
Math.abs(this.state.translationY - this.desiredTranslationY) <= 1
) return;
const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
this.setState({
translationX: lerp(this.state.translationX, this.desiredTranslationX, amt),
translationY: lerp(this.state.translationY, this.desiredTranslationY, amt),
});
this.scheduledUpdate.mark();
};
private setTranslation(inTranslationX: number, inTranslationY: number) {
const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
// Avoid overflow on the x axis
if (inTranslationX + width >= UIStore.instance.windowWidth) {
this.desiredTranslationX = UIStore.instance.windowWidth - width;
} else if (inTranslationX <= 0) {
this.desiredTranslationX = 0;
} else {
this.desiredTranslationX = inTranslationX;
}
// Avoid overflow on the y axis
if (inTranslationY + height >= UIStore.instance.windowHeight) {
this.desiredTranslationY = UIStore.instance.windowHeight - height;
} else if (inTranslationY <= 0) {
this.desiredTranslationY = 0;
} else {
this.desiredTranslationY = inTranslationY;
}
}
private onResize = (): void => {
this.snap(false);
};
private snap = (animate = false) => {
const translationX = this.desiredTranslationX;
const translationY = this.desiredTranslationY;
// We subtract the PiP size from the window size in order to calculate
// the position to snap to from the PiP center and not its top-left
// corner
const windowWidth = (
UIStore.instance.windowWidth -
(this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH)
);
const windowHeight = (
UIStore.instance.windowHeight -
(this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT)
);
if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = PADDING.top;
} else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = PADDING.top;
}
// We start animating here because we want the PiP to move when we're
// resizing the window
this.scheduledUpdate.mark();
if (animate) {
// We start animating here because we want the PiP to move when we're
// resizing the window
this.scheduledUpdate.mark();
} else {
this.setState({
translationX: this.desiredTranslationX,
translationY: this.desiredTranslationY,
});
}
};
private onStartMoving = (event: React.MouseEvent | MouseEvent) => {
event.preventDefault();
event.stopPropagation();
this.moving = true;
this.initX = event.pageX - this.desiredTranslationX;
this.initY = event.pageY - this.desiredTranslationY;
this.scheduledUpdate.mark();
};
private onMoving = (event: React.MouseEvent | MouseEvent) => {
if (!this.moving) return;
event.preventDefault();
event.stopPropagation();
this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
};
private onEndMoving = () => {
this.moving = false;
this.snap(true);
};
public render() {
const translatePixelsX = this.state.translationX + "px";
const translatePixelsY = this.state.translationY + "px";
const style = {
transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY})`,
};
return (
<div
className={this.props.className}
style={this.props.draggable ? style : undefined}
ref={this.callViewWrapper}
>
<>
{ this.props.children({
onStartMoving: this.onStartMoving,
onResize: this.onResize,
}) }
</>
</div>
);
}
}

View file

@ -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>
);
}
}

View file

@ -184,7 +184,8 @@ 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 },

View file

@ -31,18 +31,26 @@ export interface IEncryptedFile {
v: string;
}
export interface IMediaEventContent {
body?: string;
url?: string; // required on unencrypted media
file?: IEncryptedFile; // required for *encrypted* media
info?: {
thumbnail_url?: string; // eslint-disable-line camelcase
thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase
export interface IMediaEventInfo {
thumbnail_url?: string; // eslint-disable-line camelcase
thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase
thumbnail_info?: { // eslint-disable-line camelcase
mimetype: string;
w?: number;
h?: number;
size?: number;
};
mimetype: string;
w?: number;
h?: number;
size?: number;
}
export interface IMediaEventContent {
body?: string;
url?: string; // required on unencrypted media
file?: IEncryptedFile; // required for *encrypted* media
info?: IMediaEventInfo;
}
export interface IPreparedMedia extends IMediaObject {

View file

@ -198,4 +198,11 @@ export enum Action {
* 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",
}

View 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;
}

View file

@ -43,7 +43,7 @@ export default class AutocompleteWrapperModel {
) {
}
public onEscape(e: KeyboardEvent) {
public onEscape(e: KeyboardEvent): void {
this.getAutocompleterComponent().onEscape(e);
this.updateCallback({
replaceParts: [this.partCreator.plain(this.queryPart.text)],
@ -51,27 +51,27 @@ export default class AutocompleteWrapperModel {
});
}
public close() {
public close(): void {
this.updateCallback({ close: true });
}
public hasSelection() {
public hasSelection(): boolean {
return this.getAutocompleterComponent().hasSelection();
}
public hasCompletions() {
public hasCompletions(): boolean {
const ac = this.getAutocompleterComponent();
return ac && ac.countCompletions() > 0;
}
public onEnter() {
public onEnter(): void {
this.updateCallback({ close: true });
}
/**
* If there is no current autocompletion, start one and move to the first selection.
*/
public async startSelection() {
public async startSelection(): Promise<void> {
const acComponent = this.getAutocompleterComponent();
if (acComponent.countCompletions() === 0) {
// Force completions to show for the text currently entered
@ -81,15 +81,15 @@ export default class AutocompleteWrapperModel {
}
}
public selectPreviousSelection() {
public selectPreviousSelection(): void {
this.getAutocompleterComponent().moveSelection(-1);
}
public selectNextSelection() {
public selectNextSelection(): void {
this.getAutocompleterComponent().moveSelection(+1);
}
public onPartUpdate(part: Part, pos: DocumentPosition) {
public onPartUpdate(part: Part, pos: DocumentPosition): Promise<void> {
// cache the typed value and caret here
// so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
this.queryPart = part;
@ -97,7 +97,7 @@ export default class AutocompleteWrapperModel {
return this.updateQuery(part.text);
}
public onComponentSelectionChange(completion: ICompletion) {
public onComponentSelectionChange(completion: ICompletion): void {
if (!completion) {
this.updateCallback({
replaceParts: [this.queryPart],
@ -109,14 +109,14 @@ export default class AutocompleteWrapperModel {
}
}
public onComponentConfirm(completion: ICompletion) {
public onComponentConfirm(completion: ICompletion): void {
this.updateCallback({
replaceParts: this.partForCompletion(completion),
close: true,
});
}
private partForCompletion(completion: ICompletion) {
private partForCompletion(completion: ICompletion): Part[] {
const { completionId } = completion;
const text = completion.completion;
switch (completion.type) {

View file

@ -19,7 +19,7 @@ import { needsCaretNodeBefore, needsCaretNodeAfter } from "./render";
import Range from "./range";
import EditorModel from "./model";
import DocumentPosition, { IPosition } from "./position";
import { Part } from "./parts";
import { Part, Type } from "./parts";
export type Caret = Range | DocumentPosition;
@ -113,7 +113,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
// to find newline parts
for (let i = 0; i <= partIndex; ++i) {
const part = parts[i];
if (part.type === "newline") {
if (part.type === Type.Newline) {
lineIndex += 1;
nodeIndex = -1;
prevPart = null;
@ -128,7 +128,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
// and not an adjacent caret node
if (i < partIndex) {
const nextPart = parts[i + 1];
const isLastOfLine = !nextPart || nextPart.type === "newline";
const isLastOfLine = !nextPart || nextPart.type === Type.Newline;
if (needsCaretNodeAfter(part, isLastOfLine)) {
nodeIndex += 1;
}

View file

@ -20,7 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { walkDOMDepthFirst } from "./dom";
import { checkBlockNode } from "../HtmlUtils";
import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks";
import { PartCreator } from "./parts";
import { PartCreator, Type } from "./parts";
import SdkConfig from "../SdkConfig";
function parseAtRoomMentions(text: string, partCreator: PartCreator) {
@ -206,7 +206,7 @@ function prefixQuoteLines(isFirstNode, parts, partCreator) {
parts.splice(0, 0, partCreator.plain(QUOTE_LINE_PREFIX));
}
for (let i = 0; i < parts.length; i += 1) {
if (parts[i].type === "newline") {
if (parts[i].type === Type.Newline) {
parts.splice(i + 1, 0, partCreator.plain(QUOTE_LINE_PREFIX));
i += 1;
}

View file

@ -21,7 +21,7 @@ export interface IDiff {
at?: number;
}
function firstDiff(a: string, b: string) {
function firstDiff(a: string, b: string): number {
const compareLen = Math.min(a.length, b.length);
for (let i = 0; i < compareLen; ++i) {
if (a[i] !== b[i]) {

View file

@ -36,7 +36,7 @@ export default class HistoryManager {
private addedSinceLastPush = false;
private removedSinceLastPush = false;
clear() {
public clear(): void {
this.stack = [];
this.newlyTypedCharCount = 0;
this.currentIndex = -1;
@ -103,7 +103,7 @@ export default class HistoryManager {
}
// needs to persist parts and caret position
tryPush(model: EditorModel, caret: Caret, inputType: string, diff: IDiff) {
public tryPush(model: EditorModel, caret: Caret, inputType: string, diff: IDiff): boolean {
// ignore state restoration echos.
// these respect the inputType values of the input event,
// but are actually passed in from MessageEditor calling model.reset()
@ -121,22 +121,22 @@ export default class HistoryManager {
return shouldPush;
}
ensureLastChangesPushed(model: EditorModel) {
public ensureLastChangesPushed(model: EditorModel): void {
if (this.changedSinceLastPush) {
this.pushState(model, this.lastCaret);
}
}
canUndo() {
public canUndo(): boolean {
return this.currentIndex >= 1 || this.changedSinceLastPush;
}
canRedo() {
public canRedo(): boolean {
return this.currentIndex < (this.stack.length - 1);
}
// returns state that should be applied to model
undo(model: EditorModel) {
public undo(model: EditorModel): IHistory {
if (this.canUndo()) {
this.ensureLastChangesPushed(model);
this.currentIndex -= 1;
@ -145,7 +145,7 @@ export default class HistoryManager {
}
// returns state that should be applied to model
redo() {
public redo(): IHistory {
if (this.canRedo()) {
this.changedSinceLastPush = false;
this.currentIndex += 1;

View file

@ -15,16 +15,17 @@ limitations under the License.
*/
import EditorModel from "./model";
import DocumentPosition from "./position";
export default class DocumentOffset {
constructor(public offset: number, public readonly atNodeEnd: boolean) {
}
asPosition(model: EditorModel) {
public asPosition(model: EditorModel): DocumentPosition {
return model.positionForOffset(this.offset, this.atNodeEnd);
}
add(delta: number, atNodeEnd = false) {
public add(delta: number, atNodeEnd = false): DocumentOffset {
return new DocumentOffset(this.offset + delta, atNodeEnd);
}
}

View file

@ -15,13 +15,13 @@ limitations under the License.
*/
import Range from "./range";
import { Part } from "./parts";
import { Part, Type } from "./parts";
/**
* Some common queries and transformations on the editor model
*/
export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]) {
export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): void {
const { model } = range;
model.transform(() => {
const oldLen = range.length;
@ -32,7 +32,7 @@ export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]) {
});
}
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]) {
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]): void {
const { model } = range;
model.transform(() => {
const oldLen = range.length;
@ -43,29 +43,29 @@ export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]) {
});
}
export function rangeStartsAtBeginningOfLine(range: Range) {
export function rangeStartsAtBeginningOfLine(range: Range): boolean {
const { model } = range;
const startsWithPartial = range.start.offset !== 0;
const isFirstPart = range.start.index === 0;
const previousIsNewline = !isFirstPart && model.parts[range.start.index - 1].type === "newline";
const previousIsNewline = !isFirstPart && model.parts[range.start.index - 1].type === Type.Newline;
return !startsWithPartial && (isFirstPart || previousIsNewline);
}
export function rangeEndsAtEndOfLine(range: Range) {
export function rangeEndsAtEndOfLine(range: Range): boolean {
const { model } = range;
const lastPart = model.parts[range.end.index];
const endsWithPartial = range.end.offset !== lastPart.text.length;
const isLastPart = range.end.index === model.parts.length - 1;
const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === "newline";
const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === Type.Newline;
return !endsWithPartial && (isLastPart || nextIsNewline);
}
export function formatRangeAsQuote(range: Range) {
export function formatRangeAsQuote(range: Range): void {
const { model, parts } = range;
const { partCreator } = model;
for (let i = 0; i < parts.length; ++i) {
const part = parts[i];
if (part.type === "newline") {
if (part.type === Type.Newline) {
parts.splice(i + 1, 0, partCreator.plain("> "));
}
}
@ -81,10 +81,10 @@ export function formatRangeAsQuote(range: Range) {
replaceRangeAndExpandSelection(range, parts);
}
export function formatRangeAsCode(range: Range) {
export function formatRangeAsCode(range: Range): void {
const { model, parts } = range;
const { partCreator } = model;
const needsBlock = parts.some(p => p.type === "newline");
const needsBlock = parts.some(p => p.type === Type.Newline);
if (needsBlock) {
parts.unshift(partCreator.plain("```"), partCreator.newline());
if (!rangeStartsAtBeginningOfLine(range)) {
@ -105,9 +105,9 @@ export function formatRangeAsCode(range: Range) {
// parts helper methods
const isBlank = part => !part.text || !/\S/.test(part.text);
const isNL = part => part.type === "newline";
const isNL = part => part.type === Type.Newline;
export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix) {
export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix): void {
const { model, parts } = range;
const { partCreator } = model;

View file

@ -25,6 +25,8 @@ import AutocompleteWrapperModel, {
UpdateQuery,
} from "./autocomplete";
import * as Avatar from "../Avatar";
import defaultDispatcher from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
interface ISerializedPart {
type: Type.Plain | Type.Newline | Type.Command | Type.PillCandidate;
@ -39,7 +41,7 @@ interface ISerializedPillPart {
export type SerializedPart = ISerializedPart | ISerializedPillPart;
enum Type {
export enum Type {
Plain = "plain",
Newline = "newline",
Command = "command",
@ -57,12 +59,12 @@ interface IBasePart {
createAutoComplete(updateCallback: UpdateCallback): void;
serialize(): SerializedPart;
remove(offset: number, len: number): string;
remove(offset: number, len: number): string | undefined;
split(offset: number): IBasePart;
validateAndInsert(offset: number, str: string, inputType: string): boolean;
appendUntilRejected(str: string, inputType: string): string;
updateDOMNode(node: Node);
canUpdateDOMNode(node: Node);
appendUntilRejected(str: string, inputType: string): string | undefined;
updateDOMNode(node: Node): void;
canUpdateDOMNode(node: Node): boolean;
toDOMNode(): Node;
}
@ -85,19 +87,19 @@ abstract class BasePart {
this._text = text;
}
acceptsInsertion(chr: string, offset: number, inputType: string) {
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
return true;
}
acceptsRemoval(position: number, chr: string) {
protected acceptsRemoval(position: number, chr: string): boolean {
return true;
}
merge(part: Part) {
public merge(part: Part): boolean {
return false;
}
split(offset: number) {
public split(offset: number): IBasePart {
const splitText = this.text.substr(offset);
this._text = this.text.substr(0, offset);
return new PlainPart(splitText);
@ -105,7 +107,7 @@ abstract class BasePart {
// removes len chars, or returns the plain text this part should be replaced with
// if the part would become invalid if it removed everything.
remove(offset: number, len: number) {
public remove(offset: number, len: number): string | undefined {
// validate
const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
for (let i = offset; i < (len + offset); ++i) {
@ -118,7 +120,7 @@ abstract class BasePart {
}
// append str, returns the remaining string if a character was rejected.
appendUntilRejected(str: string, inputType: string) {
public appendUntilRejected(str: string, inputType: string): string | undefined {
const offset = this.text.length;
for (let i = 0; i < str.length; ++i) {
const chr = str.charAt(i);
@ -132,7 +134,7 @@ abstract class BasePart {
// inserts str at offset if all the characters in str were accepted, otherwise don't do anything
// return whether the str was accepted or not.
validateAndInsert(offset: number, str: string, inputType: string) {
public validateAndInsert(offset: number, str: string, inputType: string): boolean {
for (let i = 0; i < str.length; ++i) {
const chr = str.charAt(i);
if (!this.acceptsInsertion(chr, offset + i, inputType)) {
@ -145,42 +147,42 @@ abstract class BasePart {
return true;
}
createAutoComplete(updateCallback: UpdateCallback): void {}
public createAutoComplete(updateCallback: UpdateCallback): void {}
trim(len: number) {
protected trim(len: number): string {
const remaining = this._text.substr(len);
this._text = this._text.substr(0, len);
return remaining;
}
get text() {
public get text(): string {
return this._text;
}
abstract get type(): Type;
public abstract get type(): Type;
get canEdit() {
public get canEdit(): boolean {
return true;
}
toString() {
public toString(): string {
return `${this.type}(${this.text})`;
}
serialize(): SerializedPart {
public serialize(): SerializedPart {
return {
type: this.type as ISerializedPart["type"],
text: this.text,
};
}
abstract updateDOMNode(node: Node);
abstract canUpdateDOMNode(node: Node);
abstract toDOMNode(): Node;
public abstract updateDOMNode(node: Node): void;
public abstract canUpdateDOMNode(node: Node): boolean;
public abstract toDOMNode(): Node;
}
abstract class PlainBasePart extends BasePart {
acceptsInsertion(chr: string, offset: number, inputType: string) {
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
if (chr === "\n") {
return false;
}
@ -203,11 +205,11 @@ abstract class PlainBasePart extends BasePart {
return true;
}
toDOMNode() {
public toDOMNode(): Node {
return document.createTextNode(this.text);
}
merge(part) {
public merge(part): boolean {
if (part.type === this.type) {
this._text = this.text + part.text;
return true;
@ -215,47 +217,49 @@ abstract class PlainBasePart extends BasePart {
return false;
}
updateDOMNode(node: Node) {
public updateDOMNode(node: Node): void {
if (node.textContent !== this.text) {
node.textContent = this.text;
}
}
canUpdateDOMNode(node: Node) {
public canUpdateDOMNode(node: Node): boolean {
return node.nodeType === Node.TEXT_NODE;
}
}
// exported for unit tests, should otherwise only be used through PartCreator
export class PlainPart extends PlainBasePart implements IBasePart {
get type(): IBasePart["type"] {
public get type(): IBasePart["type"] {
return Type.Plain;
}
}
abstract class PillPart extends BasePart implements IPillPart {
export abstract class PillPart extends BasePart implements IPillPart {
constructor(public resourceId: string, label) {
super(label);
}
acceptsInsertion(chr: string) {
protected acceptsInsertion(chr: string): boolean {
return chr !== " ";
}
acceptsRemoval(position: number, chr: string) {
protected acceptsRemoval(position: number, chr: string): boolean {
return position !== 0; //if you remove initial # or @, pill should become plain
}
toDOMNode() {
public toDOMNode(): Node {
const container = document.createElement("span");
container.setAttribute("spellcheck", "false");
container.setAttribute("contentEditable", "false");
container.onclick = this.onClick;
container.className = this.className;
container.appendChild(document.createTextNode(this.text));
this.setAvatar(container);
return container;
}
updateDOMNode(node: HTMLElement) {
public updateDOMNode(node: HTMLElement): void {
const textNode = node.childNodes[0];
if (textNode.textContent !== this.text) {
textNode.textContent = this.text;
@ -263,10 +267,13 @@ abstract class PillPart extends BasePart implements IPillPart {
if (node.className !== this.className) {
node.className = this.className;
}
if (node.onclick !== this.onClick) {
node.onclick = this.onClick;
}
this.setAvatar(node);
}
canUpdateDOMNode(node: HTMLElement) {
public canUpdateDOMNode(node: HTMLElement): boolean {
return node.nodeType === Node.ELEMENT_NODE &&
node.nodeName === "SPAN" &&
node.childNodes.length === 1 &&
@ -274,7 +281,7 @@ abstract class PillPart extends BasePart implements IPillPart {
}
// helper method for subclasses
protected setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string) {
protected setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string): void {
const avatarBackground = `url('${avatarUrl}')`;
const avatarLetter = `'${initialLetter}'`;
// check if the value is changing,
@ -287,7 +294,7 @@ abstract class PillPart extends BasePart implements IPillPart {
}
}
serialize(): ISerializedPillPart {
public serialize(): ISerializedPillPart {
return {
type: this.type,
text: this.text,
@ -295,41 +302,43 @@ abstract class PillPart extends BasePart implements IPillPart {
};
}
get canEdit() {
public get canEdit(): boolean {
return false;
}
abstract get type(): IPillPart["type"];
public abstract get type(): IPillPart["type"];
abstract get className(): string;
protected abstract get className(): string;
abstract setAvatar(node: HTMLElement): void;
protected onClick?: () => void;
protected abstract setAvatar(node: HTMLElement): void;
}
class NewlinePart extends BasePart implements IBasePart {
acceptsInsertion(chr: string, offset: number) {
protected acceptsInsertion(chr: string, offset: number): boolean {
return offset === 0 && chr === "\n";
}
acceptsRemoval(position: number, chr: string) {
protected acceptsRemoval(position: number, chr: string): boolean {
return true;
}
toDOMNode() {
public toDOMNode(): Node {
return document.createElement("br");
}
merge() {
public merge(): boolean {
return false;
}
updateDOMNode() {}
public updateDOMNode(): void {}
canUpdateDOMNode(node: HTMLElement) {
public canUpdateDOMNode(node: HTMLElement): boolean {
return node.tagName === "BR";
}
get type(): IBasePart["type"] {
public get type(): IBasePart["type"] {
return Type.Newline;
}
@ -337,7 +346,7 @@ class NewlinePart extends BasePart implements IBasePart {
// rather than trying to append to it, which is what we want.
// As a newline can also be only one character, it makes sense
// as it can only be one character long. This caused #9741.
get canEdit() {
public get canEdit(): boolean {
return false;
}
}
@ -347,7 +356,7 @@ class RoomPillPart extends PillPart {
super(resourceId, label);
}
setAvatar(node: HTMLElement) {
protected setAvatar(node: HTMLElement): void {
let initialLetter = "";
let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop");
if (!avatarUrl) {
@ -357,11 +366,11 @@ class RoomPillPart extends PillPart {
this.setAvatarVars(node, avatarUrl, initialLetter);
}
get type(): IPillPart["type"] {
public get type(): IPillPart["type"] {
return Type.RoomPill;
}
get className() {
protected get className() {
return "mx_RoomPill mx_Pill";
}
}
@ -371,11 +380,11 @@ class AtRoomPillPart extends RoomPillPart {
super(text, text, room);
}
get type(): IPillPart["type"] {
public get type(): IPillPart["type"] {
return Type.AtRoomPill;
}
serialize(): ISerializedPillPart {
public serialize(): ISerializedPillPart {
return {
type: this.type,
text: this.text,
@ -388,7 +397,15 @@ class UserPillPart extends PillPart {
super(userId, displayName);
}
setAvatar(node: HTMLElement) {
public get type(): IPillPart["type"] {
return Type.UserPill;
}
protected get className() {
return "mx_UserPill mx_Pill";
}
protected setAvatar(node: HTMLElement): void {
if (!this.member) {
return;
}
@ -402,13 +419,12 @@ class UserPillPart extends PillPart {
this.setAvatarVars(node, avatarUrl, initialLetter);
}
get type(): IPillPart["type"] {
return Type.UserPill;
}
get className() {
return "mx_UserPill mx_Pill";
}
protected onClick = (): void => {
defaultDispatcher.dispatch({
action: Action.ViewUser,
member: this.member,
});
};
}
class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
@ -416,11 +432,11 @@ class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
super(text);
}
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel {
public createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel {
return this.autoCompleteCreator.create(updateCallback);
}
acceptsInsertion(chr: string, offset: number, inputType: string) {
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
if (offset === 0) {
return true;
} else {
@ -428,11 +444,11 @@ class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
}
}
merge() {
public merge(): boolean {
return false;
}
acceptsRemoval(position: number, chr: string) {
protected acceptsRemoval(position: number, chr: string): boolean {
return true;
}
@ -463,17 +479,21 @@ interface IAutocompleteCreator {
export class PartCreator {
protected readonly autoCompleteCreator: IAutocompleteCreator;
constructor(private room: Room, private client: MatrixClient, autoCompleteCreator: AutoCompleteCreator = null) {
constructor(
private readonly room: Room,
private readonly client: MatrixClient,
autoCompleteCreator: AutoCompleteCreator = null,
) {
// pre-create the creator as an object even without callback so it can already be passed
// to PillCandidatePart (e.g. while deserializing) and set later on
this.autoCompleteCreator = { create: autoCompleteCreator && autoCompleteCreator(this) };
this.autoCompleteCreator = { create: autoCompleteCreator?.(this) };
}
setAutoCompleteCreator(autoCompleteCreator: AutoCompleteCreator) {
public setAutoCompleteCreator(autoCompleteCreator: AutoCompleteCreator): void {
this.autoCompleteCreator.create = autoCompleteCreator(this);
}
createPartForInput(input: string, partIndex: number, inputType?: string): Part {
public createPartForInput(input: string, partIndex: number, inputType?: string): Part {
switch (input[0]) {
case "#":
case "@":
@ -487,11 +507,11 @@ export class PartCreator {
}
}
createDefaultPart(text: string) {
public createDefaultPart(text: string): Part {
return this.plain(text);
}
deserializePart(part: SerializedPart): Part {
public deserializePart(part: SerializedPart): Part {
switch (part.type) {
case Type.Plain:
return this.plain(part.text);
@ -508,19 +528,19 @@ export class PartCreator {
}
}
plain(text: string) {
public plain(text: string): PlainPart {
return new PlainPart(text);
}
newline() {
public newline(): NewlinePart {
return new NewlinePart("\n");
}
pillCandidate(text: string) {
public pillCandidate(text: string): PillCandidatePart {
return new PillCandidatePart(text, this.autoCompleteCreator);
}
roomPill(alias: string, roomId?: string) {
public roomPill(alias: string, roomId?: string): RoomPillPart {
let room;
if (roomId || alias[0] !== "#") {
room = this.client.getRoom(roomId || alias);
@ -533,16 +553,20 @@ export class PartCreator {
return new RoomPillPart(alias, room ? room.name : alias, room);
}
atRoomPill(text: string) {
public atRoomPill(text: string): AtRoomPillPart {
return new AtRoomPillPart(text, this.room);
}
userPill(displayName: string, userId: string) {
public userPill(displayName: string, userId: string): UserPillPart {
const member = this.room.getMember(userId);
return new UserPillPart(userId, displayName, member);
}
createMentionParts(insertTrailingCharacter: boolean, displayName: string, userId: string) {
public createMentionParts(
insertTrailingCharacter: boolean,
displayName: string,
userId: string,
): [UserPillPart, PlainPart] {
const pill = this.userPill(displayName, userId);
const postfix = this.plain(insertTrailingCharacter ? ": " : " ");
return [pill, postfix];
@ -567,7 +591,7 @@ export class CommandPartCreator extends PartCreator {
}
public deserializePart(part: SerializedPart): Part {
if (part.type === "command") {
if (part.type === Type.Command) {
return this.command(part.text);
} else {
return super.deserializePart(part);

View file

@ -30,7 +30,7 @@ export default class DocumentPosition implements IPosition {
constructor(public readonly index: number, public readonly offset: number) {
}
compare(otherPos: DocumentPosition) {
public compare(otherPos: DocumentPosition): number {
if (this.index === otherPos.index) {
return this.offset - otherPos.offset;
} else {
@ -38,7 +38,7 @@ export default class DocumentPosition implements IPosition {
}
}
iteratePartsBetween(other: DocumentPosition, model: EditorModel, callback: Callback) {
public iteratePartsBetween(other: DocumentPosition, model: EditorModel, callback: Callback): void {
if (this.index === -1 || other.index === -1) {
return;
}
@ -57,7 +57,7 @@ export default class DocumentPosition implements IPosition {
}
}
forwardsWhile(model: EditorModel, predicate: Predicate) {
public forwardsWhile(model: EditorModel, predicate: Predicate): DocumentPosition {
if (this.index === -1) {
return this;
}
@ -82,7 +82,7 @@ export default class DocumentPosition implements IPosition {
}
}
backwardsWhile(model: EditorModel, predicate: Predicate) {
public backwardsWhile(model: EditorModel, predicate: Predicate): DocumentPosition {
if (this.index === -1) {
return this;
}
@ -107,7 +107,7 @@ export default class DocumentPosition implements IPosition {
}
}
asOffset(model: EditorModel) {
public asOffset(model: EditorModel): DocumentOffset {
if (this.index === -1) {
return new DocumentOffset(0, true);
}
@ -121,7 +121,7 @@ export default class DocumentPosition implements IPosition {
return new DocumentOffset(offset, atEnd);
}
isAtEnd(model: EditorModel) {
public isAtEnd(model: EditorModel): boolean {
if (model.parts.length === 0) {
return true;
}
@ -130,7 +130,7 @@ export default class DocumentPosition implements IPosition {
return this.index === lastPartIdx && this.offset === lastPart.text.length;
}
isAtStart() {
public isAtStart(): boolean {
return this.index === 0 && this.offset === 0;
}
}

View file

@ -32,23 +32,23 @@ export default class Range {
this._end = bIsLarger ? positionB : positionA;
}
moveStart(delta: number) {
public moveStart(delta: number): void {
this._start = this._start.forwardsWhile(this.model, () => {
delta -= 1;
return delta >= 0;
});
}
trim() {
public trim(): void {
this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
this._end = this._end.backwardsWhile(this.model, whitespacePredicate);
}
expandBackwardsWhile(predicate: Predicate) {
public expandBackwardsWhile(predicate: Predicate): void {
this._start = this._start.backwardsWhile(this.model, predicate);
}
get text() {
public get text(): string {
let text = "";
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
const t = part.text.substring(startIdx, endIdx);
@ -63,7 +63,7 @@ export default class Range {
* @param {Part[]} parts the parts to replace the range with
* @return {Number} the net amount of characters added, can be negative.
*/
replace(parts: Part[]) {
public replace(parts: Part[]): number {
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
let oldLength = 0;
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
@ -77,8 +77,8 @@ export default class Range {
* Returns a copy of the (partial) parts within the range.
* For partial parts, only the text is adjusted to the part that intersects with the range.
*/
get parts() {
const parts = [];
public get parts(): Part[] {
const parts: Part[] = [];
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
const serializedPart = part.serialize();
serializedPart.text = part.text.substring(startIdx, endIdx);
@ -88,7 +88,7 @@ export default class Range {
return parts;
}
get length() {
public get length(): number {
let len = 0;
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
len += endIdx - startIdx;
@ -96,11 +96,11 @@ export default class Range {
return len;
}
get start() {
public get start(): DocumentPosition {
return this._start;
}
get end() {
public get end(): DocumentPosition {
return this._end;
}
}

View file

@ -15,19 +15,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Part } from "./parts";
import { Part, Type } from "./parts";
import EditorModel from "./model";
export function needsCaretNodeBefore(part: Part, prevPart: Part) {
const isFirst = !prevPart || prevPart.type === "newline";
export function needsCaretNodeBefore(part: Part, prevPart: Part): boolean {
const isFirst = !prevPart || prevPart.type === Type.Newline;
return !part.canEdit && (isFirst || !prevPart.canEdit);
}
export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean) {
export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean): boolean {
return !part.canEdit && isLastOfLine;
}
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement) {
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement): void {
const next = node.nextSibling;
if (next) {
node.parentElement.insertBefore(nodeToInsert, next);
@ -44,25 +44,25 @@ export const CARET_NODE_CHAR = "\ufeff";
// a caret node is a node that allows the caret to be placed
// where otherwise it wouldn't be possible
// (e.g. next to a pill span without adjacent text node)
function createCaretNode() {
function createCaretNode(): HTMLElement {
const span = document.createElement("span");
span.className = "caretNode";
span.appendChild(document.createTextNode(CARET_NODE_CHAR));
return span;
}
function updateCaretNode(node: HTMLElement) {
function updateCaretNode(node: HTMLElement): void {
// ensure the caret node contains only a zero-width space
if (node.textContent !== CARET_NODE_CHAR) {
node.textContent = CARET_NODE_CHAR;
}
}
export function isCaretNode(node: HTMLElement) {
export function isCaretNode(node: HTMLElement): boolean {
return node && node.tagName === "SPAN" && node.className === "caretNode";
}
function removeNextSiblings(node: ChildNode) {
function removeNextSiblings(node: ChildNode): void {
if (!node) {
return;
}
@ -74,7 +74,7 @@ function removeNextSiblings(node: ChildNode) {
}
}
function removeChildren(parent: HTMLElement) {
function removeChildren(parent: HTMLElement): void {
const firstChild = parent.firstChild;
if (firstChild) {
removeNextSiblings(firstChild);
@ -82,7 +82,7 @@ function removeChildren(parent: HTMLElement) {
}
}
function reconcileLine(lineContainer: ChildNode, parts: Part[]) {
function reconcileLine(lineContainer: ChildNode, parts: Part[]): void {
let currentNode;
let prevPart;
const lastPart = parts[parts.length - 1];
@ -131,13 +131,13 @@ function reconcileLine(lineContainer: ChildNode, parts: Part[]) {
removeNextSiblings(currentNode);
}
function reconcileEmptyLine(lineContainer) {
function reconcileEmptyLine(lineContainer: HTMLElement): void {
// empty div needs to have a BR in it to give it height
let foundBR = false;
let partNode = lineContainer.firstChild;
while (partNode) {
const nextNode = partNode.nextSibling;
if (!foundBR && partNode.tagName === "BR") {
if (!foundBR && (partNode as HTMLElement).tagName === "BR") {
foundBR = true;
} else {
partNode.remove();
@ -149,9 +149,9 @@ function reconcileEmptyLine(lineContainer) {
}
}
export function renderModel(editor: HTMLDivElement, model: EditorModel) {
export function renderModel(editor: HTMLDivElement, model: EditorModel): void {
const lines = model.parts.reduce((linesArr, part) => {
if (part.type === "newline") {
if (part.type === Type.Newline) {
linesArr.push([]);
} else {
const lastLine = linesArr[linesArr.length - 1];
@ -175,7 +175,7 @@ export function renderModel(editor: HTMLDivElement, model: EditorModel) {
if (parts.length) {
reconcileLine(lineContainer, parts);
} else {
reconcileEmptyLine(lineContainer);
reconcileEmptyLine(lineContainer as HTMLElement);
}
});
if (lines.length) {

View file

@ -22,30 +22,31 @@ import { AllHtmlEntities } from 'html-entities';
import SettingsStore from '../settings/SettingsStore';
import SdkConfig from '../SdkConfig';
import cheerio from 'cheerio';
import { Type } from './parts';
export function mdSerialize(model: EditorModel) {
export function mdSerialize(model: EditorModel): string {
return model.parts.reduce((html, part) => {
switch (part.type) {
case "newline":
case Type.Newline:
return html + "\n";
case "plain":
case "command":
case "pill-candidate":
case "at-room-pill":
case Type.Plain:
case Type.Command:
case Type.PillCandidate:
case Type.AtRoomPill:
return html + part.text;
case "room-pill":
case Type.RoomPill:
// Here we use the resourceId for compatibility with non-rich text clients
// See https://github.com/vector-im/element-web/issues/16660
return html +
`[${part.resourceId.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
case "user-pill":
case Type.UserPill:
return html +
`[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
}
}, "");
}
export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false } = {}) {
export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false } = {}): string {
let md = mdSerialize(model);
// copy of raw input to remove unwanted math later
const orig = md;
@ -156,31 +157,31 @@ export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false }
}
}
export function textSerialize(model: EditorModel) {
export function textSerialize(model: EditorModel): string {
return model.parts.reduce((text, part) => {
switch (part.type) {
case "newline":
case Type.Newline:
return text + "\n";
case "plain":
case "command":
case "pill-candidate":
case "at-room-pill":
case Type.Plain:
case Type.Command:
case Type.PillCandidate:
case Type.AtRoomPill:
return text + part.text;
case "room-pill":
case Type.RoomPill:
// Here we use the resourceId for compatibility with non-rich text clients
// See https://github.com/vector-im/element-web/issues/16660
return text + `${part.resourceId}`;
case "user-pill":
case Type.UserPill:
return text + `${part.text}`;
}
}, "");
}
export function containsEmote(model: EditorModel) {
export function containsEmote(model: EditorModel): boolean {
return startsWith(model, "/me ", false);
}
export function startsWith(model: EditorModel, prefix: string, caseSensitive = true) {
export function startsWith(model: EditorModel, prefix: string, caseSensitive = true): boolean {
const firstPart = model.parts[0];
// part type will be "plain" while editing,
// and "command" while composing a message.
@ -190,26 +191,26 @@ export function startsWith(model: EditorModel, prefix: string, caseSensitive = t
text = text.toLowerCase();
}
return firstPart && (firstPart.type === "plain" || firstPart.type === "command") && text.startsWith(prefix);
return firstPart && (firstPart.type === Type.Plain || firstPart.type === Type.Command) && text.startsWith(prefix);
}
export function stripEmoteCommand(model: EditorModel) {
export function stripEmoteCommand(model: EditorModel): EditorModel {
// trim "/me "
return stripPrefix(model, "/me ");
}
export function stripPrefix(model: EditorModel, prefix: string) {
export function stripPrefix(model: EditorModel, prefix: string): EditorModel {
model = model.clone();
model.removeText({ index: 0, offset: 0 }, prefix.length);
return model;
}
export function unescapeMessage(model: EditorModel) {
export function unescapeMessage(model: EditorModel): EditorModel {
const { parts } = model;
if (parts.length) {
const firstPart = parts[0];
// only unescape \/ to / at start of editor
if (firstPart.type === "plain" && firstPart.text.startsWith("\\/")) {
if (firstPart.type === Type.Plain && firstPart.text.startsWith("\\/")) {
model = model.clone();
model.removeText({ index: 0, offset: 0 }, 1);
}

View file

@ -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);
}

View file

@ -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;
};

View file

@ -4,7 +4,7 @@
"Filter room members": "Najít člena místnosti",
"Historical": "Historické",
"Home": "Úvod",
"Jump to first unread message.": "Přeskočit na první nepřečtenou zprávu.",
"Jump to first unread message.": "Přet na první nepřečtenou zprávu.",
"Logout": "Odhlásit se",
"Low priority": "Nízká priorita",
"Notifications": "Oznámení",
@ -13,7 +13,7 @@
"Settings": "Nastavení",
"This room": "Tato místnost",
"Video call": "Videohovor",
"Voice call": "Telefonát",
"Voice call": "Hlasový hovor",
"Sun": "Ne",
"Mon": "Po",
"Tue": "Út",
@ -346,7 +346,7 @@
"example": "příklad",
"Create": "Vytvořit",
"Please select the destination room for this message": "Vyberte prosím pro tuto zprávu cílovou místnost",
"Jump to read receipt": "Skočit na poslední potvrzení o přečtení",
"Jump to read receipt": "Přejít na poslední potvrzení o přečtení",
"Invite": "Pozvat",
"and %(count)s others...|one": "a někdo další...",
"Hangup": "Zavěsit",
@ -765,7 +765,7 @@
"Share Link to User": "Sdílet odkaz na uživatele",
"Send an encrypted reply…": "Odeslat šifrovanou odpověď …",
"Send an encrypted message…": "Odeslat šifrovanou zprávu…",
"Seen by %(displayName)s (%(userName)s) at %(dateTime)s": "%(displayName)s (%(userName)s) viděl %(dateTime)s",
"Seen by %(displayName)s (%(userName)s) at %(dateTime)s": "%(displayName)s (%(userName)s) viděl(a) %(dateTime)s",
"Replying": "Odpovídá",
"Share room": "Sdílet místnost",
"System Alerts": "Systémová varování",
@ -1080,7 +1080,7 @@
"Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Nepovedlo se najít profily následujících Matrix ID - chcete je stejně pozvat?",
"Invite anyway and never warn me again": "Stejně je pozvat a nikdy mě nevarujte znovu",
"Invite anyway": "Stejně je pozvat",
"Before submitting logs, you must <a>create a GitHub issue</a> to describe your problem.": "Pro odeslání záznamů je potřeba <a>vyrobit issue na GitHubu</a> s popisem problému.",
"Before submitting logs, you must <a>create a GitHub issue</a> to describe your problem.": "Pro odeslání záznamů je potřeba <a>vytvořit issue na GitHubu</a> s popisem problému.",
"Unable to load commit detail: %(msg)s": "Nepovedlo se načíst detaily revize: %(msg)s",
"Incompatible Database": "Nekompatibilní databáze",
"Continue With Encryption Disabled": "Pokračovat bez šifrování",
@ -1562,8 +1562,8 @@
"Find a room… (e.g. %(exampleRoom)s)": "Najít místnost… (např. %(exampleRoom)s)",
"If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "Pokud nemůžete nějakou místnost najít, požádejte stávající členy o pozvánku nebo si <a>Vytvořte novou místnost</a>.",
"Explore rooms": "Procházet místnosti",
"Jump to first unread room.": "Skočit na první nepřečtenou místnost.",
"Jump to first invite.": "Skočit na první pozvánku.",
"Jump to first unread room.": "Přejít na první nepřečtenou místnost.",
"Jump to first invite.": "Přejít na první pozvánku.",
"No identity server is configured: add one in server settings to reset your password.": "Žádný server identit není nakonfigurován: přidejte si ho v nastavení, abyste mohli obnovit heslo.",
"This account has been deactivated.": "Tento účet byl deaktivován.",
"Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).": "nový účet (%(newAccountId)s) je registrován, ale už jste přihlášeni pod jiným účtem (%(loggedInUserId)s).",
@ -1917,7 +1917,7 @@
"Compare unique emoji": "Porovnejte jedinečnou kombinaci emoji",
"Compare a unique set of emoji if you don't have a camera on either device": "Pokud na žádném zařízení nemáte kameru, porovnejte jedinečnou kombinaci emoji",
"Not Trusted": "Nedůvěryhodné",
"%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) se přihlásil do nové relace bez ověření:",
"%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) se přihlásil(a) do nové relace bez ověření:",
"Ask this user to verify their session, or manually verify it below.": "Požádejte tohoto uživatele, aby ověřil svou relaci, nebo jí níže můžete ověřit manuálně.",
"The session you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "Relace, kterou se snažíte ověřit, neumožňuje ověření QR kódem ani pomocí emoji, což je to, co %(brand)s podporuje. Zkuste použít jiného klienta.",
"Verify by scanning": "Ověřte naskenováním",
@ -2232,7 +2232,7 @@
"Collapse room list section": "Sbalit seznam místností",
"Select room from the room list": "Vybrat místnost v seznamu",
"Navigate up/down in the room list": "Posouvat se nahoru/dolů v seznamu místností",
"Jump to room search": "Filtrovat místnosti",
"Jump to room search": "Přejít na vyhledávání místností",
"Toggle video on/off": "Zapnout nebo vypnout video",
"Toggle microphone mute": "Ztlumit nebo zapnout mikrofon",
"Jump to start/end of the composer": "Skočit na konec/začátek textového pole",
@ -2322,7 +2322,7 @@
"Navigation": "Navigace",
"Use the + to make a new room or explore existing ones below": "Pomocí + vytvořte novou místnost nebo prozkoumejte stávající místnosti",
"Secure Backup": "Zabezpečená záloha",
"Jump to oldest unread message": "Jít na nejstarší nepřečtenou zprávu",
"Jump to oldest unread message": "Přejít na nejstarší nepřečtenou zprávu",
"Upload a file": "Nahrát soubor",
"You've reached the maximum number of simultaneous calls.": "Dosáhli jste maximálního počtu souběžných hovorů.",
"Too Many Calls": "Přiliš mnoho hovorů",
@ -2633,7 +2633,7 @@
"Move right": "Posunout doprava",
"Move left": "Posunout doleva",
"Go to Home View": "Přejít na domovské zobrazení",
"Dismiss read marker and jump to bottom": "Zavřít značku přečtených zpráv a skočit dolů",
"Dismiss read marker and jump to bottom": "Zavřít značku přečtených zpráv a přejít dolů",
"Previous/next room or DM": "Předchozí/další místnost nebo přímá zpráva",
"Previous/next unread room or DM": "Předchozí/další nepřečtená místnost nebo přímá zpráva",
"Not encrypted": "Není šifrováno",
@ -3104,7 +3104,7 @@
"Open space for anyone, best for communities": "Otevřený prostor pro kohokoli, nejlepší pro komunity",
"Public": "Veřejný",
"Create a space": "Vytvořit prostor",
"Jump to the bottom of the timeline when you send a message": "Při odesílání zprávy přeskočit na konec časové osy",
"Jump to the bottom of the timeline when you send a message": "Po odeslání zprávy přejít na konec časové osy",
"Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Prototyp prostorů. Nejsou kompatibilní se skupinami, skupinami v2 a vlastními štítky. Pro některé funkce je vyžadován kompatibilní domovský server.",
"This homeserver has been blocked by its administrator.": "Tento domovský server byl zablokován jeho správcem.",
"You're already in a call with this person.": "S touto osobou již telefonujete.",
@ -3435,7 +3435,7 @@
"Connected": "Připojeno",
"& %(count)s more|other": "a %(count)s dalších",
"Only invited people can join.": "Připojit se mohou pouze pozvané osoby.",
"Private (invite only)": "Soukromé (pouze pro pozvané)",
"Private (invite only)": "Soukromý (pouze pro pozvané)",
"This upgrade will allow members of selected spaces access to this room without an invite.": "Tato změna umožní členům vybraných prostorů přístup do této místnosti bez pozvánky.",
"There was an error loading your notification settings.": "Došlo k chybě při načítání nastavení oznámení.",
"Global": "Globální",
@ -3457,7 +3457,7 @@
"Unable to copy a link to the room to the clipboard.": "Nelze zkopírovat odkaz na místnost do schránky.",
"Unable to copy room link": "Nelze zkopírovat odkaz na místnost",
"This call has failed": "Toto volání se nezdařilo",
"Anyone can find and join.": "Kdokoliv může najít a připojit se.",
"Anyone can find and join.": "Kdokoliv může místnost najít a připojit se do ní.",
"Room visibility": "Viditelnost místnosti",
"Visible to space members": "Viditelné pro členy prostoru",
"Public room": "Veřejná místnost",
@ -3474,12 +3474,12 @@
"Call back": "Zavolat zpět",
"You missed this call": "Zmeškali jste tento hovor",
"The voice message failed to upload.": "Hlasovou zprávu se nepodařilo nahrát.",
"Copy Room Link": "Kopírovat odkaz na místnost",
"Copy Room Link": "Kopírovat odkaz",
"Show %(count)s other previews|one": "Zobrazit %(count)s další náhled",
"Show %(count)s other previews|other": "Zobrazit %(count)s dalších náhledů",
"Access": "Přístup",
"People with supported clients will be able to join the room without having a registered account.": "Lidé s podporovanými klienty se budou moci do místnosti připojit, aniž by měli registrovaný účet.",
"Decide who can join %(roomName)s.": "Rozhodněte, kdo se může připojit k %(roomName)s.",
"Decide who can join %(roomName)s.": "Rozhodněte, kdo se může připojit k místnosti %(roomName)s.",
"Space members": "Členové prostoru",
"Anyone in %(spaceName)s can find and join. You can select other spaces too.": "Každý, kdo se nachází v prostoru %(spaceName)s, ho může najít a připojit se k němu. Můžete vybrat i jiné prostory.",
"Anyone in a space can find and join. You can select multiple spaces.": "Každý, kdo se nachází v prostoru, ho může najít a připojit se k němu. Můžete vybrat více prostorů.",
@ -3502,5 +3502,67 @@
"They didn't pick up": "Nezvedli to",
"Call again": "Volat znova",
"They declined this call": "Odmítli tento hovor",
"You declined this call": "Odmítli jste tento hovor"
"You declined this call": "Odmítli jste tento hovor",
"Share content": "Sdílet obsah",
"Application window": "Okno aplikace",
"Share entire screen": "Sdílet celou obrazovku",
"You can now share your screen by pressing the \"screen share\" button during a call. You can even do this in audio calls if both sides support it!": "Nyní můžete sdílet obrazovku stisknutím tlačítka \"sdílení obrazovky\" během hovoru. Můžete tak učinit i při zvukových hovorech, pokud to obě strany podporují!",
"Screen sharing is here!": "Sdílení obrazovky je tu!",
"Your camera is still enabled": "Vaše kamera je stále zapnutá",
"Your camera is turned off": "Vaše kamera je vypnutá",
"%(sharerName)s is presenting": "%(sharerName)s prezentuje",
"You are presenting": "Prezentujete",
"Anyone will be able to find and join this room.": "Kdokoliv může najít tuto místnost a připojit se k ní.",
"Add existing space": "Přidat stávající prostor",
"Add space": "Přidat prostor",
"Give feedback.": "Poskytněte zpětnou vazbu.",
"Thank you for trying Spaces. Your feedback will help inform the next versions.": "Děkujeme, že jste vyzkoušeli Prostory. Vaše zpětná vazba pomůže při tvorbě dalších verzí.",
"Spaces feedback": "Zpětná vazba prostorů",
"Spaces are a new feature.": "Prostory jsou novou funkcí.",
"We're working on this, but just want to let you know.": "Pracujeme na tom, ale jen vás chceme informovat.",
"Search for rooms or spaces": "Hledat místnosti nebo prostory",
"Are you sure you want to leave <spaceName/>?": "Jste si jisti, že chcete opustit <spaceName/>?",
"Leave %(spaceName)s": "Opustit %(spaceName)s",
"You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "Jste jediným správcem některých místností nebo prostorů, které chcete opustit. Jejich opuštěním zůstanou bez správců.",
"You're the only admin of this space. Leaving it will mean no one has control over it.": "Jste jediným správcem tohoto prostoru. Jeho opuštění bude znamenat, že nad ním nebude mít nikdo kontrolu.",
"You won't be able to rejoin unless you are re-invited.": "Pokud nebudete znovu pozváni, nebudete se moci připojit.",
"Search %(spaceName)s": "Hledat %(spaceName)s",
"Leave specific rooms and spaces": "Opustit konkrétní místnosti a prostory",
"Don't leave any": "Neopouštět žádný",
"Leave all rooms and spaces": "Opustit všechny místnosti a prostory",
"Want to add an existing space instead?": "Chcete místo toho přidat stávající prostor?",
"Private space (invite only)": "Soukromý prostor (pouze pro pozvané)",
"Space visibility": "Viditelnost prostoru",
"Add a space to a space you manage.": "Přidat prostor do prostoru, který spravujete.",
"Only people invited will be able to find and join this space.": "Tento prostor budou moci najít a připojit se k němu pouze pozvaní lidé.",
"Anyone will be able to find and join this space, not just members of <SpaceName/>.": "Kdokoliv bude moci najít a připojit se k tomuto prostoru, nejen členové <SpaceName/>.",
"Anyone in <SpaceName/> will be able to find and join.": "Kdokoli v <SpaceName/> ho bude moci najít a připojit se.",
"Adding spaces has moved.": "Přidávání prostorů bylo přesunuto.",
"Search for rooms": "Hledat místnosti",
"Search for spaces": "Hledat prostory",
"Create a new space": "Vytvořit nový prostor",
"Want to add a new space instead?": "Chcete místo toho přidat nový prostor?",
"Decrypting": "Dešifrování",
"Show all rooms": "Zobrazit všechny místnosti",
"All rooms you're in will appear in Home.": "Všechny místnosti, ve kterých se nacházíte, se zobrazí na domovské obrazovce.",
"Send pseudonymous analytics data": "Odeslat pseudonymní analytická data",
"Missed call": "Zmeškaný hovor",
"Call declined": "Hovor odmítnut",
"Surround selected text when typing special characters": "Ohraničit označený text při psaní speciálních znaků",
"Stop recording": "Zastavit nahrávání",
"Send voice message": "Odeslat hlasovou zprávu",
"Mute the microphone": "Ztlumit mikrofon",
"Unmute the microphone": "Zrušit ztlumení mikrofonu",
"Dialpad": "Číselník",
"More": "Více",
"Show sidebar": "Zobrazit postranní panel",
"Hide sidebar": "Skrýt postranní panel",
"Start sharing your screen": "Začít sdílet obrazovku",
"Stop sharing your screen": "Přestat sdílet obrazovku",
"Stop the camera": "Vypnout kameru",
"Start the camera": "Zapnout kameru",
"%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.|other": "%(oneUser)s%(count)s krát změnil(a) <a>připnuté zprávy</a> místnosti.",
"%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.|other": "%(severalUsers)s%(count)s krát změnili <a>připnuté zprávy</a> místnosti.",
"Olm version:": "Verze Olm:",
"Don't send read receipts": "Neposílat potvrzení o přečtení"
}

View file

@ -3510,5 +3510,14 @@
"An unknown error occurred": "Ein unbekannter Fehler ist aufgetreten",
"Message bubbles": "Nachrichtenblasen",
"New layout switcher (with message bubbles)": "Layout ändern erlauben (mit Nachrichtenblasen)",
"New in the Spaces beta": "Neues in der Spaces Beta"
"New in the Spaces beta": "Neues in der Spaces Beta",
"To help space members find and join a private room, go to that room's Security & Privacy settings.": "Um Mitgliedern beim Finden privater Räume zu helfen, öffne die Sicherheitseinstellungen des Raumes.",
"Help space members find private rooms": "Hilf Mitgliedern, private Räume zu finden",
"More": "Mehr",
"Show sidebar": "Seitenleiste anzeigen",
"Hide sidebar": "Seitenleiste verbergen",
"Start sharing your screen": "Bildschirmfreigabe starten",
"Stop sharing your screen": "Bildschirmfreigabe beenden",
"Don't send read receipts": "Sende keine Lesebestätigungen",
"Send pseudonymous analytics data": "Sende pseudonymisierte Nutzungsdaten"
}

View file

@ -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,9 +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 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",
@ -811,9 +818,11 @@
"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)",
"Don't send read receipts": "Don't send read receipts",
"Font size": "Font size",
"Use custom size": "Use custom size",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
@ -839,6 +848,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",
@ -867,6 +877,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",
@ -903,19 +915,22 @@
"%(sharerName)s is presenting": "%(sharerName)s is presenting",
"Your camera is turned off": "Your camera is turned off",
"Your camera is still enabled": "Your camera is still enabled",
"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",
"Video Call": "Video Call",
"Voice Call": "Voice Call",
"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.",
@ -1000,7 +1015,9 @@
"Your server isn't responding to some <a>requests</a>.": "Your server isn't responding to some <a>requests</a>.",
"Decline (%(counter)s)": "Decline (%(counter)s)",
"Accept <policyLink /> to continue:": "Accept <policyLink /> to continue:",
"Delete avatar": "Delete avatar",
"Delete": "Delete",
"Upload avatar": "Upload avatar",
"Upload": "Upload",
"Name": "Name",
"Description": "Description",
@ -1012,12 +1029,14 @@
"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.",
"What kind of Space do you want to create?": "What kind of Space do you want to create?",
"You can change this later.": "You can change this later.",
"Public": "Public",
"Open space for anyone, best for communities": "Open space for anyone, best for communities",
"Private": "Private",
"Invite only, best for yourself or teams": "Invite only, best for yourself or teams",
"You can change this later": "You can change this later",
"You can also create a Space from a <a>community</a>.": "You can also create a Space from a <a>community</a>.",
"To join an existing space you'll need an invite.": "To join an existing space you'll need an invite.",
"Go back": "Go back",
"Your public space": "Your public space",
"Your private space": "Your private space",
@ -1025,8 +1044,10 @@
"You can change these anytime.": "You can change these anytime.",
"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",
@ -1056,17 +1077,11 @@
"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",
"Add space": "Add space",
"Members": "Members",
"Manage & explore rooms": "Manage & explore rooms",
"Explore rooms": "Explore rooms",
"Space options": "Space options",
"Jump to first unread room.": "Jump to first unread room.",
"Jump to first invite.": "Jump to first invite.",
"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 />.",
@ -1132,6 +1147,10 @@
"Connecting to integration manager...": "Connecting to integration manager...",
"Cannot connect to integration manager": "Cannot connect to integration manager",
"The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.",
"Message layout": "Message layout",
"IRC": "IRC",
"Modern": "Modern",
"Message bubbles": "Message bubbles",
"Messages containing keywords": "Messages containing keywords",
"Error saving notification preferences": "Error saving notification preferences",
"An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.",
@ -1244,10 +1263,6 @@
"Custom theme URL": "Custom theme URL",
"Add theme": "Add theme",
"Theme": "Theme",
"Message layout": "Message layout",
"IRC": "IRC",
"Modern": "Modern",
"Message bubbles": "Message bubbles",
"Set the name of a font installed on your system & %(brand)s will attempt to use it.": "Set the name of a font installed on your system & %(brand)s will attempt to use it.",
"Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout",
"Customise your appearance": "Customise your appearance",
@ -1268,6 +1283,8 @@
"Deactivate Account": "Deactivate Account",
"Deactivate account": "Deactivate account",
"Discovery": "Discovery",
"%(brand)s version:": "%(brand)s version:",
"Olm version:": "Olm version:",
"Legal": "Legal",
"Credits": "Credits",
"For help with using %(brand)s, click <a>here</a>.": "For help with using %(brand)s, click <a>here</a>.",
@ -1281,13 +1298,11 @@
"FAQ": "FAQ",
"Keyboard Shortcuts": "Keyboard Shortcuts",
"Versions": "Versions",
"%(brand)s version:": "%(brand)s version:",
"olm version:": "olm version:",
"Copy": "Copy",
"Homeserver is": "Homeserver is",
"Identity server is": "Identity server is",
"Access Token": "Access Token",
"Your access token gives full access to your account. Do not share it with anyone.": "Your access token gives full access to your account. Do not share it with anyone.",
"Copy": "Copy",
"Clear cache and reload": "Clear cache and reload",
"Labs": "Labs",
"Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.": "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.",
@ -1324,12 +1339,18 @@
"If this isn't what you want, please use a different tool to ignore users.": "If this isn't what you want, please use a different tool to ignore users.",
"Room ID or address of ban list": "Room ID or address of ban list",
"Subscribe": "Subscribe",
"Open Space": "Open Space",
"Create Space": "Create Space",
"Start automatically after system login": "Start automatically after system login",
"Warn before quitting": "Warn before quitting",
"Always show the window menu bar": "Always show the window menu bar",
"Show tray icon and minimize window to it on close": "Show tray icon and minimize window to it on close",
"Preferences": "Preferences",
"Room list": "Room list",
"Communities": "Communities",
"Communities have been archived to make way for Spaces but you can convert your communities into Spaces below. Converting will ensure your conversations get the latest features.": "Communities have been archived to make way for Spaces but you can convert your communities into Spaces below. Converting will ensure your conversations get the latest features.",
"Show my Communities": "Show my Communities",
"If a community isn't shown you may not have permission to convert it.": "If a community isn't shown you may not have permission to convert it.",
"Keyboard shortcuts": "Keyboard shortcuts",
"To view all keyboard shortcuts, click here.": "To view all keyboard shortcuts, click here.",
"Displaying time": "Displaying time",
@ -1530,6 +1551,7 @@
"Send a reply…": "Send a reply…",
"Send an encrypted message…": "Send an encrypted message…",
"Send a message…": "Send a message…",
"Send voice message": "Send voice message",
"The conversation continues here.": "The conversation continues here.",
"This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.",
"You do not have permission to post to this room": "You do not have permission to post to this room",
@ -1584,16 +1606,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",
@ -1656,8 +1679,6 @@
"Activity": "Activity",
"A-Z": "A-Z",
"List options": "List options",
"Jump to first unread room.": "Jump to first unread room.",
"Jump to first invite.": "Jump to first invite.",
"Show %(count)s more|other": "Show %(count)s more",
"Show %(count)s more|one": "Show %(count)s more",
"Show less": "Show less",
@ -1671,6 +1692,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.",
@ -1698,14 +1720,11 @@
"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",
"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.",
@ -1764,13 +1783,13 @@
"The homeserver the user youre verifying is connected to": "The homeserver the user youre 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",
@ -1866,19 +1885,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",
@ -1889,7 +1904,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.",
@ -1901,12 +1916,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",
@ -2083,6 +2097,8 @@
"%(severalUsers)schanged the server ACLs %(count)s times|one": "%(severalUsers)schanged the server ACLs",
"%(oneUser)schanged the server ACLs %(count)s times|other": "%(oneUser)schanged the server ACLs %(count)s times",
"%(oneUser)schanged the server ACLs %(count)s times|one": "%(oneUser)schanged the server ACLs",
"%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.|other": "%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.",
"%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.|other": "%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.",
"Power level": "Power level",
"Custom level": "Custom level",
"QR Code": "QR Code",
@ -2216,13 +2232,24 @@
"Visible to space members": "Visible to space members",
"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",
"This community has been upgraded into a Space": "This community has been upgraded into a Space",
"To view Spaces, hide communities in <a>Preferences</a>": "To view Spaces, hide communities in <a>Preferences</a>",
"Space created": "Space created",
"<SpaceName/> has been made and everyone who was a part of the community has been invited to it.": "<SpaceName/> has been made and everyone who was a part of the community has been invited to it.",
"To create a Space from another community, just pick the community in Preferences.": "To create a Space from another community, just pick the community in Preferences.",
"Failed to migrate community": "Failed to migrate community",
"Create Space from community": "Create Space from community",
"A link to the Space will be put in your community description.": "A link to the Space will be put in your community description.",
"All rooms will be added and all community members will be invited.": "All rooms will be added and all community members will be invited.",
"Flair won't be available in Spaces for the foreseeable future.": "Flair won't be available in Spaces for the foreseeable future.",
"This description will be shown to people when they view your space": "This description will be shown to people when they view your space",
"Space visibility": "Space visibility",
"Private space (invite only)": "Private space (invite only)",
"Public space": "Public space",
"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",
@ -2385,6 +2412,7 @@
"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",
@ -2593,6 +2621,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",
@ -2671,7 +2701,6 @@
"You must join the room to see its files": "You must join the room to see its files",
"No files visible in this room": "No files visible in this room",
"Attach files from chat or just drag and drop them anywhere in a room.": "Attach files from chat or just drag and drop them anywhere in a room.",
"Communities": "Communities",
"Create community": "Create community",
"<h1>HTML for your community's page</h1>\n<p>\n Use the long description to introduce new members to the community, or distribute\n some important <a href=\"foo\">links</a>\n</p>\n<p>\n You can even add images with Matrix URLs <img src=\"mxc://url\" />\n</p>\n": "<h1>HTML for your community's page</h1>\n<p>\n Use the long description to introduce new members to the community, or distribute\n some important <a href=\"foo\">links</a>\n</p>\n<p>\n You can even add images with Matrix URLs <img src=\"mxc://url\" />\n</p>\n",
"Add rooms to the community summary": "Add rooms to the community summary",
@ -2699,6 +2728,11 @@
"Community Settings": "Community Settings",
"Want more than a community? <a>Get your own server</a>": "Want more than a community? <a>Get your own server</a>",
"Changes made to your community <bold1>name</bold1> and <bold2>avatar</bold2> might not be seen by other users for up to 30 minutes.": "Changes made to your community <bold1>name</bold1> and <bold2>avatar</bold2> might not be seen by other users for up to 30 minutes.",
"You can create a Space from this community <a>here</a>.": "You can create a Space from this community <a>here</a>.",
"Ask the <a>admins</a> of this community to make it into a Space and keep a look out for the invite.": "Ask the <a>admins</a> of this community to make it into a Space and keep a look out for the invite.",
"Communities can now be made into Spaces": "Communities can now be made into Spaces",
"Spaces are a new way to make a community, with new features coming.": "Spaces are a new way to make a community, with new features coming.",
"Communities won't receive further updates.": "Communities won't receive further updates.",
"These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.": "These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.",
"Featured Rooms:": "Featured Rooms:",
"Featured Users:": "Featured Users:",
@ -2712,7 +2746,6 @@
"Everyone": "Everyone",
"Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!": "Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!",
"Long Description (HTML)": "Long Description (HTML)",
"Upload avatar": "Upload avatar",
"Community %(groupId)s not found": "Community %(groupId)s not found",
"This homeserver does not support communities": "This homeserver does not support communities",
"Failed to load %(groupId)s": "Failed to load %(groupId)s",
@ -2811,6 +2844,10 @@
"This room is suggested as a good one to join": "This room is suggested as a good one to join",
"Suggested": "Suggested",
"Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.",
"%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s rooms and %(numSpaces)s spaces",
"%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s room and %(numSpaces)s spaces",
"%(count)s rooms and 1 space|other": "%(count)s rooms and 1 space",
"%(count)s rooms and 1 space|one": "%(count)s room and 1 space",
"Select a room below first": "Select a room below first",
"Failed to remove some rooms. Try again later": "Failed to remove some rooms. Try again later",
"Removing...": "Removing...",
@ -2818,14 +2855,16 @@
"Mark as suggested": "Mark as suggested",
"No results found": "No results found",
"You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.",
"Results": "Results",
"Rooms and spaces": "Rooms and spaces",
"Space": "Space",
"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",
"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>",
"To join %(spaceName)s, turn on the <a>Spaces beta</a>": "To join %(spaceName)s, turn on the <a>Spaces beta</a>",
"To view %(spaceName)s, you need an invite": "To view %(spaceName)s, you need an invite",
"Created from <Community />": "Created from <Community />",
"Welcome to <name/>": "Welcome to <name/>",
"Random": "Random",
"Support": "Support",
@ -3099,7 +3138,6 @@
"Page Down": "Page Down",
"Esc": "Esc",
"Enter": "Enter",
"Space": "Space",
"End": "End",
"[number]": "[number]"
}

View file

@ -666,5 +666,15 @@
"Add some details to help people recognise it.": "Add some details to help people recognize it.",
"Unrecognised room address:": "Unrecognized room address:",
"A private space to organise your rooms": "A private space to organize your rooms",
"Message search initialisation failed": "Message search initialization failed"
"Message search initialisation failed": "Message search initialization failed",
"Permission is granted to use the webcam": "Permission is granted to use the webcam",
"A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly",
"Call failed because webcam or microphone could not be accessed. Check that:": "Call failed because webcam or microphone could not be accessed. Check that:",
"Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.",
"Unable to access microphone": "Unable to access microphone",
"The call was answered on another device.": "The call was answered on another device.",
"Answered Elsewhere": "Answered Elsewhere",
"The call could not be established": "The call could not be established",
"The user you called is busy.": "The user you called is busy.",
"User Busy": "User Busy"
}

View file

@ -561,7 +561,7 @@
"Demote": "Quitar permisos",
"Unignore": "Dejar de ignorar",
"Ignore": "Ignorar",
"Jump to read receipt": "Saltar a recibo leído",
"Jump to read receipt": "Saltar al último mensaje sin leer",
"Mention": "Mencionar",
"Invite": "Invitar",
"Share Link to User": "Compartir enlace al usuario",
@ -708,7 +708,7 @@
"collapse": "colapsar",
"expand": "expandir",
"Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "No se pudo cargar el evento al que se respondió, bien porque no existe o no tiene permiso para verlo.",
"<a>In reply to</a> <pill>": "<a>En respuesta a </a> <pill>",
"<a>In reply to</a> <pill>": "<a>Respondiendo a </a> <pill>",
"And %(count)s more...|other": "Y %(count)s más…",
"ex. @bob:example.com": "ej. @bob:ejemplo.com",
"Add User": "Agregar Usuario",
@ -718,7 +718,7 @@
"You have entered an invalid address.": "No ha introducido una dirección correcta.",
"Try using one of the following valid address types: %(validTypesList)s.": "Intente usar uno de los tipos de direcciones válidos: %(validTypesList)s.",
"Confirm Removal": "Confirmar eliminación",
"Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "¿Seguro que quieres eliminar este evento? Ten en cuenta que, si borras un cambio de nombre o tema de sala, podrías deshacer el cambio.",
"Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "¿Seguro que quieres eliminar este evento? Ten en cuenta que, si borras un cambio de nombre o asunto de sala, podrías deshacer el cambio.",
"Community IDs cannot be empty.": "Las IDs de comunidad no pueden estar vacías.",
"Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Las IDs de comunidad solo pueden contener caracteres de la «a» a la «z» excluyendo la «ñ», dígitos o «=_-./»",
"Something went wrong whilst creating your community": "Algo fue mal mientras se creaba la comunidad",
@ -870,10 +870,10 @@
"Developer options": "Opciones de desarrollador",
"Room version": "Versión de la sala",
"Room information": "Información de la sala",
"Room Topic": "Tema de la sala",
"Room Topic": "Asunto de la sala",
"Theme": "Tema",
"Voice & Video": "Voz y vídeo",
"Gets or sets the room topic": "Obtiene o establece el tema de la sala",
"Gets or sets the room topic": "Ver o cambiar el asunto de la sala",
"This room has no topic.": "Esta sala no tiene tema.",
"Sets the room name": "Establece el nombre de la sala",
"Phone numbers": "Números de teléfono",
@ -1226,7 +1226,7 @@
"Do not use an identity server": "No usar un servidor de identidad",
"Enter a new identity server": "Introducir un servidor de identidad nuevo",
"Change": "Cambiar",
"Manage integrations": "Gestionar integraciones",
"Manage integrations": "Gestor de integraciones",
"Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Los administradores de integración reciben datos de configuración, y pueden modificar widgets, enviar invitaciones de sala, y establecer niveles de poder en tu nombre.",
"Something went wrong trying to invite the users.": "Algo salió mal al intentar invitar a los usuarios.",
"We couldn't invite those users. Please check the users you want to invite and try again.": "No se pudo invitar a esos usuarios. Por favor, revisa los usuarios que quieres invitar e inténtalo de nuevo.",
@ -1351,10 +1351,10 @@
"Enable message search in encrypted rooms": "Activar la búsqueda de mensajes en salas cifradas",
"How fast should messages be downloaded.": "Con qué rapidez deben ser descargados los mensajes.",
"Verify this session by completing one of the following:": "Verifica esta sesión de una de las siguientes formas:",
"Scan this unique code": "Escanea este código único",
"Scan this unique code": "Escanea este código",
"or": "o",
"Compare unique emoji": "Comparar iconos",
"Compare a unique set of emoji if you don't have a camera on either device": "Comparar un conjunto de iconos si no tienes cámara en ninguno de los dispositivos",
"Compare unique emoji": "Compara los emojis",
"Compare a unique set of emoji if you don't have a camera on either device": "Compara un conjunto de emojis si no tienes cámara en ninguno de los dispositivos",
"Start": "Empezar",
"Waiting for %(displayName)s to verify…": "Esperando la verificación de %(displayName)s…",
"Review": "Revisar",
@ -1411,7 +1411,7 @@
"Backup key stored: ": "Clave de seguridad almacenada: ",
"Your keys are <b>not being backed up from this session</b>.": "<b>No se está haciendo una copia de seguridad de tus claves en esta sesión</b>.",
"Clear notifications": "Limpiar notificaciones",
"Enable desktop notifications for this session": "Activar notificaciones de escritorio para esta sesión",
"Enable desktop notifications for this session": "Activa las notificaciones de escritorio para esta sesión",
"Enable audible notifications for this session": "Activar notificaciones sonoras para esta sesión",
"Checking server": "Comprobando servidor",
"Change identity server": "Cambiar servidor de identidad",
@ -1457,12 +1457,12 @@
"%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s cambió la dirección principal y las alternativas de esta sala.",
"%(senderName)s changed the addresses for this room.": "%(senderName)s cambió las direcciones de esta sala.",
"You signed in to a new session without verifying it:": "Iniciaste una nueva sesión sin verificarla:",
"Verify your other session using one of the options below.": "Verificar la otra sesión utilizando una de las siguientes opciones.",
"Verify your other session using one of the options below.": "Verifica la otra sesión utilizando una de las siguientes opciones.",
"%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) inició una nueva sesión sin verificarla:",
"Ask this user to verify their session, or manually verify it below.": "Pídele al usuario que verifique su sesión, o verifícala manualmente a continuación.",
"Not Trusted": "No es de confianza",
"Manually Verify by Text": "Verificar manualmente mediante texto",
"Interactively verify by Emoji": "Verifica interactivamente con unEmoji",
"Interactively verify by Emoji": "Verificar interactivamente con emojis",
"Done": "Listo",
"Support adding custom themes": "Soporta la adición de temas personalizados",
"Show info about bridges in room settings": "Mostrar información sobre puentes en la configuración de salas",
@ -1472,7 +1472,7 @@
"Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "Permitir el servidor de respaldo de asistencia de llamadas turn.matrix.org cuando tu servidor base no lo ofrezca (tu dirección IP se compartiría durante una llamada)",
"Send read receipts for messages (requires compatible homeserver to disable)": "Enviar recibos de lectura de mensajes (requiere un servidor local compatible para desactivarlo)",
"Manually verify all remote sessions": "Verificar manualmente todas las sesiones remotas",
"Confirm the emoji below are displayed on both sessions, in the same order:": "Confirma que los iconos de abajo se muestran en el mismo orden en ambas sesiones:",
"Confirm the emoji below are displayed on both sessions, in the same order:": "Confirma que los emojis de abajo son los mismos y tienen el mismo orden en los dos sitios:",
"Verify this session by confirming the following number appears on its screen.": "Verifica esta sesión confirmando que el siguiente número aparece en su pantalla.",
"Waiting for your other session, %(deviceName)s (%(deviceId)s), to verify…": "Esperando a que la otra sesión lo verifique también %(deviceName)s (%(deviceId)s)…",
"Cancelling…": "Anulando…",
@ -1630,7 +1630,7 @@
"Reminder: Your browser is unsupported, so your experience may be unpredictable.": "Recordatorio: Su navegador no es compatible, por lo que su experiencia puede ser impredecible.",
"GitHub issue": "Incidencia de GitHub",
"Notes": "Notas",
"If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Si hay algún contexto adicional que ayude a analizar el tema, como por ejemplo lo que estaba haciendo en ese momento, nombre (ID) de sala, nombre (ID)de usuario, etc., por favor incluya esas cosas aquí.",
"If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Si hay algún contexto adicional que ayude a analizar el problema, como por ejemplo lo que estaba haciendo en ese momento, nombre (ID) de sala, nombre (ID) de usuario, etc., por favor incluye esas cosas aquí.",
"Removing…": "Quitando…",
"Destroy cross-signing keys?": "¿Destruir las claves de firma cruzada?",
"Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.": "La eliminación de claves de firma cruzada es definitiva. Cualquiera con el que lo hayas verificado verá alertas de seguridad. Es casi seguro que no quieres hacer esto, a menos que hayas perdido todos los dispositivos puedas usar hacer una firma cruzada.",
@ -1696,7 +1696,7 @@
"Do you want to chat with %(user)s?": "¿Quieres chatear con %(user)s?",
"Do you want to join %(roomName)s?": "¿Quieres unirte a %(roomName)s?",
"<userName/> invited you": "<userName/> te ha invitado",
"You're previewing %(roomName)s. Want to join it?": "Estás previsualizando %(roomName)s. ¿Quieres unirte?",
"You're previewing %(roomName)s. Want to join it?": "Esto es una vista previa de %(roomName)s. ¿Te quieres unir?",
"%(roomName)s can't be previewed. Do you want to join it?": "La sala %(roomName)s no permite previsualización. ¿Quieres unirte?",
"This room doesn't exist. Are you sure you're at the right place?": "Esta sala no existe. ¿Estás seguro de estar en el lugar correcto?",
"Try again later, or ask a room admin to check if you have access.": "Inténtalo más tarde, o pide que un administrador de la sala compruebe si tienes acceso.",
@ -1722,7 +1722,7 @@
"Deactivate user": "Desactivar usuario",
"Failed to deactivate user": "Error en desactivar usuario",
"Remove recent messages": "Eliminar mensajes recientes",
"Send a reply…": "Enviar una respuesta …",
"Send a reply…": "Enviar una respuesta…",
"Send a message…": "Enviar un mensaje…",
"Bold": "Negrita",
"Italics": "Cursiva",
@ -1994,7 +1994,7 @@
"Liberate your communication": "Libera tu comunicación",
"Send a Direct Message": "Envía un mensaje directo",
"Explore Public Rooms": "Explora las salas públicas",
"Create a Group Chat": "Crea un chat grupal",
"Create a Group Chat": "Crea una conversación grupal",
"Explore": "Explorar",
"Filter": "Filtrar",
"Filter rooms…": "Filtrar salas…",
@ -2014,7 +2014,7 @@
"Guest": "Invitado",
"Your profile": "Su perfil",
"Could not load user profile": "No se pudo cargar el perfil de usuario",
"Verify this login": "Verificar este inicio de sesión",
"Verify this login": "Verifica este inicio de sesión",
"Session verified": "Sesión verificada",
"Changing your password will reset any end-to-end encryption keys on all of your sessions, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another session before resetting your password.": "Cambiar la contraseña restablecerá cualquier clave de cifrado de extremo a extremo en todas sus sesiones, haciendo ilegible el historial de chat cifrado. Configura la copia de seguridad de las claves o exporta las claves de la sala de otra sesión antes de restablecer la contraseña.",
"Your Matrix account on %(serverName)s": "Su cuenta de Matrix en %(serverName)s",
@ -2096,7 +2096,7 @@
"%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s",
"%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s",
"Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Tu nueva sesión ha sido verificada. Ahora tiene acceso a los mensajes cifrados y otros usuarios la verán como verificada.",
"Your new session is now verified. Other users will see it as trusted.": "Tu sesión se encuentra ahora verificada. Otros usuarios la verán como confiable.",
"Your new session is now verified. Other users will see it as trusted.": "Has verificado esta sesión. El resto la verá como «de confianza».",
"This session is encrypting history using the new recovery method.": "Esta sesión está cifrando el historial usando el nuevo método de recuperación.",
"Change notification settings": "Cambiar los ajustes de notificaciones",
"Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Prototipo de comunidades v2. Requiere un servidor compatible. Altamente experimental - usar con precuación.",
@ -2196,7 +2196,7 @@
"About": "Acerca de",
"%(count)s people|other": "%(count)s personas",
"%(count)s people|one": "%(count)s persona",
"Show files": "Mostrar archivos",
"Show files": "Ver archivos",
"Room settings": "Configuración de la sala",
"You've successfully verified your device!": "¡Ha verificado correctamente su dispositivo!",
"Take a picture": "Toma una foto",
@ -2421,7 +2421,7 @@
"Navigate recent messages to edit": "Navegar entre mensajes recientes para editar",
"Jump to start/end of the composer": "Saltar al inicio o final del editor",
"Navigate composer history": "Navegar por el historial del editor",
"Cancel replying to a message": "Cancelar la respuesta a un mensaje",
"Cancel replying to a message": "Cancelar responder al mensaje",
"Toggle microphone mute": "Alternar silencio del micrófono",
"Toggle video on/off": "Activar/desactivar video",
"Scroll up/down in the timeline": "Desplazarse hacia arriba o hacia abajo en la línea de tiempo",
@ -2769,7 +2769,7 @@
"Add a photo, so people can easily spot your room.": "Añade una imagen para que la gente reconozca la sala fácilmente.",
"%(displayName)s created this room.": "%(displayName)s creó esta sala.",
"You created this room.": "Creaste esta sala.",
"<a>Add a topic</a> to help people know what it is about.": "<a>Añade un tema</a> para que la gente sepa de qué va esta sala.",
"<a>Add a topic</a> to help people know what it is about.": "<a>Escribe un asunto</a> para que la gente sepa de qué va esta sala.",
"Topic: %(topic)s ": "Tema: %(topic)s ",
"Topic: %(topic)s (<a>edit</a>)": "Tema: %(topic)s (<a>cambiar</a>)",
"Remove messages sent by others": "Eliminar mensajes mandados por otros",
@ -2801,7 +2801,7 @@
"Safeguard against losing access to encrypted messages & data": "Evita perder acceso a datos y mensajes cifrados",
"Use app": "Usar la aplicación",
"Use app for a better experience": "Usa la aplicación para una experiencia mejor",
"Enable desktop notifications": "Activar notificaciones de escritorio",
"Enable desktop notifications": "Activa las notificaciones de escritorio",
"Don't miss a reply": "No te pierdas ninguna respuesta",
"Send messages as you in your active room": "Enviar mensajes en tu sala activa",
"See messages posted to your active room": "Ver los mensajes publicados en tu sala activa",
@ -2814,10 +2814,10 @@
"Change the avatar of your active room": "Cambiar la foto de tu sala actual",
"See when the avatar changes in this room": "Ver cuándo cambia la imagen de esta sala",
"Change the avatar of this room": "Cambiar la imagen de esta sala",
"See when the name changes in your active room": "Ver cuándo cambia el tema de tu sala actual",
"See when the name changes in your active room": "Ver cuándo cambia el asunto de tu sala actual",
"Change the name of your active room": "Cambiar el nombre de tu sala actual",
"See when the name changes in this room": "Ver cuándo cambia el tema de esta sala",
"Change the name of this room": "Cambiar el tema de esta sala",
"See when the name changes in this room": "Ver cuándo cambia el tema de esta asunto",
"Change the name of this room": "Cambiar el asunto de esta sala",
"Sint Maarten": "San Martín",
"Singapore": "Singapur",
"Sierra Leone": "Sierra Leona",
@ -2845,10 +2845,10 @@
"Mongolia": "Mongolia",
"Montenegro": "Montenegro",
"Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web en móviles es un experimento. Para una mejor experiencia y las últimas funcionalidades, usa nuestra aplicación nativa gratuita.",
"See when the topic changes in your active room": "Ver cuándo cambia el tema de tu sala actual",
"Change the topic of your active room": "Cambiar el tema de tu sala actual",
"See when the topic changes in this room": "Ver cuándo cambia el tema de esta sala",
"Change the topic of this room": "Cambiar el tema de esta sala",
"See when the topic changes in your active room": "Ver cuándo cambia el asunto de la sala en la que estés",
"Change the topic of your active room": "Cambiar el asunto de la sala en la que estés",
"See when the topic changes in this room": "Ver cuándo cambia el asunto de esta sala",
"Change the topic of this room": "Cambiar el asunto de esta sala",
"%(senderName)s declined the call.": "%(senderName)s ha rechazado la llamada.",
"(an error occurred)": "(ha ocurrido un error)",
"(their device couldn't start the camera / microphone)": "(su dispositivo no ha podido acceder a la cámara o micrófono)",
@ -3199,7 +3199,7 @@
"Add existing rooms": "Añadir salas existentes",
"%(count)s people you know have already joined|one": "%(count)s persona que ya conoces se ha unido",
"%(count)s people you know have already joined|other": "%(count)s personas que ya conoces se han unido",
"Accept on your other login…": "Acepta en tu otro inicio de sesión…",
"Accept on your other login…": "Acepta en otro sitio donde hayas iniciado sesión…",
"Stop & send recording": "Parar y enviar grabación",
"Record a voice message": "Grabar un mensaje de voz",
"Quick actions": "Acciones rápidas",
@ -3223,7 +3223,7 @@
"You can add more later too, including already existing ones.": "Puedes añadir más después, incluso si ya existen.",
"Please choose a strong password": "Por favor, elige una contraseña segura",
"Use another login": "Usar otro inicio de sesión",
"Verify your identity to access encrypted messages and prove your identity to others.": "Verifica tu identidad para acceder a mensajes cifrados y probar tu identidad a otros.",
"Verify your identity to access encrypted messages and prove your identity to others.": "Verifica tu identidad para leer tus mensajes cifrados y probar a las demás personas que realmente eres tú.",
"Without verifying, you wont have access to all your messages and may appear as untrusted to others.": "Si no verificas no tendrás acceso a todos tus mensajes y puede que aparezcas como no confiable para otros usuarios.",
"Invite messages are hidden by default. Click to show the message.": "Los mensajes de invitación no se muestran por defecto. Haz clic para mostrarlo.",
"You can select all or individual messages to retry or delete": "Puedes seleccionar uno o todos los mensajes para reintentar o eliminar",
@ -3364,7 +3364,7 @@
"Please pick a nature and describe what makes this message abusive.": "Por favor, escoge una categoría y explica por qué el mensaje es abusivo.",
"Any other reason. Please describe the problem.\nThis will be reported to the room moderators.": "Otro motivo. Por favor, describe el problema.\nSe avisará a los moderadores de la sala.",
"This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\n This will be reported to the administrators of %(homeserver)s.": "Esta sala está dedicada a un tema ilegal o contenido tóxico, o bien los moderadores no están tomando medidas frente a este tipo de contenido.\nSe avisará a los administradores de %(homeserver)s.",
"This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\nThis will be reported to the administrators of %(homeserver)s. The administrators will NOT be able to read the encrypted content of this room.": "Esta sala está dedicada a un tema ilegal o contenido tóxico, o bien los moderadores no están tomando medidas frente a este tipo de contenido.\nSe avisará a los administradores de %(homeserver)s, pero no podrán leer el contenido cifrado de la sala.",
"This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\nThis will be reported to the administrators of %(homeserver)s. The administrators will NOT be able to read the encrypted content of this room.": "Esta sala está dedicada a un tema ilegal o contenido tóxico, o los moderadores no están tomando medidas frente a este tipo de contenido.\nSe avisará a los administradores de %(homeserver)s, pero no podrán leer el contenido cifrado de la sala.",
"This user is spamming the room with ads, links to ads or to propaganda.\nThis will be reported to the room moderators.": "Esta persona está mandando publicidad no deseada o propaganda.\nSe avisará a los moderadores de la sala.",
"This user is displaying illegal behaviour, for instance by doxing people or threatening violence.\nThis will be reported to the room moderators who may escalate this to legal authorities.": "Esta persona está comportándose de manera posiblemente ilegal. Por ejemplo, amenazando con violencia física o con revelar datos personales.\nSe avisará a los moderadores de la sala, que podrían denunciar los hechos.",
"This user is displaying toxic behaviour, for instance by insulting other users or sharing adult-only content in a family-friendly room or otherwise violating the rules of this room.\nThis will be reported to the room moderators.": "Esta persona está teniendo un comportamiento tóxico. Por ejemplo, insultando al resto, compartiendo contenido explícito en una sala para todos los públicos, o incumpliendo las normas de la sala en general.\nSe avisará a los moderadores de la sala.",
@ -3390,7 +3390,7 @@
"anyone with the link can view and join": "cualquiera con el enlace puede verlo y unirse",
"Decide who can view and join %(spaceName)s.": "Decide quién puede ver y unirse a %(spaceName)s.",
"Visibility": "Visibilidad",
"Guests can join a space without having an account.": "Las personas sin cuenta podrían unirse al espacio sin invitación.",
"Guests can join a space without having an account.": "Dejar que las personas sin cuenta se unan al espacio.",
"This may be useful for public spaces.": "Esto puede ser útil para espacios públicos.",
"Enable guest access": "Permitir acceso a personas sin cuenta",
"Failed to update the history visibility of this space": "No se ha podido cambiar la visibilidad del historial de este espacio",
@ -3423,15 +3423,15 @@
"Some invites couldn't be sent": "No se han podido enviar algunas invitaciones",
"Integration manager": "Gestor de integración",
"Your %(brand)s doesn't allow you to use an integration manager to do this. Please contact an admin.": "%(brand)s no utilizar un \"gestor de integración\" para hacer esto. Por favor, contacta con un administrador.",
"Using this widget may share data <helpIcon /> with %(widgetDomain)s & your integration manager.": "Usar este widget puede resultar en que se compartan datos <helpIcon /> con %(widgetDomain)s y su administrador de integración.",
"Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Los administradores de integración reciben datos de configuración, y pueden modificar widgets, enviar invitaciones de sala, y establecer niveles de poder en tu nombre.",
"Use an integration manager to manage bots, widgets, and sticker packs.": "Utiliza un administrador de integración para gestionar los bots, los widgets y los paquetes de pegatinas.",
"Use an integration manager <b>(%(serverName)s)</b> to manage bots, widgets, and sticker packs.": "Usar un gestor de integraciones <b>(%(serverName)s)</b> para manejar los bots, widgets y paquetes de pegatinas.",
"Using this widget may share data <helpIcon /> with %(widgetDomain)s & your integration manager.": "Al usar este widget puede que se compartan datos <helpIcon /> con %(widgetDomain)s y tu gestor de integraciones.",
"Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Los gestores de integraciones reciben datos de configuración, y pueden modificar widgets, enviar invitaciones de sala, y establecer niveles de poder en tu nombre.",
"Use an integration manager to manage bots, widgets, and sticker packs.": "Usa un gestor de integraciones para bots, widgets y paquetes de pegatinas.",
"Use an integration manager <b>(%(serverName)s)</b> to manage bots, widgets, and sticker packs.": "Usar un gestor de integraciones <b>(%(serverName)s)</b> para gestionar bots, widgets y paquetes de pegatinas.",
"Identity server": "Servidor de identidad",
"Identity server (%(server)s)": "Servidor de identidad %(server)s",
"Could not connect to identity server": "No se ha podido conectar al servidor de identidad",
"Not a valid identity server (status code %(code)s)": "No es un servidor de identidad válido (código de estado %(code)s)",
"Identity server URL must be HTTPS": "La URL del servidor de identidad debe ser tipo HTTPS",
"Identity server URL must be HTTPS": "La URL del servidor de identidad debe ser HTTPS",
"Unable to copy a link to the room to the clipboard.": "No se ha podido copiar el enlace a la sala.",
"Unable to copy room link": "No se ha podido copiar el enlace a la sala",
"Unnamed audio": "Audio sin título",
@ -3477,5 +3477,113 @@
"Copy Room Link": "Copiar enlace a la sala",
"Displaying time": "Mostrando la hora",
"IRC": "IRC",
"Use Ctrl + F to search timeline": "Usa Control + F para buscar dentro de la conversación"
"Use Ctrl + F to search timeline": "Usa Control + F para buscar dentro de la conversación",
"<b>Please note upgrading will make a new version of the room</b>. All current messages will stay in this archived room.": "<b>Ten en cuenta que actualizar crea una nueva versión de la sala</b>. Todos los mensajes hasta ahora quedarán archivados aquí, en esta sala.",
"Automatically invite members from this room to the new one": "Invitar a la nueva sala automáticamente miembros de esta",
"These are likely ones other room admins are a part of.": "Otros administradores de la sala estarán dentro.",
"Other spaces or rooms you might not know": "Otros espacios o salas que puede que no conozcas",
"Decide which spaces can access this room. If a space is selected, its members can find and join <RoomName/>.": "Decide qué espacios pueden acceder a esta sala. Si seleccionas un espacio, sus miembros podrán encontrar y unirse a <RoomName/>.",
"You're removing all spaces. Access will default to invite only": "Al quitar todos los espacios, el acceso por defecto pasará a ser «solo por invitación»",
"Only people invited will be able to find and join this space.": "Solo las personas invitadas podrán encontrar y unirse a este espacio.",
"Anyone will be able to find and join this space, not just members of <SpaceName/>.": "Cualquiera podrá encontrar y unirse a este espacio, incluso si no forman parte de <SpaceName/>.",
"Anyone in <SpaceName/> will be able to find and join.": "Cualquiera que forme parte de <SpaceName/> podrá encontrar y unirse.",
"%(senderName)s unbanned %(targetName)s": "%(senderName)s ha quitado el veto a %(targetName)s",
"%(senderName)s banned %(targetName)s": "%(senderName)s ha vetado a %(targetName)s",
"%(senderName)s banned %(targetName)s: %(reason)s": "%(senderName)s ha vetado a %(targetName)s: %(reason)s",
"New layout switcher (with message bubbles)": "Nuevo menú lateral (con burbujas de mensajes)",
"This makes it easy for rooms to stay private to a space, while letting people in the space find and join them. All new rooms in a space will have this option available.": "Esto hace que sea fácil tener salas privadas solo para un espacio, permitiendo que cualquier persona que forme parte del espacio pueda unirse a ellas. Todas las nuevas salas dentro del espacio tendrán esta opción disponible.",
"User %(userId)s is already invited to the room": "ya se ha invitado a %(userId)s a unirse a la sala",
"Screen sharing is here!": "¡Ya puedes compartir tu pantalla!",
"People with supported clients will be able to join the room without having a registered account.": "Las personas con una aplicación compatible podrán unirse a la sala sin tener que registrar una cuenta.",
"Anyone in a space can find and join. You can select multiple spaces.": "Cualquiera en un espacio puede encontrar y unirse. Puedes seleccionar varios espacios.",
"Anyone in %(spaceName)s can find and join. You can select other spaces too.": "Cualquiera en %(spaceName)s puede encontrar y unirse. También puedes seleccionar otros espacios.",
"Spaces with access": "Espacios con acceso",
"Anyone in a space can find and join. <a>Edit which spaces can access here.</a>": "Cualquiera en un espacio puede encontrar y unirse. <a>Ajusta qué espacios pueden acceder desde aquí.",
"Currently, %(count)s spaces have access|other": "Ahora mismo, %(count)s espacios tienen acceso",
"& %(count)s more|other": "y %(count)s más",
"Upgrade required": "Actualización necesaria",
"Anyone can find and join.": "Cualquiera puede encontrar y unirse.",
"Only invited people can join.": "Solo las personas invitadas pueden unirse.",
"Private (invite only)": "Privado (solo por invitación)",
"This upgrade will allow members of selected spaces access to this room without an invite.": "Esta actualización permitirá a los miembros de los espacios que elijas acceder a esta sala sin que tengas que invitarles.",
"Message bubbles": "Burbujas de mensaje",
"Show all rooms": "Ver todas las salas",
"Give feedback.": "Danos tu opinión.",
"Thank you for trying Spaces. Your feedback will help inform the next versions.": "Gracias por probar los espacios. Tu opinión nos ayudará a tomar decisiones sobre las próximas versiones.",
"Spaces feedback": "Danos tu opinión sobre los espacios",
"Spaces are a new feature.": "Los espacios son una funcionalidad nueva.",
"Your camera is still enabled": "Tu cámara todavía está encendida",
"Your camera is turned off": "Tu cámara está apagada",
"%(sharerName)s is presenting": "%(sharerName)s está presentando",
"You are presenting": "Estás presentando",
"All rooms you're in will appear in Home.": "En la página de inicio aparecerán todas las salas a las que te hayas unido.",
"To help space members find and join a private room, go to that room's Security & Privacy settings.": "Para ayudar a los miembros de tus espacios a encontrar y unirse a salas privadas, ve a los ajustes seguridad y privacidad de la sala en cuestión.",
"Help space members find private rooms": "Ayuda a los miembros de tus espacios a encontrar salas privadas",
"Help people in spaces to find and join private rooms": "Ayuda a la gente en tus espacios a encontrar y unirse a salas privadas",
"New in the Spaces beta": "Novedades en la beta de los espacios",
"We're working on this, but just want to let you know.": "Todavía estamos trabajando en esto, pero queríamos enseñártelo.",
"Search for rooms or spaces": "Buscar salas o espacios",
"Add space": "Añadir espacio",
"Spaces you know that contain this room": "Espacios que conoces que contienen esta sala",
"Search spaces": "Buscar espacios",
"Select spaces": "Elegir espacios",
"Are you sure you want to leave <spaceName/>?": "¿Seguro que quieres irte de <spaceName/>?",
"Leave %(spaceName)s": "Salir de %(spaceName)s",
"You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "Eres la única persona con permisos de administración en algunos de los espacios de los que quieres irte. Al salir de ellos, nadie podrá gestionarlos.",
"You're the only admin of this space. Leaving it will mean no one has control over it.": "Eres la única persona con permisos de administración en el espacio. Al salir, nadie podrá gestionarlo.",
"You won't be able to rejoin unless you are re-invited.": "No podrás volverte a unir hasta que te vuelvan a invitar.",
"Search %(spaceName)s": "Buscar en %(spaceName)s",
"Leave specific rooms and spaces": "Salir de algunas salas y espacios que yo elija",
"Don't leave any": "No salir de ningún sitio",
"Leave all rooms and spaces": "Salir de todas las salas y espacios",
"Want to add an existing space instead?": "¿Quieres añadir un espacio que ya exista?",
"Private space (invite only)": "Espacio privado (solo por invitación)",
"Space visibility": "Visibilidad del espacio",
"Add a space to a space you manage.": "Añade un espacio a dentro de otros espacio que gestiones.",
"Visible to space members": "Visible para los miembros del espacio",
"Public room": "Sala pública",
"Private room (invite only)": "Sala privada (solo por invitación)",
"Room visibility": "Visibilidad de la sala",
"Create a room": "Crear una sala",
"Only people invited will be able to find and join this room.": "Solo aquellas personas invitadas podrán encontrar y unirse a esta sala.",
"Anyone will be able to find and join this room.": "Todo el mundo podrá encontrar y unirse a esta sala.",
"Anyone will be able to find and join this room, not just members of <SpaceName/>.": "Cualquiera podrá encontrar y unirse a esta sala, incluso gente que no sea miembro de <SpaceName/>.",
"You can change this at any time from room settings.": "Puedes cambiar esto cuando quieras desde los ajustes de la sala.",
"Everyone in <SpaceName/> will be able to find and join this room.": "Todo el mundo en <SpaceName/> podrá encontrar y unirse a esta sala.",
"Adding spaces has moved.": "Hemos cambiado de sitio la creación de espacios.",
"Search for rooms": "Buscar salas",
"Search for spaces": "Buscar espacios",
"Create a new space": "Crear un nuevo espacio",
"Want to add a new space instead?": "¿Quieres añadir un espacio nuevo en su lugar?",
"Add existing space": "Añadir un espacio ya existente",
"Share content": "Compartir contenido",
"Application window": "Ventana concreta",
"Share entire screen": "Compartir toda la pantalla",
"Decrypting": "Descifrando",
"They didn't pick up": "No han cogido",
"Call again": "Volver a llamar",
"They declined this call": "Han rechazado la llamada",
"You declined this call": "Has rechazado la llamada",
"The voice message failed to upload.": "Ha fallado el envío del mensaje de voz.",
"You can now share your screen by pressing the \"screen share\" button during a call. You can even do this in audio calls if both sides support it!": "Ahora puedes compartir tu pantalla dándole al botón de «compartir pantalla» durante una llamada. ¡Hasta puedes hacerlo en una llamada de voz si ambas partes lo permiten!",
"Access": "Acceso",
"Decide who can join %(roomName)s.": "Decide quién puede unirse a %(roomName)s.",
"Space members": "Miembros del espacio",
"Missed call": "Llamada perdida",
"Call declined": "Llamada rechazada",
"Stop recording": "Dejar de grabar",
"Send voice message": "Enviar mensaje de voz",
"Olm version:": "Versión de Olm:",
"Mute the microphone": "Silenciar el micrófono",
"Unmute the microphone": "Activar el micrófono",
"Dialpad": "Teclado numérico",
"More": "Más",
"Show sidebar": "Ver menú lateral",
"Hide sidebar": "Ocultar menú lateral",
"Start sharing your screen": "Comparte tu pantalla",
"Stop sharing your screen": "Dejar de compartir la pantalla",
"Stop the camera": "Parar la cámara",
"Start the camera": "Iniciar la cámara",
"Surround selected text when typing special characters": "Rodear texto seleccionado al escribir caracteres especiales",
"Send pseudonymous analytics data": "Enviar datos estadísticos seudonimizados"
}

View file

@ -3521,5 +3521,63 @@
"The voice message failed to upload.": "Häälsõnumi üleslaadimine ei õnnestunud.",
"Everyone in <SpaceName/> will be able to find and join this room.": "Kõik <SpaceName/> kogukonna liikmed saavad seda jututuba leida ning võivad temaga liituda.",
"You can change this at any time from room settings.": "Sa saad seda alati jututoa seadistustest muuta.",
"Anyone will be able to find and join this room, not just members of <SpaceName/>.": "Mitte ainult <SpaceName/> kogukonna liikmed, vaid kõik saavad seda jututuba leida ja võivad temaga liituda."
"Anyone will be able to find and join this room, not just members of <SpaceName/>.": "Mitte ainult <SpaceName/> kogukonna liikmed, vaid kõik saavad seda jututuba leida ja võivad temaga liituda.",
"You declined this call": "Sina keeldusid kõnest",
"They declined this call": "Teine osapool keeldus kõnest",
"Call again": "Helista uuesti",
"They didn't pick up": "Teine osapool ei võtnud kõnet vastu",
"You are presenting": "Sina esitad",
"%(sharerName)s is presenting": "%(sharerName)s esitab",
"Your camera is turned off": "Sinu seadme kaamera on välja lülitatud",
"Your camera is still enabled": "Sinu seadme kaamera on jätkuvalt kasutusel",
"Screen sharing is here!": "Meil on nüüd olemas ekraanijagamine!",
"Share entire screen": "Jaga tervet ekraani",
"Application window": "Rakenduse aken",
"Share content": "Jaga sisu",
"Anyone will be able to find and join this room.": "Kõik saavad seda jututuba leida ja temaga liituda.",
"Leave %(spaceName)s": "Lahku %(spaceName)s kogukonnakeskusest",
"Are you sure you want to leave <spaceName/>?": "Kas oled kindel, et soovid lahkuda <spaceName/> kogukonnakeskusest?",
"Decrypting": "Dekrüptin sisu",
"Search for rooms or spaces": "Otsi jututubasid või kogukondi",
"Spaces are a new feature.": "Kogukonnakeskused on uus funktsionaalsus.",
"Spaces feedback": "Tagasiside kogukonnakeskuste kohta",
"Give feedback.": "Jaga tagasisidet.",
"We're working on this, but just want to let you know.": "Me küll alles arendame seda võimalust, kuid soovisime, et tead, mis tulemas on.",
"All rooms you're in will appear in Home.": "Kõik sinu jututoad on nähtavad kodulehel.",
"Show all rooms": "Näita kõiki jututubasid",
"Leave all rooms and spaces": "Lahku kõikidest jututubadest ja kogukondadest",
"Don't leave any": "Ära lahku ühestki",
"Leave specific rooms and spaces": "Lahku neist jututubadest ja kogukondadest",
"Search %(spaceName)s": "Otsi %(spaceName)s kogukonnast",
"You won't be able to rejoin unless you are re-invited.": "Ilma uue kutseta sa ei saa uuesti liituda.",
"Want to add a new space instead?": "Kas sa selle asemel soovid lisada uut kogukonnakeskust?",
"Create a new space": "Loo uus kogukonnakeskus",
"Search for spaces": "Otsi kogukonnakeskusi",
"Search for rooms": "Otsi jututube",
"Adding spaces has moved.": "Kogukondade lisamine asub nüüd uues kohas.",
"Anyone in <SpaceName/> will be able to find and join.": "Kõik <SpaceName/> kogukonna liikmed saavad seda leida ning võivad temaga liituda.",
"Anyone will be able to find and join this space, not just members of <SpaceName/>.": "Mitte ainult <SpaceName/> kogukonna liikmed, vaid kõik saavad seda kogukonda leida ja võivad temaga liituda.",
"Only people invited will be able to find and join this space.": "See kogukond on leitav vaid kutse olemasolul ning liitumine on võimalik vaid kutse alusel.",
"Add a space to a space you manage.": "Lisa kogukond sellesse kogukonda, mida sa juba haldad.",
"Space visibility": "Kogukonna nähtavus",
"Private space (invite only)": "Privaatne kogukond (kutse alusel)",
"Want to add an existing space instead?": "Kas sa selle asemel soovid lisada olemasoleva kogukonnakeskuse?",
"Thank you for trying Spaces. Your feedback will help inform the next versions.": "Tänud, et katsetasid kogukonnakeskuseid. Sinu tagasiside alusel saame neid tulevikus paremaks teha.",
"You're the only admin of this space. Leaving it will mean no one has control over it.": "Sa oled selle kogukonna ainus haldaja. Kui lahkud, siis ei leidu enam kedagi, kellel oleks seal haldusõigusi.",
"You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "Mõnedes jututubades või kogukondades oled sa ainus haldaja. Kuna sa nüüd soovid neist lahkuda, siis jäävad nad haldajata.",
"Send pseudonymous analytics data": "Saada analüütilist teavet suvalise nime alt",
"Call declined": "Osapool keeldus kõnest",
"Missed call": "Vastamata kõne",
"Send voice message": "Saada häälsõnum",
"Stop recording": "Lõpeta salvestamine",
"Start the camera": "Võta kaamera kasutusele",
"Stop the camera": "Lõpeta kaamera kasutamine",
"Stop sharing your screen": "Lõpeta oma seadme ekraani jagamine",
"Start sharing your screen": "Alusta oma seadme ekraani jagamist",
"Hide sidebar": "Peida külgpaan",
"Show sidebar": "Näita külgpaani",
"More": "Veel",
"Dialpad": "Numbriklahvistik",
"Unmute the microphone": "Eemalda mikrofoni summutamine",
"Mute the microphone": "Summuta mikrofon"
}

View file

@ -3228,7 +3228,7 @@
"You have unverified logins": "Vous avez des sessions non-vérifiées",
"Without verifying, you wont have access to all your messages and may appear as untrusted to others.": "Sans vérification vous naurez pas accès à tous vos messages et napparaîtrez pas comme de confiance aux autres.",
"Verify your identity to access encrypted messages and prove your identity to others.": "Vérifiez votre identité pour accéder aux messages chiffrés et prouver votre identité aux autres.",
"Use another login": "Utiliser un autre identifiant",
"Use another login": "Utiliser une autre session",
"Please choose a strong password": "Merci de choisir un mot de passe fort",
"You can add more later too, including already existing ones.": "Vous pourrez en ajouter plus tard, y compris certains déjà existant.",
"Let's create a room for each of them.": "Créons un salon pour chacun dentre eux.",
@ -3493,5 +3493,132 @@
"Use Command + F to search timeline": "Utilisez Commande + F pour rechercher dans le fil de discussion",
"User %(userId)s is already invited to the room": "Lutilisateur %(userId)s est déjà invité dans le salon",
"Transfer Failed": "Échec du transfert",
"Unable to transfer call": "Impossible de transférer lappel"
"Unable to transfer call": "Impossible de transférer lappel",
"Send pseudonymous analytics data": "Envoyer des données de télémétrie pseudonymisées",
"To help space members find and join a private room, go to that room's Security & Privacy settings.": "Pour aider les membres de lespace à trouver et rejoindre un salon privé, allez dans les paramètres de confidentialité de ce salon.",
"Help space members find private rooms": "Aidez les membres de lespace à trouver des salons privés",
"Help people in spaces to find and join private rooms": "Aidez les personnes dans les espaces à trouver et rejoindre des salons privés",
"New in the Spaces beta": "Nouveautés dans les espaces en bêta",
"Decide who can join %(roomName)s.": "Choisir qui peut rejoindre %(roomName)s.",
"Space members": "Membres de lespace",
"Anyone in a space can find and join. You can select multiple spaces.": "Tout le monde dans un espace peut trouver et venir. Vous pouvez sélectionner plusieurs espaces.",
"Anyone in %(spaceName)s can find and join. You can select other spaces too.": "Tout le monde dans %(spaceName)s peut trouver et venir. Vous pouvez sélectionner dautres espaces.",
"Spaces with access": "Espaces avec accès",
"Anyone in a space can find and join. <a>Edit which spaces can access here.</a>": "Tout le monde dans un espace peut trouver et venir. <a>Éditer les accès des espaces ici.</a>",
"Currently, %(count)s spaces have access|other": "%(count)s espaces ont actuellement laccès",
"& %(count)s more|other": "& %(count)s de plus",
"Upgrade required": "Mise-à-jour nécessaire",
"Anyone can find and join.": "Tout le monde peut trouver et venir.",
"Only invited people can join.": "Seules les personnes invitées peuvent venir.",
"Private (invite only)": "Privé (sur invitation)",
"This upgrade will allow members of selected spaces access to this room without an invite.": "Cette mise-à-jour permettra aux membres des espaces sélectionnés daccéder à ce salon sans invitation.",
"Message bubbles": "Message en bulles",
"IRC": "IRC",
"Show all rooms": "Afficher tous les salons",
"Give feedback.": "Écrire un commentaire.",
"Thank you for trying Spaces. Your feedback will help inform the next versions.": "Merci dessayer les espaces. Vos commentaires permettront daméliorer les prochaines versions.",
"Spaces feedback": "Commentaires sur les espaces",
"Spaces are a new feature.": "Les espaces sont une fonctionnalité nouvelle.",
"Your camera is still enabled": "Votre caméra est toujours allumée",
"Your camera is turned off": "Votre caméra est éteinte",
"%(sharerName)s is presenting": "%(sharerName)s est à lécran",
"You are presenting": "Vous êtes à lécran",
"All rooms you're in will appear in Home.": "Tous les salons dans lesquels vous vous trouvez apparaîtront sur lAccueil.",
"New layout switcher (with message bubbles)": "Nouveau sélecteur de disposition (avec les messages en bulles)",
"This makes it easy for rooms to stay private to a space, while letting people in the space find and join them. All new rooms in a space will have this option available.": "Cela permet de garder facilement un salon privé dans un espace, tout en laissant la possibilité aux gens de trouver lespace et de le rejoindre. Tous les nouveaux salons de cet espace auront cette option disponible.",
"Adding spaces has moved.": "Lajout despaces a été déplacé.",
"Search for rooms": "Rechercher des salons",
"Search for spaces": "Rechercher des espaces",
"Create a new space": "Créer un nouvel espace",
"Want to add a new space instead?": "Vous voulez plutôt ajouter un nouvel espace ?",
"Add existing space": "Ajouter un espace existant",
"Share content": "Partager le contenu",
"Application window": "Fenêtre dapplication",
"Share entire screen": "Partager lécran entier",
"Image": "Image",
"Sticker": "Autocollant",
"Decrypting": "Déchiffrement",
"The call is in an unknown state!": "Cet appel est dans un état inconnu !",
"You missed this call": "Vous avez raté cet appel",
"This call has failed": "Cet appel a échoué",
"Unknown failure: %(reason)s)": "Échec inconnu : %(reason)s",
"An unknown error occurred": "Une erreur inconnue sest produite",
"Their device couldn't start the camera or microphone": "Leur appareil na pas pu démarrer la caméra ou le microphone",
"Connection failed": "Connexion échouée",
"Could not connect media": "Impossible de se connecter au média",
"They didn't pick up": "Ils nont pas décroché",
"This call has ended": "Cet appel est terminé",
"Call again": "Appeler encore",
"Call back": "Rappeler",
"They declined this call": "Ils ont refusé cet appel",
"You declined this call": "Vous avez refusé cet appel",
"Connected": "Connecté",
"The voice message failed to upload.": "Ce message vocal na pas pu être envoyé.",
"Copy Room Link": "Copier le lien du salon",
"You can now share your screen by pressing the \"screen share\" button during a call. You can even do this in audio calls if both sides support it!": "Vous pouvez désormais partager votre écran à laide du bouton « partager lécran » pendant un appel. Il est même possible de le faire pendant un appel audio si les deux parties le prennent en charge !",
"Screen sharing is here!": "Le partage décran est arrivé !",
"Access": "Accès",
"People with supported clients will be able to join the room without having a registered account.": "Les personnes utilisant un client pris en charge pourront rejoindre le salon sans compte.",
"We're working on this, but just want to let you know.": "Nous travaillons dessus, mais c'est juste pour vous le faire savoir.",
"Search for rooms or spaces": "Rechercher des salons ou des espaces",
"Unable to copy a link to the room to the clipboard.": "Impossible de copier le lien du salon dans le presse-papier.",
"Unable to copy room link": "Impossible de copier le lien du salon",
"Error downloading audio": "Erreur lors du téléchargement de laudio",
"Unnamed audio": "Audio sans nom",
"Add space": "Ajouter un espace",
"<b>Please note upgrading will make a new version of the room</b>. All current messages will stay in this archived room.": "<b>Veuillez notez que la mise-à-jour va créer une nouvelle version de ce salon</b>. Tous les messages actuels resteront dans ce salon archivé.",
"Automatically invite members from this room to the new one": "Inviter automatiquement les membres de ce salon dans le nouveau",
"This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\n This will be reported to the administrators of %(homeserver)s.": "Ce salon est utilisé pour du contenu toxique ou illégal, ou les modérateurs ont échoué à modérer le contenu toxique ou illégal.\n Cela sera signalé aux administrateurs de %(homeserver)s.",
"This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\nThis will be reported to the administrators of %(homeserver)s. The administrators will NOT be able to read the encrypted content of this room.": "Ce salon est utilisé pour du contenu toxique ou illégal, ou les modérateurs ont échoué à modérer le contenu toxique ou illégal.\nCela sera signalé aux administrateurs de %(homeserver)s. Les administrateurs ne pourront PAS lire le contenu chiffré ce salon.",
"These are likely ones other room admins are a part of.": "Ces autres administrateurs du salon en font probablement partie.",
"Other spaces or rooms you might not know": "Autres espaces ou salons que vous pourriez ne pas connaître",
"Spaces you know that contain this room": "Les espaces connus qui contiennent ce salon",
"Search spaces": "Rechercher des espaces",
"Decide which spaces can access this room. If a space is selected, its members can find and join <RoomName/>.": "Choisir quels espaces peuvent accéder à ce salon. Si un espace est sélectionné, ses membres pourront trouver et rejoindre <RoomName/>.",
"Select spaces": "Sélectionner des espaces",
"You're removing all spaces. Access will default to invite only": "Vous allez supprimer tous les espaces. Laccès se fera sur invitation uniquement par défaut",
"Are you sure you want to leave <spaceName/>?": "Êtes-vous sûr de quitter <spaceName/> ?",
"Leave %(spaceName)s": "Quitter %(spaceName)s",
"You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "Vous êtes le seul administrateur de certains salons ou espaces que vous souhaitez quitter. En les quittant, vous les laisserez sans aucun administrateur.",
"You're the only admin of this space. Leaving it will mean no one has control over it.": "Vous êtes le seul administrateur de cet espace. En le quittant, plus personne naura le contrôle dessus.",
"You won't be able to rejoin unless you are re-invited.": "Il vous sera impossible de revenir à moins dy être réinvité.",
"Search %(spaceName)s": "Rechercher %(spaceName)s",
"Leave specific rooms and spaces": "Laisser certains salons et espaces",
"Don't leave any": "Ne rien quitter",
"Leave all rooms and spaces": "Quitter tous les salons et les espaces",
"Want to add an existing space instead?": "Vous voulez plutôt ajouter un espace existant ?",
"Private space (invite only)": "Espace privé (uniquement sur invitation)",
"Space visibility": "Visibilité de lespace",
"Add a space to a space you manage.": "Ajouter un espace à lespace que vous gérez.",
"Only people invited will be able to find and join this space.": "Seules les personnes invitées pourront trouver et rejoindre cet espace.",
"Anyone will be able to find and join this space, not just members of <SpaceName/>.": "Quiconque pourra trouver et rejoindre cet espace, pas seulement les membres de <SpaceName/>.",
"Anyone in <SpaceName/> will be able to find and join.": "Tous les membres de <SpaceName/> pourront trouver et venir.",
"Visible to space members": "Visible pour les membres de l'espace",
"Public room": "Salon public",
"Private room (invite only)": "Salon privé (uniquement sur invitation)",
"Room visibility": "Visibilité du salon",
"Create a room": "Créer un salon",
"Only people invited will be able to find and join this room.": "Seules les personnes invitées pourront trouver et rejoindre ce salon.",
"Anyone will be able to find and join this room.": "Quiconque pourra trouver et rejoindre ce salon.",
"Anyone will be able to find and join this room, not just members of <SpaceName/>.": "Quiconque pourra trouver et rejoindre ce salon, pas seulement les membres de <SpaceName/>.",
"You can change this at any time from room settings.": "Vous pouvez changer ceci nimporte quand depuis les paramètres du salon.",
"Everyone in <SpaceName/> will be able to find and join this room.": "Tout le monde dans <SpaceName/> pourra trouver et rejoindre ce salon.",
"%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.|other": "%(oneUser)s a changé %(count)s fois les <a>messages épinglés</a> du salon.",
"%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.|other": "%(severalUsers)s ont changé %(count)s fois les <a>messages épinglés</a> du salon.",
"Missed call": "Appel manqué",
"Call declined": "Appel rejeté",
"Stop recording": "Arrêter lenregistrement",
"Send voice message": "Envoyer un message vocal",
"Olm version:": "Version de Olm :",
"Mute the microphone": "Désactiver le microphone",
"Unmute the microphone": "Activer le microphone",
"Dialpad": "Pavé numérique",
"More": "Plus",
"Show sidebar": "Afficher la barre latérale",
"Hide sidebar": "Masquer la barre latérale",
"Start sharing your screen": "Commencer à partager mon écran",
"Stop sharing your screen": "Arrêter de partager mon écran",
"Stop the camera": "Arrêter la caméra",
"Start the camera": "Démarrer la caméra",
"Surround selected text when typing special characters": "Entourer le texte sélectionné lors de la saisie de certains caractères"
}

Some files were not shown because too many files have changed in this diff Show more