Merge pull request #6340 from SimonBrandner/feature/media-devices
This commit is contained in:
commit
a1c3f25fe5
4 changed files with 104 additions and 127 deletions
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,118 +111,74 @@ 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>;
|
|
||||||
microphoneDropdown = <p>{ _t('No Microphones detected') }</p>;
|
|
||||||
webcamDropdown = <p>{ _t('No Webcams detected') }</p>;
|
|
||||||
|
|
||||||
const defaultOption = {
|
|
||||||
deviceId: '',
|
|
||||||
label: _t('Default Device'),
|
|
||||||
};
|
|
||||||
const getDefaultDevice = (devices) => {
|
|
||||||
// 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(defaultOption);
|
|
||||||
return '';
|
|
||||||
} else {
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const audioOutputs = this.state.mediaDevices.audioOutput.slice(0);
|
|
||||||
if (audioOutputs.length > 0) {
|
|
||||||
const defaultDevice = getDefaultDevice(audioOutputs);
|
|
||||||
speakerDropdown = (
|
speakerDropdown = (
|
||||||
<Field element="select" label={_t("Audio Output")}
|
this.renderDropdown(MediaDeviceKindEnum.AudioOutput, _t("Audio Output")) ||
|
||||||
value={this.state.activeAudioOutput || defaultDevice}
|
<p>{ _t('No Audio Outputs detected') }</p>
|
||||||
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 = (
|
microphoneDropdown = (
|
||||||
<Field element="select" label={_t("Microphone")}
|
this.renderDropdown(MediaDeviceKindEnum.AudioInput, _t("Microphone")) ||
|
||||||
value={this.state.activeAudioInput || defaultDevice}
|
<p>{ _t('No Microphones detected') }</p>
|
||||||
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 = (
|
webcamDropdown = (
|
||||||
<Field element="select" label={_t("Camera")}
|
this.renderDropdown(MediaDeviceKindEnum.VideoInput, _t("Camera")) ||
|
||||||
value={this.state.activeVideoInput || defaultDevice}
|
<p>{ _t('No Webcams detected') }</p>
|
||||||
onChange={this._setVideoInput}>
|
|
||||||
{this._renderDeviceOptions(videoInputs, 'videoInput')}
|
|
||||||
</Field>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_SettingsTab mx_VoiceUserSettingsTab">
|
<div className="mx_SettingsTab mx_VoiceUserSettingsTab">
|
||||||
|
@ -220,12 +192,12 @@ export default class VoiceUserSettingsTab extends React.Component {
|
||||||
<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>
|
|
@ -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…
Reference in a new issue