Merge remote-tracking branch 'upstream/develop' into fix/17130/draggable-pip
This commit is contained in:
commit
007548aa7f
20 changed files with 297 additions and 252 deletions
3
src/@types/global.d.ts
vendored
3
src/@types/global.d.ts
vendored
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
|
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
|
||||||
import * as ModernizrStatic from "modernizr";
|
import "@types/modernizr";
|
||||||
|
|
||||||
import ContentMessages from "../ContentMessages";
|
import ContentMessages from "../ContentMessages";
|
||||||
import { IMatrixClientPeg } from "../MatrixClientPeg";
|
import { IMatrixClientPeg } from "../MatrixClientPeg";
|
||||||
|
@ -50,7 +50,6 @@ import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
Modernizr: ModernizrStatic;
|
|
||||||
matrixChat: ReturnType<Renderer>;
|
matrixChat: ReturnType<Renderer>;
|
||||||
mxMatrixClientPeg: IMatrixClientPeg;
|
mxMatrixClientPeg: IMatrixClientPeg;
|
||||||
Olm: {
|
Olm: {
|
||||||
|
|
|
@ -569,7 +569,7 @@ export default class ContentMessages {
|
||||||
dis.dispatch<UploadStartedPayload>({ action: Action.UploadStarted, upload });
|
dis.dispatch<UploadStartedPayload>({ action: Action.UploadStarted, upload });
|
||||||
|
|
||||||
// Focus the composer view
|
// Focus the composer view
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
|
|
||||||
function onProgress(ev) {
|
function onProgress(ev) {
|
||||||
upload.total = ev.total;
|
upload.total = ev.total;
|
||||||
|
|
|
@ -20,12 +20,15 @@ import { SettingLevel } from "./settings/SettingLevel";
|
||||||
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
|
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
|
|
||||||
interface IMediaDevices {
|
// XXX: MediaDeviceKind is a union type, so we make our own enum
|
||||||
audioOutput: Array<MediaDeviceInfo>;
|
export enum MediaDeviceKindEnum {
|
||||||
audioInput: Array<MediaDeviceInfo>;
|
AudioOutput = "audiooutput",
|
||||||
videoInput: Array<MediaDeviceInfo>;
|
AudioInput = "audioinput",
|
||||||
|
VideoInput = "videoinput",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IMediaDevices = Record<MediaDeviceKindEnum, Array<MediaDeviceInfo>>;
|
||||||
|
|
||||||
export enum MediaDeviceHandlerEvent {
|
export enum MediaDeviceHandlerEvent {
|
||||||
AudioOutputChanged = "audio_output_changed",
|
AudioOutputChanged = "audio_output_changed",
|
||||||
}
|
}
|
||||||
|
@ -51,20 +54,14 @@ export default class MediaDeviceHandler extends EventEmitter {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
const output = {
|
||||||
|
[MediaDeviceKindEnum.AudioOutput]: [],
|
||||||
|
[MediaDeviceKindEnum.AudioInput]: [],
|
||||||
|
[MediaDeviceKindEnum.VideoInput]: [],
|
||||||
|
};
|
||||||
|
|
||||||
const audioOutput = [];
|
devices.forEach((device) => output[device.kind].push(device));
|
||||||
const audioInput = [];
|
return output;
|
||||||
const videoInput = [];
|
|
||||||
|
|
||||||
devices.forEach((device) => {
|
|
||||||
switch (device.kind) {
|
|
||||||
case 'audiooutput': audioOutput.push(device); break;
|
|
||||||
case 'audioinput': audioInput.push(device); break;
|
|
||||||
case 'videoinput': videoInput.push(device); break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { audioOutput, audioInput, videoInput };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Unable to refresh WebRTC Devices: ', error);
|
console.warn('Unable to refresh WebRTC Devices: ', error);
|
||||||
}
|
}
|
||||||
|
@ -106,6 +103,14 @@ export default class MediaDeviceHandler extends EventEmitter {
|
||||||
setMatrixCallVideoInput(deviceId);
|
setMatrixCallVideoInput(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void {
|
||||||
|
switch (kind) {
|
||||||
|
case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break;
|
||||||
|
case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break;
|
||||||
|
case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static getAudioOutput(): string {
|
public static getAudioOutput(): string {
|
||||||
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
|
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
|
||||||
}
|
}
|
||||||
|
|
|
@ -398,7 +398,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||||
// refocusing during a paste event will make the
|
// refocusing during a paste event will make the
|
||||||
// paste end up in the newly focused element,
|
// paste end up in the newly focused element,
|
||||||
// so dispatch synchronously before paste happens
|
// so dispatch synchronously before paste happens
|
||||||
dis.fire(Action.FocusComposer, true);
|
dis.fire(Action.FocusSendMessageComposer, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -552,7 +552,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
||||||
// synchronous dispatch so we focus before key generates input
|
// synchronous dispatch so we focus before key generates input
|
||||||
dis.fire(Action.FocusComposer, true);
|
dis.fire(Action.FocusSendMessageComposer, true);
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
// we should *not* preventDefault() here as
|
// we should *not* preventDefault() here as
|
||||||
// that would prevent typing in the now-focussed composer
|
// that would prevent typing in the now-focussed composer
|
||||||
|
|
|
@ -443,7 +443,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||||
CountlyAnalytics.instance.trackPageChange(durationMs);
|
CountlyAnalytics.instance.trackPageChange(durationMs);
|
||||||
}
|
}
|
||||||
if (this.focusComposer) {
|
if (this.focusComposer) {
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
this.focusComposer = false;
|
this.focusComposer = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1427,7 +1427,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||||
showNotificationsToast(false);
|
showNotificationsToast(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
this.setState({
|
this.setState({
|
||||||
ready: true,
|
ready: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -48,6 +48,9 @@ import { ActionPayload } from "../../dispatcher/payloads";
|
||||||
const MAX_NAME_LENGTH = 80;
|
const MAX_NAME_LENGTH = 80;
|
||||||
const MAX_TOPIC_LENGTH = 800;
|
const MAX_TOPIC_LENGTH = 800;
|
||||||
|
|
||||||
|
const LAST_SERVER_KEY = "mx_last_room_directory_server";
|
||||||
|
const LAST_INSTANCE_KEY = "mx_last_room_directory_instance";
|
||||||
|
|
||||||
function track(action: string) {
|
function track(action: string) {
|
||||||
Analytics.trackEvent('RoomDirectory', action);
|
Analytics.trackEvent('RoomDirectory', action);
|
||||||
}
|
}
|
||||||
|
@ -61,7 +64,7 @@ interface IState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
protocolsLoading: boolean;
|
protocolsLoading: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
instanceId: string | symbol;
|
instanceId: string;
|
||||||
roomServer: string;
|
roomServer: string;
|
||||||
filterString: string;
|
filterString: string;
|
||||||
selectedCommunityId?: string;
|
selectedCommunityId?: string;
|
||||||
|
@ -116,6 +119,36 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||||
} else if (!selectedCommunityId) {
|
} else if (!selectedCommunityId) {
|
||||||
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
|
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
|
||||||
this.protocols = response;
|
this.protocols = response;
|
||||||
|
const myHomeserver = MatrixClientPeg.getHomeserverName();
|
||||||
|
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
|
||||||
|
const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
|
||||||
|
|
||||||
|
let roomServer = myHomeserver;
|
||||||
|
if (
|
||||||
|
SdkConfig.get().roomDirectory?.servers?.includes(lsRoomServer) ||
|
||||||
|
SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
|
||||||
|
) {
|
||||||
|
roomServer = lsRoomServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
let instanceId: string = null;
|
||||||
|
if (roomServer === myHomeserver && (
|
||||||
|
lsInstanceId === ALL_ROOMS ||
|
||||||
|
Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId))
|
||||||
|
)) {
|
||||||
|
instanceId = lsInstanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the room list only if validation failed and we had to change these
|
||||||
|
if (this.state.instanceId !== instanceId || this.state.roomServer !== roomServer) {
|
||||||
|
this.setState({
|
||||||
|
protocolsLoading: false,
|
||||||
|
instanceId,
|
||||||
|
roomServer,
|
||||||
|
});
|
||||||
|
this.refreshRoomList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.setState({ protocolsLoading: false });
|
this.setState({ protocolsLoading: false });
|
||||||
}, (err) => {
|
}, (err) => {
|
||||||
console.warn(`error loading third party protocols: ${err}`);
|
console.warn(`error loading third party protocols: ${err}`);
|
||||||
|
@ -150,8 +183,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||||
publicRooms: [],
|
publicRooms: [],
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
instanceId: undefined,
|
instanceId: localStorage.getItem(LAST_INSTANCE_KEY),
|
||||||
roomServer: MatrixClientPeg.getHomeserverName(),
|
roomServer: localStorage.getItem(LAST_SERVER_KEY),
|
||||||
filterString: this.props.initialText || "",
|
filterString: this.props.initialText || "",
|
||||||
selectedCommunityId,
|
selectedCommunityId,
|
||||||
communityName: null,
|
communityName: null,
|
||||||
|
@ -342,7 +375,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onOptionChange = (server: string, instanceId?: string | symbol) => {
|
private onOptionChange = (server: string, instanceId?: string) => {
|
||||||
// clear next batch so we don't try to load more rooms
|
// clear next batch so we don't try to load more rooms
|
||||||
this.nextBatch = null;
|
this.nextBatch = null;
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -360,6 +393,14 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||||
// find the five gitter ones, at which point we do not want
|
// find the five gitter ones, at which point we do not want
|
||||||
// to render all those rooms when switching back to 'all networks'.
|
// to render all those rooms when switching back to 'all networks'.
|
||||||
// Easiest to just blow away the state & re-fetch.
|
// Easiest to just blow away the state & re-fetch.
|
||||||
|
|
||||||
|
// We have to be careful here so that we don't set instanceId = "undefined"
|
||||||
|
localStorage.setItem(LAST_SERVER_KEY, server);
|
||||||
|
if (instanceId) {
|
||||||
|
localStorage.setItem(LAST_INSTANCE_KEY, instanceId);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(LAST_INSTANCE_KEY);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onFillRequest = (backwards: boolean) => {
|
private onFillRequest = (backwards: boolean) => {
|
||||||
|
|
|
@ -131,7 +131,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case RoomListAction.ClearSearch:
|
case RoomListAction.ClearSearch:
|
||||||
this.clearInput();
|
this.clearInput();
|
||||||
defaultDispatcher.fire(Action.FocusComposer);
|
defaultDispatcher.fire(Action.FocusSendMessageComposer);
|
||||||
break;
|
break;
|
||||||
case RoomListAction.NextRoom:
|
case RoomListAction.NextRoom:
|
||||||
case RoomListAction.PrevRoom:
|
case RoomListAction.PrevRoom:
|
||||||
|
|
|
@ -118,12 +118,12 @@ export default class RoomStatusBar extends React.PureComponent {
|
||||||
this.setState({ isResending: false });
|
this.setState({ isResending: false });
|
||||||
});
|
});
|
||||||
this.setState({ isResending: true });
|
this.setState({ isResending: true });
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
};
|
};
|
||||||
|
|
||||||
_onCancelAllClick = () => {
|
_onCancelAllClick = () => {
|
||||||
Resend.cancelUnsentEvents(this.props.room);
|
Resend.cancelUnsentEvents(this.props.room);
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
};
|
};
|
||||||
|
|
||||||
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
|
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
|
||||||
|
|
|
@ -818,17 +818,16 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
case Action.ComposerInsert: {
|
case Action.ComposerInsert: {
|
||||||
// re-dispatch to the correct composer
|
// re-dispatch to the correct composer
|
||||||
if (this.state.editState) {
|
dis.dispatch({
|
||||||
dis.dispatch({
|
...payload,
|
||||||
...payload,
|
action: this.state.editState ? "edit_composer_insert" : "send_composer_insert",
|
||||||
action: "edit_composer_insert",
|
});
|
||||||
});
|
break;
|
||||||
} else {
|
}
|
||||||
dis.dispatch({
|
|
||||||
...payload,
|
case Action.FocusAComposer: {
|
||||||
action: "send_composer_insert",
|
// re-dispatch to the correct composer
|
||||||
});
|
dis.fire(this.state.editState ? Action.FocusEditMessageComposer : Action.FocusSendMessageComposer);
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1246,7 +1245,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||||
ev.dataTransfer.files, this.state.room.roomId, this.context,
|
ev.dataTransfer.files, this.state.room.roomId, this.context,
|
||||||
);
|
);
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
draggingFile: false,
|
draggingFile: false,
|
||||||
|
@ -1548,7 +1547,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||||
} else {
|
} else {
|
||||||
// Otherwise we have to jump manually
|
// Otherwise we have to jump manually
|
||||||
this.messagePanel.jumpToLiveTimeline();
|
this.messagePanel.jumpToLiveTimeline();
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||||
Copyright 2015, 2016, 2018, 2019, 2021 The Matrix.org Foundation C.I.C.
|
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -16,12 +16,11 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||||
import { EventStatus } from 'matrix-js-sdk/src/models/event';
|
import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
|
||||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||||
import dis from '../../../dispatcher/dispatcher';
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
import * as sdk from '../../../index';
|
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import Modal from '../../../Modal';
|
import Modal from '../../../Modal';
|
||||||
import Resend from '../../../Resend';
|
import Resend from '../../../Resend';
|
||||||
|
@ -29,53 +28,65 @@ import SettingsStore from '../../../settings/SettingsStore';
|
||||||
import { isUrlPermitted } from '../../../HtmlUtils';
|
import { isUrlPermitted } from '../../../HtmlUtils';
|
||||||
import { isContentActionable } from '../../../utils/EventUtils';
|
import { isContentActionable } from '../../../utils/EventUtils';
|
||||||
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu';
|
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu';
|
||||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
|
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
|
||||||
import ForwardDialog from "../dialogs/ForwardDialog";
|
import ForwardDialog from "../dialogs/ForwardDialog";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
|
import ReportEventDialog from '../dialogs/ReportEventDialog';
|
||||||
|
import ViewSource from '../../structures/ViewSource';
|
||||||
|
import ConfirmRedactDialog from '../dialogs/ConfirmRedactDialog';
|
||||||
|
import ErrorDialog from '../dialogs/ErrorDialog';
|
||||||
|
import ShareDialog from '../dialogs/ShareDialog';
|
||||||
|
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||||
|
|
||||||
export function canCancel(eventStatus) {
|
export function canCancel(eventStatus: EventStatus): boolean {
|
||||||
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
|
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IEventTileOps {
|
||||||
|
isWidgetHidden(): boolean;
|
||||||
|
unhideWidget(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
/* the MatrixEvent associated with the context menu */
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
/* an optional EventTileOps implementation that can be used to unhide preview widgets */
|
||||||
|
eventTileOps?: IEventTileOps;
|
||||||
|
permalinkCreator?: RoomPermalinkCreator;
|
||||||
|
/* an optional function to be called when the user clicks collapse thread, if not provided hide button */
|
||||||
|
collapseReplyThread?(): void;
|
||||||
|
/* callback called when the menu is dismissed */
|
||||||
|
onFinished(): void;
|
||||||
|
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
|
||||||
|
onCloseDialog?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
canRedact: boolean;
|
||||||
|
canPin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.context_menus.MessageContextMenu")
|
@replaceableComponent("views.context_menus.MessageContextMenu")
|
||||||
export default class MessageContextMenu extends React.Component {
|
export default class MessageContextMenu extends React.Component<IProps, IState> {
|
||||||
static propTypes = {
|
|
||||||
/* the MatrixEvent associated with the context menu */
|
|
||||||
mxEvent: PropTypes.object.isRequired,
|
|
||||||
|
|
||||||
/* an optional EventTileOps implementation that can be used to unhide preview widgets */
|
|
||||||
eventTileOps: PropTypes.object,
|
|
||||||
|
|
||||||
/* an optional function to be called when the user clicks collapse thread, if not provided hide button */
|
|
||||||
collapseReplyThread: PropTypes.func,
|
|
||||||
|
|
||||||
/* callback called when the menu is dismissed */
|
|
||||||
onFinished: PropTypes.func,
|
|
||||||
|
|
||||||
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
|
|
||||||
onCloseDialog: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
canRedact: false,
|
canRedact: false,
|
||||||
canPin: false,
|
canPin: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions);
|
MatrixClientPeg.get().on('RoomMember.powerLevel', this.checkPermissions);
|
||||||
this._checkPermissions();
|
this.checkPermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
if (cli) {
|
if (cli) {
|
||||||
cli.removeListener('RoomMember.powerLevel', this._checkPermissions);
|
cli.removeListener('RoomMember.powerLevel', this.checkPermissions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_checkPermissions = () => {
|
private checkPermissions = (): void => {
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||||
|
|
||||||
|
@ -93,7 +104,7 @@ export default class MessageContextMenu extends React.Component {
|
||||||
this.setState({ canRedact, canPin });
|
this.setState({ canRedact, canPin });
|
||||||
};
|
};
|
||||||
|
|
||||||
_isPinned() {
|
private isPinned(): boolean {
|
||||||
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
|
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
|
||||||
const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, '');
|
const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, '');
|
||||||
if (!pinnedEvent) return false;
|
if (!pinnedEvent) return false;
|
||||||
|
@ -101,38 +112,35 @@ export default class MessageContextMenu extends React.Component {
|
||||||
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
|
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
onResendReactionsClick = () => {
|
private onResendReactionsClick = (): void => {
|
||||||
for (const reaction of this._getUnsentReactions()) {
|
for (const reaction of this.getUnsentReactions()) {
|
||||||
Resend.resend(reaction);
|
Resend.resend(reaction);
|
||||||
}
|
}
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
onReportEventClick = () => {
|
private onReportEventClick = (): void => {
|
||||||
const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog");
|
|
||||||
Modal.createTrackedDialog('Report Event', '', ReportEventDialog, {
|
Modal.createTrackedDialog('Report Event', '', ReportEventDialog, {
|
||||||
mxEvent: this.props.mxEvent,
|
mxEvent: this.props.mxEvent,
|
||||||
}, 'mx_Dialog_reportEvent');
|
}, 'mx_Dialog_reportEvent');
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
onViewSourceClick = () => {
|
private onViewSourceClick = (): void => {
|
||||||
const ViewSource = sdk.getComponent('structures.ViewSource');
|
|
||||||
Modal.createTrackedDialog('View Event Source', '', ViewSource, {
|
Modal.createTrackedDialog('View Event Source', '', ViewSource, {
|
||||||
mxEvent: this.props.mxEvent,
|
mxEvent: this.props.mxEvent,
|
||||||
}, 'mx_Dialog_viewsource');
|
}, 'mx_Dialog_viewsource');
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
onRedactClick = () => {
|
private onRedactClick = (): void => {
|
||||||
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
|
|
||||||
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
|
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
|
||||||
onFinished: async (proceed, reason) => {
|
onFinished: async (proceed: boolean, reason?: string) => {
|
||||||
if (!proceed) return;
|
if (!proceed) return;
|
||||||
|
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
try {
|
try {
|
||||||
if (this.props.onCloseDialog) this.props.onCloseDialog();
|
this.props.onCloseDialog?.();
|
||||||
await cli.redactEvent(
|
await cli.redactEvent(
|
||||||
this.props.mxEvent.getRoomId(),
|
this.props.mxEvent.getRoomId(),
|
||||||
this.props.mxEvent.getId(),
|
this.props.mxEvent.getId(),
|
||||||
|
@ -145,7 +153,6 @@ export default class MessageContextMenu extends React.Component {
|
||||||
// (e.g. no errcode or statusCode) as in that case the redactions end up in the
|
// (e.g. no errcode or statusCode) as in that case the redactions end up in the
|
||||||
// detached queue and we show the room status bar to allow retry
|
// detached queue and we show the room status bar to allow retry
|
||||||
if (typeof code !== "undefined") {
|
if (typeof code !== "undefined") {
|
||||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
|
||||||
// display error message stating you couldn't delete this.
|
// display error message stating you couldn't delete this.
|
||||||
Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, {
|
Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, {
|
||||||
title: _t('Error'),
|
title: _t('Error'),
|
||||||
|
@ -158,7 +165,7 @@ export default class MessageContextMenu extends React.Component {
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
onForwardClick = () => {
|
private onForwardClick = (): void => {
|
||||||
Modal.createTrackedDialog('Forward Message', '', ForwardDialog, {
|
Modal.createTrackedDialog('Forward Message', '', ForwardDialog, {
|
||||||
matrixClient: MatrixClientPeg.get(),
|
matrixClient: MatrixClientPeg.get(),
|
||||||
event: this.props.mxEvent,
|
event: this.props.mxEvent,
|
||||||
|
@ -167,12 +174,12 @@ export default class MessageContextMenu extends React.Component {
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
onPinClick = () => {
|
private onPinClick = (): void => {
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||||
const eventId = this.props.mxEvent.getId();
|
const eventId = this.props.mxEvent.getId();
|
||||||
|
|
||||||
const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.pinned || [];
|
const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || [];
|
||||||
if (pinnedIds.includes(eventId)) {
|
if (pinnedIds.includes(eventId)) {
|
||||||
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
|
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
|
||||||
} else {
|
} else {
|
||||||
|
@ -188,18 +195,16 @@ export default class MessageContextMenu extends React.Component {
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
closeMenu = () => {
|
private closeMenu = (): void => {
|
||||||
if (this.props.onFinished) this.props.onFinished();
|
this.props.onFinished();
|
||||||
};
|
};
|
||||||
|
|
||||||
onUnhidePreviewClick = () => {
|
private onUnhidePreviewClick = (): void => {
|
||||||
if (this.props.eventTileOps) {
|
this.props.eventTileOps?.unhideWidget();
|
||||||
this.props.eventTileOps.unhideWidget();
|
|
||||||
}
|
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
onQuoteClick = () => {
|
private onQuoteClick = (): void => {
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: Action.ComposerInsert,
|
action: Action.ComposerInsert,
|
||||||
event: this.props.mxEvent,
|
event: this.props.mxEvent,
|
||||||
|
@ -207,9 +212,8 @@ export default class MessageContextMenu extends React.Component {
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
onPermalinkClick = (e) => {
|
private onPermalinkClick = (e: React.MouseEvent): void => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
|
|
||||||
Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
|
Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
|
||||||
target: this.props.mxEvent,
|
target: this.props.mxEvent,
|
||||||
permalinkCreator: this.props.permalinkCreator,
|
permalinkCreator: this.props.permalinkCreator,
|
||||||
|
@ -217,30 +221,27 @@ export default class MessageContextMenu extends React.Component {
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
onCollapseReplyThreadClick = () => {
|
private onCollapseReplyThreadClick = (): void => {
|
||||||
this.props.collapseReplyThread();
|
this.props.collapseReplyThread();
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
_getReactions(filter) {
|
private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] {
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||||
const eventId = this.props.mxEvent.getId();
|
const eventId = this.props.mxEvent.getId();
|
||||||
return room.getPendingEvents().filter(e => {
|
return room.getPendingEvents().filter(e => {
|
||||||
const relation = e.getRelation();
|
const relation = e.getRelation();
|
||||||
return relation &&
|
return relation?.rel_type === RelationType.Annotation && relation.event_id === eventId && filter(e);
|
||||||
relation.rel_type === "m.annotation" &&
|
|
||||||
relation.event_id === eventId &&
|
|
||||||
filter(e);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_getPendingReactions() {
|
private getPendingReactions(): MatrixEvent[] {
|
||||||
return this._getReactions(e => canCancel(e.status));
|
return this.getReactions(e => canCancel(e.status));
|
||||||
}
|
}
|
||||||
|
|
||||||
_getUnsentReactions() {
|
private getUnsentReactions(): MatrixEvent[] {
|
||||||
return this._getReactions(e => e.status === EventStatus.NOT_SENT);
|
return this.getReactions(e => e.status === EventStatus.NOT_SENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
@ -248,16 +249,17 @@ export default class MessageContextMenu extends React.Component {
|
||||||
const me = cli.getUserId();
|
const me = cli.getUserId();
|
||||||
const mxEvent = this.props.mxEvent;
|
const mxEvent = this.props.mxEvent;
|
||||||
const eventStatus = mxEvent.status;
|
const eventStatus = mxEvent.status;
|
||||||
const unsentReactionsCount = this._getUnsentReactions().length;
|
const unsentReactionsCount = this.getUnsentReactions().length;
|
||||||
let resendReactionsButton;
|
|
||||||
let redactButton;
|
let resendReactionsButton: JSX.Element;
|
||||||
let forwardButton;
|
let redactButton: JSX.Element;
|
||||||
let pinButton;
|
let forwardButton: JSX.Element;
|
||||||
let unhidePreviewButton;
|
let pinButton: JSX.Element;
|
||||||
let externalURLButton;
|
let unhidePreviewButton: JSX.Element;
|
||||||
let quoteButton;
|
let externalURLButton: JSX.Element;
|
||||||
let collapseReplyThread;
|
let quoteButton: JSX.Element;
|
||||||
let redactItemList;
|
let collapseReplyThread: JSX.Element;
|
||||||
|
let redactItemList: JSX.Element;
|
||||||
|
|
||||||
// status is SENT before remote-echo, null after
|
// status is SENT before remote-echo, null after
|
||||||
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
|
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
|
||||||
|
@ -296,7 +298,7 @@ export default class MessageContextMenu extends React.Component {
|
||||||
pinButton = (
|
pinButton = (
|
||||||
<IconizedContextMenuOption
|
<IconizedContextMenuOption
|
||||||
iconClassName="mx_MessageContextMenu_iconPin"
|
iconClassName="mx_MessageContextMenu_iconPin"
|
||||||
label={ this._isPinned() ? _t('Unpin') : _t('Pin') }
|
label={ this.isPinned() ? _t('Unpin') : _t('Pin') }
|
||||||
onClick={this.onPinClick}
|
onClick={this.onPinClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -333,9 +335,14 @@ export default class MessageContextMenu extends React.Component {
|
||||||
onClick={this.onPermalinkClick}
|
onClick={this.onPermalinkClick}
|
||||||
label= {_t('Share')}
|
label= {_t('Share')}
|
||||||
element="a"
|
element="a"
|
||||||
href={permalink}
|
{
|
||||||
target="_blank"
|
// XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
|
||||||
rel="noreferrer noopener"
|
...{
|
||||||
|
href: permalink,
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noreferrer noopener",
|
||||||
|
}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -350,8 +357,8 @@ export default class MessageContextMenu extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bridges can provide a 'external_url' to link back to the source.
|
// Bridges can provide a 'external_url' to link back to the source.
|
||||||
if (typeof (mxEvent.event.content.external_url) === "string" &&
|
if (typeof (mxEvent.getContent().external_url) === "string" &&
|
||||||
isUrlPermitted(mxEvent.event.content.external_url)
|
isUrlPermitted(mxEvent.getContent().external_url)
|
||||||
) {
|
) {
|
||||||
externalURLButton = (
|
externalURLButton = (
|
||||||
<IconizedContextMenuOption
|
<IconizedContextMenuOption
|
||||||
|
@ -359,9 +366,14 @@ export default class MessageContextMenu extends React.Component {
|
||||||
onClick={this.closeMenu}
|
onClick={this.closeMenu}
|
||||||
label={ _t('Source URL') }
|
label={ _t('Source URL') }
|
||||||
element="a"
|
element="a"
|
||||||
target="_blank"
|
{
|
||||||
rel="noreferrer noopener"
|
// XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
|
||||||
href={mxEvent.event.content.external_url}
|
...{
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noreferrer noopener",
|
||||||
|
href: mxEvent.getContent().external_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -376,7 +388,7 @@ export default class MessageContextMenu extends React.Component {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let reportEventButton;
|
let reportEventButton: JSX.Element;
|
||||||
if (mxEvent.getSender() !== me) {
|
if (mxEvent.getSender() !== me) {
|
||||||
reportEventButton = (
|
reportEventButton = (
|
||||||
<IconizedContextMenuOption
|
<IconizedContextMenuOption
|
|
@ -41,7 +41,8 @@ import QuestionDialog from "../dialogs/QuestionDialog";
|
||||||
import UIStore from "../../../stores/UIStore";
|
import UIStore from "../../../stores/UIStore";
|
||||||
import { compare } from "../../../utils/strings";
|
import { compare } from "../../../utils/strings";
|
||||||
|
|
||||||
export const ALL_ROOMS = Symbol("ALL_ROOMS");
|
// XXX: We would ideally use a symbol here but we can't since we save this value to localStorage
|
||||||
|
export const ALL_ROOMS = "ALL_ROOMS";
|
||||||
|
|
||||||
const SETTING_NAME = "room_directory_servers";
|
const SETTING_NAME = "room_directory_servers";
|
||||||
|
|
||||||
|
@ -94,8 +95,7 @@ export interface IInstance {
|
||||||
fields: object;
|
fields: object;
|
||||||
network_id: string;
|
network_id: string;
|
||||||
// XXX: this is undocumented but we rely on it.
|
// XXX: this is undocumented but we rely on it.
|
||||||
// we inject a fake entry with a symbolic instance_id.
|
instance_id: string;
|
||||||
instance_id: string | symbol;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProtocol {
|
export interface IProtocol {
|
||||||
|
@ -112,8 +112,8 @@ export type Protocols = Record<string, IProtocol>;
|
||||||
interface IProps {
|
interface IProps {
|
||||||
protocols: Protocols;
|
protocols: Protocols;
|
||||||
selectedServerName: string;
|
selectedServerName: string;
|
||||||
selectedInstanceId: string | symbol;
|
selectedInstanceId: string;
|
||||||
onOptionChange(server: string, instanceId?: string | symbol): void;
|
onOptionChange(server: string, instanceId?: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This dropdown sources homeservers from three places:
|
// This dropdown sources homeservers from three places:
|
||||||
|
@ -171,7 +171,7 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s
|
||||||
|
|
||||||
const protocolsList = server === hsName ? Object.values(protocols) : [];
|
const protocolsList = server === hsName ? Object.values(protocols) : [];
|
||||||
if (protocolsList.length > 0) {
|
if (protocolsList.length > 0) {
|
||||||
// add a fake protocol with the ALL_ROOMS symbol
|
// add a fake protocol with ALL_ROOMS
|
||||||
protocolsList.push({
|
protocolsList.push({
|
||||||
instances: [{
|
instances: [{
|
||||||
fields: [],
|
fields: [],
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { ReactHTML } from 'react';
|
||||||
|
|
||||||
import { Key } from '../../../Keyboard';
|
import { Key } from '../../../Keyboard';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
|
@ -29,7 +29,7 @@ export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Elemen
|
||||||
*/
|
*/
|
||||||
interface IProps extends React.InputHTMLAttributes<Element> {
|
interface IProps extends React.InputHTMLAttributes<Element> {
|
||||||
inputRef?: React.Ref<Element>;
|
inputRef?: React.Ref<Element>;
|
||||||
element?: string;
|
element?: keyof ReactHTML;
|
||||||
// The kind of button, similar to how Bootstrap works.
|
// The kind of button, similar to how Bootstrap works.
|
||||||
// See available classes for AccessibleButton for options.
|
// See available classes for AccessibleButton for options.
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
@ -122,7 +122,7 @@ export default function AccessibleButton({
|
||||||
}
|
}
|
||||||
|
|
||||||
AccessibleButton.defaultProps = {
|
AccessibleButton.defaultProps = {
|
||||||
element: 'div',
|
element: 'div' as keyof ReactHTML,
|
||||||
role: 'button',
|
role: 'button',
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
};
|
};
|
||||||
|
|
|
@ -334,7 +334,7 @@ export default class ReplyThread extends React.Component {
|
||||||
events,
|
events,
|
||||||
});
|
});
|
||||||
|
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
|
@ -22,6 +22,7 @@ import EmojiPicker from "./EmojiPicker";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import dis from "../../../dispatcher/dispatcher";
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
import { Action } from '../../../dispatcher/actions';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
mxEvent: MatrixEvent;
|
mxEvent: MatrixEvent;
|
||||||
|
@ -93,6 +94,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
|
||||||
this.props.mxEvent.getRoomId(),
|
this.props.mxEvent.getRoomId(),
|
||||||
myReactions[reaction],
|
myReactions[reaction],
|
||||||
);
|
);
|
||||||
|
dis.dispatch({ action: Action.FocusAComposer });
|
||||||
// Tell the emoji picker not to bump this in the more frequently used list.
|
// Tell the emoji picker not to bump this in the more frequently used list.
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
|
@ -104,6 +106,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
dis.dispatch({ action: "message_sent" });
|
dis.dispatch({ action: "message_sent" });
|
||||||
|
dis.dispatch({ action: Action.FocusAComposer });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -181,7 +181,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
|
||||||
} else {
|
} else {
|
||||||
this.clearStoredEditorState();
|
this.clearStoredEditorState();
|
||||||
dis.dispatch({ action: 'edit_event', event: null });
|
dis.dispatch({ action: 'edit_event', event: null });
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
break;
|
break;
|
||||||
|
@ -200,7 +200,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
|
||||||
private cancelEdit = (): void => {
|
private cancelEdit = (): void => {
|
||||||
this.clearStoredEditorState();
|
this.clearStoredEditorState();
|
||||||
dis.dispatch({ action: "edit_event", event: null });
|
dis.dispatch({ action: "edit_event", event: null });
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
};
|
};
|
||||||
|
|
||||||
private get shouldSaveStoredEditorState(): boolean {
|
private get shouldSaveStoredEditorState(): boolean {
|
||||||
|
@ -375,7 +375,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
|
||||||
|
|
||||||
// close the event editing and focus composer
|
// close the event editing and focus composer
|
||||||
dis.dispatch({ action: "edit_event", event: null });
|
dis.dispatch({ action: "edit_event", event: null });
|
||||||
dis.fire(Action.FocusComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
};
|
};
|
||||||
|
|
||||||
private cancelPreviousPendingEdit(): void {
|
private cancelPreviousPendingEdit(): void {
|
||||||
|
@ -452,6 +452,8 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
|
||||||
} else if (payload.text) {
|
} else if (payload.text) {
|
||||||
this.editorRef.current?.insertPlaintext(payload.text);
|
this.editorRef.current?.insertPlaintext(payload.text);
|
||||||
}
|
}
|
||||||
|
} else if (payload.action === Action.FocusEditMessageComposer && this.editorRef.current) {
|
||||||
|
this.editorRef.current.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -497,7 +497,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
|
||||||
|
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case 'reply_to_event':
|
case 'reply_to_event':
|
||||||
case Action.FocusComposer:
|
case Action.FocusSendMessageComposer:
|
||||||
this.editorRef.current?.focus();
|
this.editorRef.current?.focus();
|
||||||
break;
|
break;
|
||||||
case "send_composer_insert":
|
case "send_composer_insert":
|
||||||
|
|
|
@ -33,7 +33,7 @@ import RecordingPlayback from "../audio_messages/RecordingPlayback";
|
||||||
import { MsgType } from "matrix-js-sdk/src/@types/event";
|
import { MsgType } from "matrix-js-sdk/src/@types/event";
|
||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||||
import MediaDeviceHandler from "../../../MediaDeviceHandler";
|
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
@ -135,7 +135,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
// change between this and recording, but at least we will have tried.
|
// change between this and recording, but at least we will have tried.
|
||||||
try {
|
try {
|
||||||
const devices = await MediaDeviceHandler.getDevices();
|
const devices = await MediaDeviceHandler.getDevices();
|
||||||
if (!devices?.['audioInput']?.length) {
|
if (!devices?.[MediaDeviceKindEnum.AudioInput]?.length) {
|
||||||
Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, {
|
Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, {
|
||||||
title: _t("No microphone found"),
|
title: _t("No microphone found"),
|
||||||
description: <>
|
description: <>
|
||||||
|
|
|
@ -18,41 +18,58 @@ limitations under the License.
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { _t } from "../../../../../languageHandler";
|
import { _t } from "../../../../../languageHandler";
|
||||||
import SdkConfig from "../../../../../SdkConfig";
|
import SdkConfig from "../../../../../SdkConfig";
|
||||||
import MediaDeviceHandler from "../../../../../MediaDeviceHandler";
|
import MediaDeviceHandler, { IMediaDevices, MediaDeviceKindEnum } from "../../../../../MediaDeviceHandler";
|
||||||
import Field from "../../../elements/Field";
|
import Field from "../../../elements/Field";
|
||||||
import AccessibleButton from "../../../elements/AccessibleButton";
|
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||||
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
|
||||||
import * as sdk from "../../../../../index";
|
|
||||||
import Modal from "../../../../../Modal";
|
import Modal from "../../../../../Modal";
|
||||||
import { SettingLevel } from "../../../../../settings/SettingLevel";
|
import { SettingLevel } from "../../../../../settings/SettingLevel";
|
||||||
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
|
||||||
|
import SettingsFlag from '../../../elements/SettingsFlag';
|
||||||
|
import ErrorDialog from '../../../dialogs/ErrorDialog';
|
||||||
|
|
||||||
|
const getDefaultDevice = (devices: Array<Partial<MediaDeviceInfo>>) => {
|
||||||
|
// Note we're looking for a device with deviceId 'default' but adding a device
|
||||||
|
// with deviceId == the empty string: this is because Chrome gives us a device
|
||||||
|
// with deviceId 'default', so we're looking for this, not the one we are adding.
|
||||||
|
if (!devices.some((i) => i.deviceId === 'default')) {
|
||||||
|
devices.unshift({ deviceId: '', label: _t('Default Device') });
|
||||||
|
return '';
|
||||||
|
} else {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IState extends Record<MediaDeviceKindEnum, string> {
|
||||||
|
mediaDevices: IMediaDevices;
|
||||||
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.settings.tabs.user.VoiceUserSettingsTab")
|
@replaceableComponent("views.settings.tabs.user.VoiceUserSettingsTab")
|
||||||
export default class VoiceUserSettingsTab extends React.Component {
|
export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
|
||||||
constructor() {
|
constructor(props: {}) {
|
||||||
super();
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
mediaDevices: false,
|
mediaDevices: null,
|
||||||
activeAudioOutput: null,
|
[MediaDeviceKindEnum.AudioOutput]: null,
|
||||||
activeAudioInput: null,
|
[MediaDeviceKindEnum.AudioInput]: null,
|
||||||
activeVideoInput: null,
|
[MediaDeviceKindEnum.VideoInput]: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async componentDidMount() {
|
async componentDidMount() {
|
||||||
const canSeeDeviceLabels = await MediaDeviceHandler.hasAnyLabeledDevices();
|
const canSeeDeviceLabels = await MediaDeviceHandler.hasAnyLabeledDevices();
|
||||||
if (canSeeDeviceLabels) {
|
if (canSeeDeviceLabels) {
|
||||||
this._refreshMediaDevices();
|
this.refreshMediaDevices();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_refreshMediaDevices = async (stream) => {
|
private refreshMediaDevices = async (stream?: MediaStream): Promise<void> => {
|
||||||
this.setState({
|
this.setState({
|
||||||
mediaDevices: await MediaDeviceHandler.getDevices(),
|
mediaDevices: await MediaDeviceHandler.getDevices(),
|
||||||
activeAudioOutput: MediaDeviceHandler.getAudioOutput(),
|
[MediaDeviceKindEnum.AudioOutput]: MediaDeviceHandler.getAudioOutput(),
|
||||||
activeAudioInput: MediaDeviceHandler.getAudioInput(),
|
[MediaDeviceKindEnum.AudioInput]: MediaDeviceHandler.getAudioInput(),
|
||||||
activeVideoInput: MediaDeviceHandler.getVideoInput(),
|
[MediaDeviceKindEnum.VideoInput]: MediaDeviceHandler.getVideoInput(),
|
||||||
});
|
});
|
||||||
if (stream) {
|
if (stream) {
|
||||||
// kill stream (after we've enumerated the devices, otherwise we'd get empty labels again)
|
// kill stream (after we've enumerated the devices, otherwise we'd get empty labels again)
|
||||||
|
@ -62,7 +79,7 @@ export default class VoiceUserSettingsTab extends React.Component {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_requestMediaPermissions = async () => {
|
private requestMediaPermissions = async (): Promise<void> => {
|
||||||
let constraints;
|
let constraints;
|
||||||
let stream;
|
let stream;
|
||||||
let error;
|
let error;
|
||||||
|
@ -86,7 +103,6 @@ export default class VoiceUserSettingsTab extends React.Component {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.log("Failed to list userMedia devices", error);
|
console.log("Failed to list userMedia devices", error);
|
||||||
const brand = SdkConfig.get().brand;
|
const brand = SdkConfig.get().brand;
|
||||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
|
||||||
Modal.createTrackedDialog('No media permissions', '', ErrorDialog, {
|
Modal.createTrackedDialog('No media permissions', '', ErrorDialog, {
|
||||||
title: _t('No media permissions'),
|
title: _t('No media permissions'),
|
||||||
description: _t(
|
description: _t(
|
||||||
|
@ -95,137 +111,93 @@ export default class VoiceUserSettingsTab extends React.Component {
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this._refreshMediaDevices(stream);
|
this.refreshMediaDevices(stream);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_setAudioOutput = (e) => {
|
private setDevice = (deviceId: string, kind: MediaDeviceKindEnum): void => {
|
||||||
MediaDeviceHandler.instance.setAudioOutput(e.target.value);
|
MediaDeviceHandler.instance.setDevice(deviceId, kind);
|
||||||
this.setState({
|
this.setState<null>({ [kind]: deviceId });
|
||||||
activeAudioOutput: e.target.value,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_setAudioInput = (e) => {
|
private changeWebRtcMethod = (p2p: boolean): void => {
|
||||||
MediaDeviceHandler.instance.setAudioInput(e.target.value);
|
|
||||||
this.setState({
|
|
||||||
activeAudioInput: e.target.value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
_setVideoInput = (e) => {
|
|
||||||
MediaDeviceHandler.instance.setVideoInput(e.target.value);
|
|
||||||
this.setState({
|
|
||||||
activeVideoInput: e.target.value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
_changeWebRtcMethod = (p2p) => {
|
|
||||||
MatrixClientPeg.get().setForceTURN(!p2p);
|
MatrixClientPeg.get().setForceTURN(!p2p);
|
||||||
};
|
};
|
||||||
|
|
||||||
_changeFallbackICEServerAllowed = (allow) => {
|
private changeFallbackICEServerAllowed = (allow: boolean): void => {
|
||||||
MatrixClientPeg.get().setFallbackICEServerAllowed(allow);
|
MatrixClientPeg.get().setFallbackICEServerAllowed(allow);
|
||||||
};
|
};
|
||||||
|
|
||||||
_renderDeviceOptions(devices, category) {
|
private renderDeviceOptions(devices: Array<MediaDeviceInfo>, category: MediaDeviceKindEnum): Array<JSX.Element> {
|
||||||
return devices.map((d) => {
|
return devices.map((d) => {
|
||||||
return (<option key={`${category}-${d.deviceId}`} value={d.deviceId}>{d.label}</option>);
|
return (<option key={`${category}-${d.deviceId}`} value={d.deviceId}>{d.label}</option>);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
private renderDropdown(kind: MediaDeviceKindEnum, label: string): JSX.Element {
|
||||||
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
|
const devices = this.state.mediaDevices[kind].slice(0);
|
||||||
|
if (devices.length === 0) return null;
|
||||||
|
|
||||||
|
const defaultDevice = getDefaultDevice(devices);
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
element="select"
|
||||||
|
label={label}
|
||||||
|
value={this.state[kind] || defaultDevice}
|
||||||
|
onChange={(e) => this.setDevice(e.target.value, kind)}
|
||||||
|
>
|
||||||
|
{ this.renderDeviceOptions(devices, kind) }
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
let requestButton = null;
|
let requestButton = null;
|
||||||
let speakerDropdown = null;
|
let speakerDropdown = null;
|
||||||
let microphoneDropdown = null;
|
let microphoneDropdown = null;
|
||||||
let webcamDropdown = null;
|
let webcamDropdown = null;
|
||||||
if (this.state.mediaDevices === false) {
|
if (!this.state.mediaDevices) {
|
||||||
requestButton = (
|
requestButton = (
|
||||||
<div className='mx_VoiceUserSettingsTab_missingMediaPermissions'>
|
<div className='mx_VoiceUserSettingsTab_missingMediaPermissions'>
|
||||||
<p>{_t("Missing media permissions, click the button below to request.")}</p>
|
<p>{_t("Missing media permissions, click the button below to request.")}</p>
|
||||||
<AccessibleButton onClick={this._requestMediaPermissions} kind="primary">
|
<AccessibleButton onClick={this.requestMediaPermissions} kind="primary">
|
||||||
{_t("Request media permissions")}
|
{_t("Request media permissions")}
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (this.state.mediaDevices) {
|
} else if (this.state.mediaDevices) {
|
||||||
speakerDropdown = <p>{ _t('No Audio Outputs detected') }</p>;
|
speakerDropdown = (
|
||||||
microphoneDropdown = <p>{ _t('No Microphones detected') }</p>;
|
this.renderDropdown(MediaDeviceKindEnum.AudioOutput, _t("Audio Output")) ||
|
||||||
webcamDropdown = <p>{ _t('No Webcams detected') }</p>;
|
<p>{ _t('No Audio Outputs detected') }</p>
|
||||||
|
);
|
||||||
const defaultOption = {
|
microphoneDropdown = (
|
||||||
deviceId: '',
|
this.renderDropdown(MediaDeviceKindEnum.AudioInput, _t("Microphone")) ||
|
||||||
label: _t('Default Device'),
|
<p>{ _t('No Microphones detected') }</p>
|
||||||
};
|
);
|
||||||
const getDefaultDevice = (devices) => {
|
webcamDropdown = (
|
||||||
// Note we're looking for a device with deviceId 'default' but adding a device
|
this.renderDropdown(MediaDeviceKindEnum.VideoInput, _t("Camera")) ||
|
||||||
// with deviceId == the empty string: this is because Chrome gives us a device
|
<p>{ _t('No Webcams detected') }</p>
|
||||||
// with deviceId 'default', so we're looking for this, not the one we are adding.
|
);
|
||||||
if (!devices.some((i) => i.deviceId === 'default')) {
|
|
||||||
devices.unshift(defaultOption);
|
|
||||||
return '';
|
|
||||||
} else {
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const audioOutputs = this.state.mediaDevices.audioOutput.slice(0);
|
|
||||||
if (audioOutputs.length > 0) {
|
|
||||||
const defaultDevice = getDefaultDevice(audioOutputs);
|
|
||||||
speakerDropdown = (
|
|
||||||
<Field element="select" label={_t("Audio Output")}
|
|
||||||
value={this.state.activeAudioOutput || defaultDevice}
|
|
||||||
onChange={this._setAudioOutput}>
|
|
||||||
{this._renderDeviceOptions(audioOutputs, 'audioOutput')}
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioInputs = this.state.mediaDevices.audioInput.slice(0);
|
|
||||||
if (audioInputs.length > 0) {
|
|
||||||
const defaultDevice = getDefaultDevice(audioInputs);
|
|
||||||
microphoneDropdown = (
|
|
||||||
<Field element="select" label={_t("Microphone")}
|
|
||||||
value={this.state.activeAudioInput || defaultDevice}
|
|
||||||
onChange={this._setAudioInput}>
|
|
||||||
{this._renderDeviceOptions(audioInputs, 'audioInput')}
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoInputs = this.state.mediaDevices.videoInput.slice(0);
|
|
||||||
if (videoInputs.length > 0) {
|
|
||||||
const defaultDevice = getDefaultDevice(videoInputs);
|
|
||||||
webcamDropdown = (
|
|
||||||
<Field element="select" label={_t("Camera")}
|
|
||||||
value={this.state.activeVideoInput || defaultDevice}
|
|
||||||
onChange={this._setVideoInput}>
|
|
||||||
{this._renderDeviceOptions(videoInputs, 'videoInput')}
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_SettingsTab mx_VoiceUserSettingsTab">
|
<div className="mx_SettingsTab mx_VoiceUserSettingsTab">
|
||||||
<div className="mx_SettingsTab_heading">{_t("Voice & Video")}</div>
|
<div className="mx_SettingsTab_heading">{_t("Voice & Video")}</div>
|
||||||
<div className="mx_SettingsTab_section">
|
<div className="mx_SettingsTab_section">
|
||||||
{requestButton}
|
{ requestButton }
|
||||||
{speakerDropdown}
|
{ speakerDropdown }
|
||||||
{microphoneDropdown}
|
{ microphoneDropdown }
|
||||||
{webcamDropdown}
|
{ webcamDropdown }
|
||||||
<SettingsFlag name='VideoView.flipVideoHorizontally' level={SettingLevel.ACCOUNT} />
|
<SettingsFlag name='VideoView.flipVideoHorizontally' level={SettingLevel.ACCOUNT} />
|
||||||
<SettingsFlag
|
<SettingsFlag
|
||||||
name='webRtcAllowPeerToPeer'
|
name='webRtcAllowPeerToPeer'
|
||||||
level={SettingLevel.DEVICE}
|
level={SettingLevel.DEVICE}
|
||||||
onChange={this._changeWebRtcMethod}
|
onChange={this.changeWebRtcMethod}
|
||||||
/>
|
/>
|
||||||
<SettingsFlag
|
<SettingsFlag
|
||||||
name='fallbackICEServerAllowed'
|
name='fallbackICEServerAllowed'
|
||||||
level={SettingLevel.DEVICE}
|
level={SettingLevel.DEVICE}
|
||||||
onChange={this._changeFallbackICEServerAllowed}
|
onChange={this.changeFallbackICEServerAllowed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -56,9 +56,21 @@ export enum Action {
|
||||||
CheckUpdates = "check_updates",
|
CheckUpdates = "check_updates",
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Focuses the user's cursor to the composer. No additional payload information required.
|
* Focuses the user's cursor to the send message composer. No additional payload information required.
|
||||||
*/
|
*/
|
||||||
FocusComposer = "focus_composer",
|
FocusSendMessageComposer = "focus_send_message_composer",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focuses the user's cursor to the edit message composer. No additional payload information required.
|
||||||
|
*/
|
||||||
|
FocusEditMessageComposer = "focus_edit_message_composer",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focuses the user's cursor to the edit message composer or send message
|
||||||
|
* composer based on the current edit state. No additional payload
|
||||||
|
* information required.
|
||||||
|
*/
|
||||||
|
FocusAComposer = "focus_a_composer",
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the user menu (previously known as the top left menu). No additional payload information required.
|
* Opens the user menu (previously known as the top left menu). No additional payload information required.
|
||||||
|
|
|
@ -1364,17 +1364,17 @@
|
||||||
"Where you’re logged in": "Where you’re logged in",
|
"Where you’re logged in": "Where you’re logged in",
|
||||||
"Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.": "Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.",
|
"Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.": "Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.",
|
||||||
"A session's public name is visible to people you communicate with": "A session's public name is visible to people you communicate with",
|
"A session's public name is visible to people you communicate with": "A session's public name is visible to people you communicate with",
|
||||||
|
"Default Device": "Default Device",
|
||||||
"No media permissions": "No media permissions",
|
"No media permissions": "No media permissions",
|
||||||
"You may need to manually permit %(brand)s to access your microphone/webcam": "You may need to manually permit %(brand)s to access your microphone/webcam",
|
"You may need to manually permit %(brand)s to access your microphone/webcam": "You may need to manually permit %(brand)s to access your microphone/webcam",
|
||||||
"Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.",
|
"Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.",
|
||||||
"Request media permissions": "Request media permissions",
|
"Request media permissions": "Request media permissions",
|
||||||
"No Audio Outputs detected": "No Audio Outputs detected",
|
|
||||||
"No Microphones detected": "No Microphones detected",
|
|
||||||
"No Webcams detected": "No Webcams detected",
|
|
||||||
"Default Device": "Default Device",
|
|
||||||
"Audio Output": "Audio Output",
|
"Audio Output": "Audio Output",
|
||||||
|
"No Audio Outputs detected": "No Audio Outputs detected",
|
||||||
"Microphone": "Microphone",
|
"Microphone": "Microphone",
|
||||||
|
"No Microphones detected": "No Microphones detected",
|
||||||
"Camera": "Camera",
|
"Camera": "Camera",
|
||||||
|
"No Webcams detected": "No Webcams detected",
|
||||||
"Voice & Video": "Voice & Video",
|
"Voice & Video": "Voice & Video",
|
||||||
"This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers",
|
"This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers",
|
||||||
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.",
|
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue