diff --git a/res/css/_components.scss b/res/css/_components.scss
index d2000b0e23..bf121de03e 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -237,4 +237,6 @@
@import "./views/verification/_VerificationShowSas.scss";
@import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallView.scss";
+@import "./views/voip/_DialPad.scss";
+@import "./views/voip/_DialPadModal.scss";
@import "./views/voip/_VideoFeed.scss";
diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss
index b7759d265f..66e1b827d0 100644
--- a/res/css/views/rooms/_RoomList.scss
+++ b/res/css/views/rooms/_RoomList.scss
@@ -24,6 +24,9 @@ limitations under the License.
.mx_RoomList_iconExplore::before {
mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
}
+.mx_RoomList_iconDialpad::before {
+ mask-image: url('$(res)/img/element-icons/roomlist/dialpad.svg');
+}
.mx_RoomList_explorePrompt {
margin: 4px 12px 4px;
diff --git a/res/css/views/voip/_DialPad.scss b/res/css/views/voip/_DialPad.scss
new file mode 100644
index 0000000000..0c7bff0ce8
--- /dev/null
+++ b/res/css/views/voip/_DialPad.scss
@@ -0,0 +1,62 @@
+/*
+Copyright 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.
+*/
+
+.mx_DialPad {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+}
+
+.mx_DialPad_button {
+ width: 40px;
+ height: 40px;
+ background-color: $theme-button-bg-color;
+ border-radius: 40px;
+ font-size: 18px;
+ font-weight: 600;
+ text-align: center;
+ vertical-align: middle;
+ line-height: 40px;
+}
+
+.mx_DialPad_deleteButton, .mx_DialPad_dialButton {
+ &::before {
+ content: '';
+ display: inline-block;
+ height: 40px;
+ width: 40px;
+ vertical-align: middle;
+ mask-repeat: no-repeat;
+ mask-size: 20px;
+ mask-position: center;
+ background-color: $primary-bg-color;
+ }
+}
+
+.mx_DialPad_deleteButton {
+ background-color: $notice-primary-color;
+ &::before {
+ mask-image: url('$(res)/img/element-icons/call/delete.svg');
+ mask-position: 9px; // delete icon is right-heavy so have to be slightly to the left to look centered
+ }
+}
+
+.mx_DialPad_dialButton {
+ background-color: $accent-color;
+ &::before {
+ mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
+ }
+}
diff --git a/res/css/views/voip/_DialPadModal.scss b/res/css/views/voip/_DialPadModal.scss
new file mode 100644
index 0000000000..f9d7673a38
--- /dev/null
+++ b/res/css/views/voip/_DialPadModal.scss
@@ -0,0 +1,74 @@
+/*
+Copyright 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.
+*/
+
+.mx_Dialog_dialPadWrapper .mx_Dialog {
+ padding: 0px;
+}
+
+.mx_DialPadModal {
+ width: 192px;
+ height: 368px;
+}
+
+.mx_DialPadModal_header {
+ margin-top: 12px;
+ margin-left: 12px;
+ margin-right: 12px;
+}
+
+.mx_DialPadModal_title {
+ color: $muted-fg-color;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.mx_DialPadModal_cancel {
+ float: right;
+ mask: url('$(res)/img/feather-customised/cancel.svg');
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-size: cover;
+ width: 14px;
+ height: 14px;
+ background-color: $dialog-close-fg-color;
+ cursor: pointer;
+}
+
+.mx_DialPadModal_field {
+ border: none;
+ margin: 0px;
+}
+
+.mx_DialPadModal_field input {
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.mx_DialPadModal_dialPad {
+ margin-left: 16px;
+ margin-right: 16px;
+ margin-top: 16px;
+}
+
+.mx_DialPadModal_horizSep {
+ position: relative;
+ &::before {
+ content: '';
+ position: absolute;
+ width: 100%;
+ border-bottom: 1px solid $input-darker-bg-color;
+ }
+}
diff --git a/res/img/element-icons/call/delete.svg b/res/img/element-icons/call/delete.svg
new file mode 100644
index 0000000000..133bdad4ca
--- /dev/null
+++ b/res/img/element-icons/call/delete.svg
@@ -0,0 +1,10 @@
+
diff --git a/res/img/element-icons/roomlist/dialpad.svg b/res/img/element-icons/roomlist/dialpad.svg
new file mode 100644
index 0000000000..b51d4a4dc9
--- /dev/null
+++ b/res/img/element-icons/roomlist/dialpad.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx
index a8f121dfb9..e9ccd6ef20 100644
--- a/src/CallHandler.tsx
+++ b/src/CallHandler.tsx
@@ -83,6 +83,9 @@ import {UIFeature} from "./settings/UIFeature";
import { CallError } from "matrix-js-sdk/src/webrtc/call";
import { logger } from 'matrix-js-sdk/src/logger';
import DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker"
+import { Action } from './dispatcher/actions';
+
+const CHECK_PSTN_SUPPORT_ATTEMPTS = 3;
enum AudioID {
Ring = 'ringAudio',
@@ -120,6 +123,8 @@ export default class CallHandler {
private calls = new Map(); // roomId -> call
private audioPromises = new Map>();
private dispatcherRef: string = null;
+ private supportsPstnProtocol = null;
+ private pstnSupportCheckTimer: NodeJS.Timeout; // number actually because we're in the browser
static sharedInstance() {
if (!window.mxCallHandler) {
@@ -146,6 +151,8 @@ export default class CallHandler {
if (SettingsStore.getValue(UIFeature.Voip)) {
MatrixClientPeg.get().on('Call.incoming', this.onCallIncoming);
}
+
+ this.checkForPstnSupport(CHECK_PSTN_SUPPORT_ATTEMPTS);
}
stop() {
@@ -159,6 +166,33 @@ export default class CallHandler {
}
}
+ private async checkForPstnSupport(maxTries) {
+ try {
+ const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
+ if (protocols['im.vector.protocol.pstn'] !== undefined) {
+ this.supportsPstnProtocol = protocols['im.vector.protocol.pstn'];
+ } else if (protocols['m.protocol.pstn'] !== undefined) {
+ this.supportsPstnProtocol = protocols['m.protocol.pstn'];
+ } else {
+ this.supportsPstnProtocol = null;
+ }
+ dis.dispatch({action: Action.PstnSupportUpdated});
+ } catch (e) {
+ if (maxTries === 1) {
+ console.log("Failed to check for pstn protocol support and no retries remain: assuming no support", e);
+ } else {
+ console.log("Failed to check for pstn protocol support: will retry", e);
+ this.pstnSupportCheckTimer = setTimeout(() => {
+ this.checkForPstnSupport(maxTries - 1);
+ }, 10000);
+ }
+ }
+ }
+
+ getSupportsPstnProtocol() {
+ return this.supportsPstnProtocol;
+ }
+
private onCallIncoming = (call) => {
// we dispatch this synchronously to make sure that the event
// handlers on the call are set up immediately (so that if
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index 4a8d3cc718..62c729c422 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -80,6 +80,7 @@ import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityProt
import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore";
import {UIFeature} from "../../settings/UIFeature";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
+import DialPadModal from "../views/voip/DialPadModal";
/** constants for MatrixChat.state.view */
export enum Views {
@@ -703,6 +704,9 @@ export default class MatrixChat extends React.PureComponent {
this.state.resizeNotifier.notifyLeftHandleResized();
});
break;
+ case Action.OpenDialPad:
+ Modal.createTrackedDialog('Dial pad', '', DialPadModal, {}, "mx_Dialog_dialPadWrapper");
+ break;
case 'on_logged_in':
if (
!Lifecycle.isSoftLogout() &&
diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js
index 2889afc1fc..b4eb6c187b 100644
--- a/src/components/structures/NotificationPanel.js
+++ b/src/components/structures/NotificationPanel.js
@@ -39,7 +39,7 @@ class NotificationPanel extends React.Component {
const emptyState = (
{_t('You’re all caught up')}
-
{_t('You have no visible notifications in this room.')}
+
{_t('You have no visible notifications.')}
);
let content;
diff --git a/src/components/views/context_menus/CallContextMenu.tsx b/src/components/views/context_menus/CallContextMenu.tsx
index 336b72cebf..3557976326 100644
--- a/src/components/views/context_menus/CallContextMenu.tsx
+++ b/src/components/views/context_menus/CallContextMenu.tsx
@@ -20,6 +20,8 @@ import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import CallHandler from '../../../CallHandler';
+import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog';
+import Modal from '../../../Modal';
interface IProps extends IContextMenuProps {
call: MatrixCall;
@@ -46,14 +48,30 @@ export default class CallContextMenu extends React.Component {
this.props.onFinished();
}
+ onTransferClick = () => {
+ Modal.createTrackedDialog(
+ 'Transfer Call', '', InviteDialog, {kind: KIND_CALL_TRANSFER, call: this.props.call},
+ /*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
+ );
+ this.props.onFinished();
+ }
+
render() {
const holdUnholdCaption = this.props.call.isRemoteOnHold() ? _t("Resume") : _t("Hold");
const handler = this.props.call.isRemoteOnHold() ? this.onUnholdClick : this.onHoldClick;
+ let transferItem;
+ if (this.props.call.opponentCanBeTransferred()) {
+ transferItem = ;
+ }
+
return
+ {transferItem}
;
}
}
diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx
index 8ccbbe473c..5b936e822c 100644
--- a/src/components/views/dialogs/InviteDialog.tsx
+++ b/src/components/views/dialogs/InviteDialog.tsx
@@ -41,12 +41,14 @@ import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {Room} from "matrix-js-sdk/src/models/room";
+import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
export const KIND_DM = "dm";
export const KIND_INVITE = "invite";
+export const KIND_CALL_TRANSFER = "call_transfer";
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
@@ -310,6 +312,9 @@ interface IInviteDialogProps {
// The room ID this dialog is for. Only required for KIND_INVITE.
roomId: string,
+ // The call to transfer. Only required for KIND_CALL_TRANSFER.
+ call: MatrixCall,
+
// Initial value to populate the filter with
initialText: string,
}
@@ -345,6 +350,8 @@ export default class InviteDialog extends React.PureComponent {
+ this._convertFilter();
+ const targets = this._convertFilter();
+ const targetIds = targets.map(t => t.userId);
+ if (targetIds.length > 1) {
+ this.setState({
+ errorText: _t("A call can only be transferred to a single user."),
+ });
+ }
+
+ this.setState({busy: true});
+ try {
+ await this.props.call.transfer(targetIds[0]);
+ this.setState({busy: false});
+ this.props.onFinished();
+ } catch (e) {
+ this.setState({
+ busy: false,
+ errorText: _t("Failed to transfer call"),
+ });
+ }
+ };
+
_onKeyDown = (e) => {
if (this.state.busy) return;
const value = e.target.value.trim();
@@ -1217,7 +1247,7 @@ export default class InviteDialog extends React.PureComponent 0
diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx
index 6e677f2b01..2d6396c83f 100644
--- a/src/components/views/rooms/RoomList.tsx
+++ b/src/components/views/rooms/RoomList.tsx
@@ -46,6 +46,7 @@ import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
import AccessibleButton from "../elements/AccessibleButton";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
+import CallHandler from "../../../CallHandler";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
@@ -89,10 +90,44 @@ interface ITagAesthetics {
defaultHidden: boolean;
}
-const TAG_AESTHETICS: {
+interface ITagAestheticsMap {
// @ts-ignore - TS wants this to be a string but we know better
[tagId: TagID]: ITagAesthetics;
-} = {
+}
+
+// If we have no dialer support, we just show the create chat dialog
+const dmOnAddRoom = (dispatcher?: Dispatcher) => {
+ (dispatcher || defaultDispatcher).dispatch({action: 'view_create_chat'});
+};
+
+// If we have dialer support, show a context menu so the user can pick between
+// the dialer and the create chat dialog
+const dmAddRoomContextMenu = (onFinished: () => void) => {
+ return
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ onFinished();
+ defaultDispatcher.dispatch({action: "view_create_chat"});
+ }}
+ />
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ onFinished();
+ defaultDispatcher.fire(Action.OpenDialPad);
+ }}
+ />
+ ;
+};
+
+const TAG_AESTHETICS: ITagAestheticsMap = {
[DefaultTagID.Invite]: {
sectionLabel: _td("Invites"),
isInvite: true,
@@ -108,9 +143,8 @@ const TAG_AESTHETICS: {
isInvite: false,
defaultHidden: false,
addRoomLabel: _td("Start chat"),
- onAddRoom: (dispatcher?: Dispatcher) => {
- (dispatcher || defaultDispatcher).dispatch({action: 'view_create_chat'});
- },
+ // Either onAddRoom or addRoomContextMenu are set depending on whether we
+ // have dialer support.
},
[DefaultTagID.Untagged]: {
sectionLabel: _td("Rooms"),
@@ -178,6 +212,7 @@ function customTagAesthetics(tagId: TagID): ITagAesthetics {
export default class RoomList extends React.PureComponent {
private dispatcherRef;
private customTagStoreRef;
+ private tagAesthetics: ITagAestheticsMap;
constructor(props: IProps) {
super(props);
@@ -187,6 +222,10 @@ export default class RoomList extends React.PureComponent {
isNameFiltering: !!RoomListStore.instance.getFirstNameFilterCondition(),
};
+ // shallow-copy from the template as we need to make modifications to it
+ this.tagAesthetics = objectShallowClone(TAG_AESTHETICS);
+ this.updateDmAddRoomAction();
+
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
@@ -202,6 +241,17 @@ export default class RoomList extends React.PureComponent {
if (this.customTagStoreRef) this.customTagStoreRef.remove();
}
+ private updateDmAddRoomAction() {
+ const dmTagAesthetics = objectShallowClone(TAG_AESTHETICS[DefaultTagID.DM]);
+ if (CallHandler.sharedInstance().getSupportsPstnProtocol()) {
+ dmTagAesthetics.addRoomContextMenu = dmAddRoomContextMenu;
+ } else {
+ dmTagAesthetics.onAddRoom = dmOnAddRoom;
+ }
+
+ this.tagAesthetics[DefaultTagID.DM] = dmTagAesthetics;
+ }
+
private onAction = (payload: ActionPayload) => {
if (payload.action === Action.ViewRoomDelta) {
const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
@@ -214,6 +264,9 @@ export default class RoomList extends React.PureComponent {
show_room_tile: true, // to make sure the room gets scrolled into view
});
}
+ } else if (payload.action === Action.PstnSupportUpdated) {
+ this.updateDmAddRoomAction();
+ this.updateLists();
}
};
@@ -355,7 +408,7 @@ export default class RoomList extends React.PureComponent {
const aesthetics: ITagAesthetics = isCustomTag(orderedTagId)
? customTagAesthetics(orderedTagId)
- : TAG_AESTHETICS[orderedTagId];
+ : this.tagAesthetics[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
components.push( void;
+}
+
+class DialPadButton extends React.PureComponent {
+ onClick = () => {
+ this.props.onButtonPress(this.props.digit);
+ }
+
+ render() {
+ switch (this.props.kind) {
+ case DialPadButtonKind.Digit:
+ return
+ {this.props.digit}
+ ;
+ case DialPadButtonKind.Delete:
+ return ;
+ case DialPadButtonKind.Dial:
+ return ;
+ }
+ }
+}
+
+interface IProps {
+ onDigitPress: (string) => void;
+ onDeletePress: (string) => void;
+ onDialPress: (string) => void;
+}
+
+export default class Dialpad extends React.PureComponent {
+ render() {
+ const buttonNodes = [];
+
+ for (const button of BUTTONS) {
+ buttonNodes.push();
+ }
+
+ buttonNodes.push();
+ buttonNodes.push();
+
+ return
+ {buttonNodes}
+
;
+ }
+}
diff --git a/src/components/views/voip/DialPadModal.tsx b/src/components/views/voip/DialPadModal.tsx
new file mode 100644
index 0000000000..9f7e4140c9
--- /dev/null
+++ b/src/components/views/voip/DialPadModal.tsx
@@ -0,0 +1,111 @@
+/*
+Copyright 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 * as React from "react";
+import { ensureDMExists } from "../../../createRoom";
+import { _t } from "../../../languageHandler";
+import { MatrixClientPeg } from "../../../MatrixClientPeg";
+import AccessibleButton from "../elements/AccessibleButton";
+import Field from "../elements/Field";
+import DialPad from './DialPad';
+import dis from '../../../dispatcher/dispatcher';
+import Modal from "../../../Modal";
+import ErrorDialog from "../../views/dialogs/ErrorDialog";
+
+interface IProps {
+ onFinished: (boolean) => void;
+}
+
+interface IState {
+ value: string;
+}
+
+export default class DialpadModal extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ value: '',
+ }
+ }
+
+ onCancelClick = () => {
+ this.props.onFinished(false);
+ }
+
+ onChange = (ev) => {
+ this.setState({value: ev.target.value});
+ }
+
+ onFormSubmit = (ev) => {
+ ev.preventDefault();
+ this.onDialPress();
+ }
+
+ onDigitPress = (digit) => {
+ this.setState({value: this.state.value + digit});
+ }
+
+ onDeletePress = () => {
+ if (this.state.value.length === 0) return;
+ this.setState({value: this.state.value.slice(0, -1)});
+ }
+
+ onDialPress = async () => {
+ const results = await MatrixClientPeg.get().getThirdpartyUser('im.vector.protocol.pstn', {
+ 'm.id.phone': this.state.value,
+ });
+ if (!results || results.length === 0 || !results[0].userid) {
+ Modal.createTrackedDialog('', '', ErrorDialog, {
+ title: _t("Unable to look up phone number"),
+ description: _t("There was an error looking up the phone number"),
+ });
+ }
+ const userId = results[0].userid;
+
+ const roomId = await ensureDMExists(MatrixClientPeg.get(), userId);
+
+ dis.dispatch({
+ action: 'view_room',
+ room_id: roomId,
+ });
+
+ this.props.onFinished(true);
+ }
+
+ render() {
+ return
+
+
+ {_t("Dial pad")}
+
+
+
+
+
+
+
+
+
;
+ }
+}
diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts
index 6fb71df30d..ce27f9b289 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -94,4 +94,16 @@ export enum Action {
* Trigged after the phase of the right panel is set. Should be used with AfterRightPanelPhaseChangePayload.
*/
AfterRightPanelPhaseChange = "after_right_panel_phase_change",
+
+ /**
+ * Opens the modal dial pad
+ */
+ OpenDialPad = "open_dial_pad",
+
+ /**
+ * Fired when CallHandler has checked for PSTN protocol support
+ * payload: none
+ * XXX: Is an action the right thing for this?
+ */
+ PstnSupportUpdated = "pstn_support_updated",
}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 51757a89c1..af211f6b17 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -861,6 +861,9 @@
"Fill Screen": "Fill Screen",
"Return to call": "Return to call",
"%(name)s on hold": "%(name)s on hold",
+ "Unable to look up phone number": "Unable to look up phone number",
+ "There was an error looking up the phone number": "There was an error looking up the phone number",
+ "Dial pad": "Dial pad",
"Unknown caller": "Unknown caller",
"Incoming voice call": "Incoming voice call",
"Incoming video call": "Incoming video call",
@@ -1459,6 +1462,8 @@
"Hide Widgets": "Hide Widgets",
"Show Widgets": "Show Widgets",
"Search": "Search",
+ "Start a Conversation": "Start a Conversation",
+ "Open dial pad": "Open dial pad",
"Invites": "Invites",
"Favourites": "Favourites",
"People": "People",
@@ -2080,6 +2085,8 @@
"We couldn't create your DM. Please check the users you want to invite and try again.": "We couldn't create your DM. Please check the users you want to invite and try again.",
"Something went wrong trying to invite the users.": "Something went wrong trying to invite the users.",
"We couldn't invite those users. Please check the users you want to invite and try again.": "We couldn't invite those users. Please check the users you want to invite and try again.",
+ "A call can only be transferred to a single user.": "A call can only be transferred to a single user.",
+ "Failed to transfer call": "Failed to transfer call",
"Failed to find the following users": "Failed to find the following users",
"The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s",
"Recent Conversations": "Recent Conversations",
@@ -2093,6 +2100,7 @@
"Go": "Go",
"Invite someone using their name, email address, username (like ) or share this room.": "Invite someone using their name, email address, username (like ) or share this room.",
"Invite someone using their name, username (like ) or share this room.": "Invite someone using their name, username (like ) or share this room.",
+ "Transfer": "Transfer",
"a new master key signature": "a new master key signature",
"a new cross-signing key signature": "a new cross-signing key signature",
"a device cross-signing signature": "a device cross-signing signature",
@@ -2434,7 +2442,7 @@
"Create a new community": "Create a new community",
"Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.",
"You’re all caught up": "You’re all caught up",
- "You have no visible notifications in this room.": "You have no visible notifications in this room.",
+ "You have no visible notifications.": "You have no visible notifications.",
"%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.",
"%(brand)s failed to get the public room list.": "%(brand)s failed to get the public room list.",
"The homeserver may be unavailable or overloaded.": "The homeserver may be unavailable or overloaded.",