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 = + {_t("Transfer")} + ; + } + return {holdUnholdCaption} + {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.",