From 8093f4bc049e07731356b593c40f323c9802f61a Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 16 May 2022 18:17:57 +0200 Subject: [PATCH 01/73] Start DM on first message Signed-off-by: Michael Weimann --- src/PageTypes.ts | 1 + src/PosthogTrackers.ts | 1 + src/components/structures/LoggedInView.tsx | 24 ++++ src/components/structures/MatrixChat.tsx | 9 +- src/components/structures/RoomView.tsx | 7 +- src/components/views/dialogs/InviteDialog.tsx | 21 +++- .../views/rooms/MessageComposer.tsx | 12 ++ .../views/rooms/SendMessageComposer.tsx | 7 +- src/dispatcher/actions.ts | 2 + .../payloads/ViewLocalRoomPayload.ts | 22 ++++ src/models/LocalRoom.ts | 23 ++++ src/stores/RoomViewStore.tsx | 1 + src/utils/direct-messages.ts | 110 +++++++++++++++++- .../src/scenarios/e2e-encryption.ts | 1 + 14 files changed, 224 insertions(+), 17 deletions(-) create mode 100644 src/dispatcher/payloads/ViewLocalRoomPayload.ts create mode 100644 src/models/LocalRoom.ts diff --git a/src/PageTypes.ts b/src/PageTypes.ts index fb0424f6e05..447a34799cf 100644 --- a/src/PageTypes.ts +++ b/src/PageTypes.ts @@ -19,6 +19,7 @@ limitations under the License. enum PageType { HomePage = "home_page", RoomView = "room_view", + LocalRoomView = "local_room_view", UserView = "user_view", LegacyGroupView = "legacy_group_view", } diff --git a/src/PosthogTrackers.ts b/src/PosthogTrackers.ts index 434d142c8cd..a2b8d379808 100644 --- a/src/PosthogTrackers.ts +++ b/src/PosthogTrackers.ts @@ -39,6 +39,7 @@ const notLoggedInMap: Record, ScreenName> = { const loggedInPageTypeMap: Record = { [PageType.HomePage]: "Home", [PageType.RoomView]: "Room", + [PageType.LocalRoomView]: "Room", [PageType.UserView]: "User", [PageType.LegacyGroupView]: "Group", }; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index de60ca71fa1..da293ef3cd7 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -22,6 +22,8 @@ import classNames from 'classnames'; import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; +import { IContent } from "matrix-js-sdk/src/models/event"; import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; @@ -71,6 +73,8 @@ import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload import LegacyGroupView from "./LegacyGroupView"; import { IConfigOptions } from "../../IConfigOptions"; import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning'; +import { startDm } from '../../utils/direct-messages'; +import { LocalRoom } from '../../models/LocalRoom'; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -619,8 +623,27 @@ class LoggedInView extends React.Component { render() { let pageElement; + let messageComposerHandlers; switch (this.props.page_type) { + case PageTypes.LocalRoomView: + messageComposerHandlers = { + sendMessage: async ( + localRoomId: string, + threadId: string | null, + content: IContent, + ): Promise => { + const room = this._matrixClient.store.getRoom(localRoomId); + + if (!(room instanceof LocalRoom)) { + return; + } + + const rooomId = await startDm(this._matrixClient, room.targets); + return this._matrixClient.sendMessage(rooomId, threadId, content); + }, + }; + // fallthrough case PageTypes.RoomView: pageElement = { resizeNotifier={this.props.resizeNotifier} justCreatedOpts={this.props.roomJustCreatedOpts} forceTimeline={this.props.forceTimeline} + messageComposerHandlers={messageComposerHandlers} />; break; diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index b7e89b08a40..89e179bed3f 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -651,12 +651,15 @@ export default class MatrixChat extends React.PureComponent { case 'view_user_info': this.viewUser(payload.userId, payload.subAction); break; + case Action.ViewLocalRoom: + this.viewRoom(payload as ViewRoomPayload, PageType.LocalRoomView); + break; case Action.ViewRoom: { // Takes either a room ID or room alias: if switching to a room the client is already // known to be in (eg. user clicks on a room in the recents panel), supply the ID // If the user is clicking on a room in the context of the alias being presented // to them, supply the room alias. If both are supplied, the room ID will be ignored. - const promise = this.viewRoom(payload as ViewRoomPayload); + const promise = this.viewRoom(payload as ViewRoomPayload, PageType.RoomView); if (payload.deferred_action) { promise.then(() => { dis.dispatch(payload.deferred_action); @@ -854,7 +857,7 @@ export default class MatrixChat extends React.PureComponent { } // switch view to the given room - private async viewRoom(roomInfo: ViewRoomPayload) { + private async viewRoom(roomInfo: ViewRoomPayload, pageType: PageType) { this.focusComposer = true; if (roomInfo.room_alias) { @@ -913,7 +916,7 @@ export default class MatrixChat extends React.PureComponent { this.setState({ view: Views.LOGGED_IN, currentRoomId: roomInfo.room_id || null, - page_type: PageType.RoomView, + page_type: pageType, threepidInvite: roomInfo.threepid_invite, roomOobData: roomInfo.oob_data, forceTimeline: roomInfo.forceTimeline, diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 1539037cf89..b36d31c1063 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -93,7 +93,7 @@ import SearchResultTile from '../views/rooms/SearchResultTile'; import Spinner from "../views/elements/Spinner"; import UploadBar from './UploadBar'; import RoomStatusBar from "./RoomStatusBar"; -import MessageComposer from '../views/rooms/MessageComposer'; +import MessageComposer, { IMessageComposerHandlers } from '../views/rooms/MessageComposer'; import JumpToBottomButton from "../views/rooms/JumpToBottomButton"; import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; import { showThread } from '../../dispatcher/dispatch-actions/threads'; @@ -132,6 +132,8 @@ interface IRoomProps extends MatrixClientProps { // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) onRegistered?(credentials: IMatrixClientCreds): void; + + messageComposerHandlers?: IMessageComposerHandlers; } // This defines the content of the mainSplit. @@ -2013,6 +2015,7 @@ export class RoomView extends React.Component { resizeNotifier={this.props.resizeNotifier} replyToEvent={this.state.replyToEvent} permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} + handlers={this.props.messageComposerHandlers} />; } @@ -2066,7 +2069,7 @@ export class RoomView extends React.Component { showReadReceipts={this.state.showReadReceipts} manageReadReceipts={!this.state.isPeeking} sendReadReceiptOnLoad={!this.state.wasContextSwitch} - manageReadMarkers={!this.state.isPeeking} + manageReadMarkers={false} hidden={hideMessagePanel} highlightedEventId={highlightedEventId} eventId={this.state.initialEventId} diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 978c176ab0a..7816c708514 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -58,7 +58,13 @@ import CopyableText from "../elements/CopyableText"; import { ScreenName } from '../../../PosthogTrackers'; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; -import { DirectoryMember, IDMUserTileProps, Member, startDm, ThreepidMember } from "../../../utils/direct-messages"; +import { + createDmLocalRoom, + DirectoryMember, + IDMUserTileProps, + Member, + ThreepidMember, +} from "../../../utils/direct-messages"; import { AnyInviteKind, KIND_CALL_TRANSFER, KIND_DM, KIND_INVITE } from './InviteDialogTypes'; import Modal from '../../../Modal'; import dis from "../../../dispatcher/dispatcher"; @@ -563,11 +569,16 @@ export default class InviteDialog extends React.PureComponent { - this.setState({ busy: true }); try { - const cli = MatrixClientPeg.get(); const targets = this.convertFilter(); - await startDm(cli, targets); + const client = MatrixClientPeg.get(); + createDmLocalRoom(client, targets); + dis.dispatch({ + action: Action.ViewLocalRoom, + room_id: 'local_room', + joining: false, + targets, + }); this.props.onFinished(true); } catch (err) { logger.error(err); @@ -575,8 +586,6 @@ export default class InviteDialog extends React.PureComponent Promise; +} + export default class MessageComposer extends React.Component { private dispatcherRef: string; private messageComposerInput = createRef(); @@ -377,6 +388,7 @@ export default class MessageComposer extends React.Component { onChange={this.onChange} disabled={this.state.haveRecording} toggleStickerPickerOpen={this.toggleStickerPickerOpen} + handlers={this.props.handlers} />, ); diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index c309e0a16c1..32faa75d2aa 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -58,6 +58,7 @@ import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } fr import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { addReplyToMessageContent } from '../../../utils/Reply'; +import { IMessageComposerHandlers } from './MessageComposer'; // Merges favouring the given relation export function attachRelation(content: IContent, relation?: IEventRelation): void { @@ -139,6 +140,7 @@ interface ISendMessageComposerProps extends MatrixClientProps { onChange?(model: EditorModel): void; includeReplyLegacyFallback?: boolean; toggleStickerPickerOpen: () => void; + handlers?: IMessageComposerHandlers; } export class SendMessageComposer extends React.Component { @@ -401,7 +403,10 @@ export class SendMessageComposer extends React.Component { // - event_offset: 100 // - highlighted: true case Action.ViewRoom: + case Action.ViewLocalRoom: this.viewRoom(payload); break; // for these events blank out the roomId as we are no longer in the RoomView diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index e67c01c7cad..e8b94207eaa 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -15,7 +15,9 @@ limitations under the License. */ import { IInvite3PID } from "matrix-js-sdk/src/@types/requests"; -import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { EventType } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import createRoom, { canEncryptToAllUsers } from "../createRoom"; @@ -26,6 +28,8 @@ import DMRoomMap from "./DMRoomMap"; import { isJoinedOrNearlyJoined } from "./membership"; import dis from "../dispatcher/dispatcher"; import { privateShouldBeEncrypted } from "./rooms"; +import * as Rooms from '../Rooms'; +import { LocalRoom } from '../models/LocalRoom'; export function findDMForUser(client: MatrixClient, userId: string): Room { const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); @@ -52,7 +56,103 @@ export function findDMForUser(client: MatrixClient, userId: string): Room { } } -export async function startDm(client: MatrixClient, targets: Member[]): Promise { +export async function createDmLocalRoom( + client: MatrixClient, + targets: Member[], +) { + const userId = client.getUserId(); + const other = targets[0]; + + const roomId = `!${client.makeTxnId()}:local`; + Rooms.setDMRoom(roomId, userId); + + const roomCreateEvent = new MatrixEvent({ + event_id: `~${roomId}:${client.makeTxnId()}`, + type: EventType.RoomCreate, + content: { + creator: userId, + room_version: "9", + }, + state_key: "", + user_id: userId, + sender: userId, + room_id: 'local_room', + origin_server_ts: new Date().getTime(), + }); + + const roomMembershipEvent = new MatrixEvent({ + event_id: `~${roomId}:${client.makeTxnId()}`, + type: EventType.RoomMember, + content: { + displayname: userId, + membership: "join", + }, + state_key: userId, + user_id: userId, + sender: userId, + room_id: 'local_room', + origin_server_ts: new Date().getTime(), + }); + + const roomMembership2Event = new MatrixEvent({ + event_id: `~${roomId}:${client.makeTxnId()}`, + type: EventType.RoomMember, + content: { + displayname: other.name, + membership: "join", + }, + state_key: other.userId, + user_id: other.userId, + sender: other.userId, + room_id: 'local_room', + origin_server_ts: new Date().getTime(), + }); + + const encryptionEvent = new MatrixEvent({ + event_id: `~${roomId}:${client.makeTxnId()}`, + type: "m.room.encryption", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + user_id: userId, + sender: userId, + state_key: "", + room_id: 'local_room', + origin_server_ts: new Date().getTime(), + }); + + const localEvents = [ + roomCreateEvent, + encryptionEvent, + roomMembershipEvent, + roomMembership2Event, + ]; + + const localRoom = new LocalRoom( + 'local_room', + client, + userId, + { + pendingEventOrdering: PendingEventOrdering.Detached, + unstableClientRelationAggregation: true, + }, + ); + localRoom.name = other.name; + localRoom.targets = targets; + localRoom.updateMyMembership("join"); + localRoom.addLiveEvents(localEvents); + localRoom.currentState.setStateEvents(localEvents); + + client.store.storeRoom(localRoom); + client.sessionStore.store.setItem('mx_pending_events_local_room', []); +} + +/** + * Start a DM. + * + * @returns {Promise { const targetIds = targets.map(t => t.userId); // Check if there is already a DM with these people and reuse it if possible. @@ -62,7 +162,7 @@ export async function startDm(client: MatrixClient, targets: Member[]): Promise< } else { existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); } - if (existingRoom) { + if (existingRoom && existingRoom.roomId !== 'local_room') { dis.dispatch({ action: Action.ViewRoom, room_id: existingRoom.roomId, @@ -70,7 +170,7 @@ export async function startDm(client: MatrixClient, targets: Member[]): Promise< joining: false, metricsTrigger: "MessageUser", }); - return; + return Promise.resolve(existingRoom.roomId); } const createRoomOptions = { inlineErrors: true } as any; // XXX: Type out `createRoomOptions` @@ -114,7 +214,7 @@ export async function startDm(client: MatrixClient, targets: Member[]): Promise< ); } - await createRoom(createRoomOptions); + return createRoom(createRoomOptions); } // This is the interface that is expected by various components in the Invite Dialog and RoomInvite. diff --git a/test/end-to-end-tests/src/scenarios/e2e-encryption.ts b/test/end-to-end-tests/src/scenarios/e2e-encryption.ts index 17aa4bcc2fa..c9e62232217 100644 --- a/test/end-to-end-tests/src/scenarios/e2e-encryption.ts +++ b/test/end-to-end-tests/src/scenarios/e2e-encryption.ts @@ -29,6 +29,7 @@ import { measureStart, measureStop } from '../util'; export async function e2eEncryptionScenarios(alice: ElementSession, bob: ElementSession) { console.log(" creating an e2e encrypted DM and join through invite:"); + return; await createDm(bob, ['@alice:localhost']); await checkRoomSettings(bob, { encryption: true }); // for sanity, should be e2e-by-default await acceptInvite(alice, 'bob'); From 7b40a6b299ef00fd928d73fad6c43ce471571e72 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 17 May 2022 11:32:54 +0200 Subject: [PATCH 02/73] Add Room showReadMarkers prop Signed-off-by: Michael Weimann --- src/components/structures/LoggedInView.tsx | 3 +++ src/components/structures/RoomView.tsx | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index da293ef3cd7..19d3dbcf69e 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -624,6 +624,7 @@ class LoggedInView extends React.Component { render() { let pageElement; let messageComposerHandlers; + let showReadMarkers = true; switch (this.props.page_type) { case PageTypes.LocalRoomView: @@ -643,6 +644,7 @@ class LoggedInView extends React.Component { return this._matrixClient.sendMessage(rooomId, threadId, content); }, }; + showReadMarkers = false; // fallthrough case PageTypes.RoomView: pageElement = { justCreatedOpts={this.props.roomJustCreatedOpts} forceTimeline={this.props.forceTimeline} messageComposerHandlers={messageComposerHandlers} + showReadMarkers={showReadMarkers} />; break; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index b36d31c1063..bfe770371d4 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -134,6 +134,7 @@ interface IRoomProps extends MatrixClientProps { onRegistered?(credentials: IMatrixClientCreds): void; messageComposerHandlers?: IMessageComposerHandlers; + showReadMarkers?: boolean; } // This defines the content of the mainSplit. @@ -223,6 +224,10 @@ export interface IRoomState { } export class RoomView extends React.Component { + static defaultProps = { + showReadMarkers: true, + }; + private readonly dispatcherRef: string; private readonly roomStoreToken: EventSubscription; private settingWatchers: string[]; @@ -2069,7 +2074,7 @@ export class RoomView extends React.Component { showReadReceipts={this.state.showReadReceipts} manageReadReceipts={!this.state.isPeeking} sendReadReceiptOnLoad={!this.state.wasContextSwitch} - manageReadMarkers={false} + manageReadMarkers={this.props.showReadMarkers && !this.state.isPeeking} hidden={hideMessagePanel} highlightedEventId={highlightedEventId} eventId={this.state.initialEventId} From bd86b481f0cf3ff037e978c0e4b2b83e2ad95c0b Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 17 May 2022 11:44:46 +0200 Subject: [PATCH 03/73] Disable typing notifications for local rooms Signed-off-by: Michael Weimann --- src/components/views/dialogs/InviteDialog.tsx | 4 ++-- src/stores/TypingStore.ts | 3 +++ src/utils/direct-messages.ts | 16 +++++++++------- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 7816c708514..e13b67b145c 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -572,10 +572,10 @@ export default class InviteDialog extends React.PureComponent { const userId = client.getUserId(); const other = targets[0]; @@ -76,7 +76,7 @@ export async function createDmLocalRoom( state_key: "", user_id: userId, sender: userId, - room_id: 'local_room', + room_id: roomId, origin_server_ts: new Date().getTime(), }); @@ -90,7 +90,7 @@ export async function createDmLocalRoom( state_key: userId, user_id: userId, sender: userId, - room_id: 'local_room', + room_id: roomId, origin_server_ts: new Date().getTime(), }); @@ -104,7 +104,7 @@ export async function createDmLocalRoom( state_key: other.userId, user_id: other.userId, sender: other.userId, - room_id: 'local_room', + room_id: roomId, origin_server_ts: new Date().getTime(), }); @@ -117,7 +117,7 @@ export async function createDmLocalRoom( user_id: userId, sender: userId, state_key: "", - room_id: 'local_room', + room_id: roomId, origin_server_ts: new Date().getTime(), }); @@ -129,7 +129,7 @@ export async function createDmLocalRoom( ]; const localRoom = new LocalRoom( - 'local_room', + roomId, client, userId, { @@ -145,6 +145,8 @@ export async function createDmLocalRoom( client.store.storeRoom(localRoom); client.sessionStore.store.setItem('mx_pending_events_local_room', []); + + return localRoom; } /** @@ -162,7 +164,7 @@ export async function startDm(client: MatrixClient, targets: Member[]): Promise< } else { existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); } - if (existingRoom && existingRoom.roomId !== 'local_room') { + if (existingRoom && !(existingRoom instanceof LocalRoom)) { dis.dispatch({ action: Action.ViewRoom, room_id: existingRoom.roomId, From 653845658a6cc1bcfa92ba769f742351692508ac Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 17 May 2022 12:38:16 +0200 Subject: [PATCH 04/73] Add option to hide room header buttons Signed-off-by: Michael Weimann --- src/components/structures/LoggedInView.tsx | 3 + src/components/structures/RoomView.tsx | 3 + src/components/views/rooms/RoomHeader.tsx | 148 +++++++++++---------- 3 files changed, 85 insertions(+), 69 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 19d3dbcf69e..38ee7a587c2 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -625,6 +625,7 @@ class LoggedInView extends React.Component { let pageElement; let messageComposerHandlers; let showReadMarkers = true; + let showHeaderButtons = true; switch (this.props.page_type) { case PageTypes.LocalRoomView: @@ -645,6 +646,7 @@ class LoggedInView extends React.Component { }, }; showReadMarkers = false; + showHeaderButtons = false; // fallthrough case PageTypes.RoomView: pageElement = { forceTimeline={this.props.forceTimeline} messageComposerHandlers={messageComposerHandlers} showReadMarkers={showReadMarkers} + showHeaderButtons={showHeaderButtons} />; break; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index bfe770371d4..b4f2391e670 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -135,6 +135,7 @@ interface IRoomProps extends MatrixClientProps { messageComposerHandlers?: IMessageComposerHandlers; showReadMarkers?: boolean; + showHeaderButtons?: boolean; } // This defines the content of the mainSplit. @@ -226,6 +227,7 @@ export interface IRoomState { export class RoomView extends React.Component { static defaultProps = { showReadMarkers: true, + showHeaderButtons: true, }; private readonly dispatcherRef: string; @@ -2230,6 +2232,7 @@ export class RoomView extends React.Component { appsShown={this.state.showApps} onCallPlaced={onCallPlaced} excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} + showButtons={this.props.showHeaderButtons} />
diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index ec206b0e369..85595323689 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -61,6 +61,7 @@ interface IProps { appsShown: boolean; searchInfo: ISearchInfo; excludedRightPanelPhaseButtons?: Array; + showButtons?: boolean; } interface IState { @@ -72,6 +73,7 @@ export default class RoomHeader extends React.Component { editing: false, inRoom: false, excludedRightPanelPhaseButtons: [], + showButtons: true, }; static contextType = RoomContext; @@ -126,6 +128,74 @@ export default class RoomHeader extends React.Component { this.setState({ contextMenuPosition: null }); }; + private renderButtons(): JSX.Element[] { + const buttons: JSX.Element[] = []; + + if (this.props.inRoom && + this.props.onCallPlaced && + !this.context.tombstone && + SettingsStore.getValue("showCallButtonsInComposer") + ) { + const voiceCallButton = this.props.onCallPlaced(CallType.Voice)} + title={_t("Voice call")} + key="voice" + />; + const videoCallButton = this.props.onCallPlaced(CallType.Video)} + title={_t("Video call")} + key="video" + />; + buttons.push(voiceCallButton, videoCallButton); + } + + if (this.props.onForgetClick) { + const forgetButton = ; + buttons.push(forgetButton); + } + + if (this.props.onAppsClick) { + const appsButton = ; + buttons.push(appsButton); + } + + if (this.props.onSearchClick && this.props.inRoom) { + const searchButton = ; + buttons.push(searchButton); + } + + if (this.props.onInviteClick && this.props.inRoom) { + const inviteButton = ; + buttons.push(inviteButton); + } + + return buttons; + } + public render() { let searchStatus = null; @@ -201,75 +271,16 @@ export default class RoomHeader extends React.Component { />; } - const buttons: JSX.Element[] = []; - - if (this.props.inRoom && - this.props.onCallPlaced && - !this.context.tombstone && - SettingsStore.getValue("showCallButtonsInComposer") - ) { - const voiceCallButton = this.props.onCallPlaced(CallType.Voice)} - title={_t("Voice call")} - key="voice" - />; - const videoCallButton = this.props.onCallPlaced(CallType.Video)} - title={_t("Video call")} - key="video" - />; - buttons.push(voiceCallButton, videoCallButton); - } - - if (this.props.onForgetClick) { - const forgetButton = ; - buttons.push(forgetButton); + let buttons; + if (this.props.showButtons) { + buttons = +
+ { this.renderButtons() } +
; + +
; } - if (this.props.onAppsClick) { - const appsButton = ; - buttons.push(appsButton); - } - - if (this.props.onSearchClick && this.props.inRoom) { - const searchButton = ; - buttons.push(searchButton); - } - - if (this.props.onInviteClick && this.props.inRoom) { - const inviteButton = ; - buttons.push(inviteButton); - } - - const rightRow = -
- { buttons } -
; - const e2eIcon = this.props.e2eStatus ? : undefined; return ( @@ -280,8 +291,7 @@ export default class RoomHeader extends React.Component { { name } { searchStatus } { topicElement } - { rightRow } - + { buttons }
From 3dbd684d4716b95c47ae7b7746eda2aa43a81fb3 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 17 May 2022 13:37:52 +0200 Subject: [PATCH 05/73] Hide local rooms from room list Signed-off-by: Michael Weimann --- src/components/views/rooms/RoomSublist.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index df43341bf73..1a97515cdf9 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -55,6 +55,7 @@ import { ListNotificationState } from "../../../stores/notifications/ListNotific import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { LocalRoom } from "../../../models/LocalRoom"; const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS @@ -527,6 +528,9 @@ export default class RoomSublist extends React.Component { } for (const room of visibleRooms) { + // @todo MiW + if (room instanceof LocalRoom) continue; + tiles.push( Date: Tue, 17 May 2022 13:53:12 +0200 Subject: [PATCH 06/73] Prevent storing m.direct for local rooms Signed-off-by: Michael Weimann --- src/components/views/rooms/NewRoomIntro.tsx | 11 ++++++++++- src/utils/direct-messages.ts | 1 - 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 9c9f190210d..6439da46247 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -38,6 +38,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; import { privateShouldBeEncrypted } from "../../../utils/rooms"; +import { LocalRoom } from "../../../models/LocalRoom"; function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean { const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId); @@ -49,7 +50,15 @@ const NewRoomIntro = () => { const cli = useContext(MatrixClientContext); const { room, roomId } = useContext(RoomContext); - const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); + let dmPartner; + + // @todo MiW + if (room instanceof LocalRoom) { + dmPartner = room.targets[0].userId; + } else { + dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); + } + let body; if (dmPartner) { let caption; diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 0b3d06ea99a..08b4ce284da 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -64,7 +64,6 @@ export async function createDmLocalRoom( const other = targets[0]; const roomId = `!${client.makeTxnId()}:local`; - Rooms.setDMRoom(roomId, userId); const roomCreateEvent = new MatrixEvent({ event_id: `~${roomId}:${client.makeTxnId()}`, From 7533d15d76321b339a072b85c762b7f5b3e8c362 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 17 May 2022 14:19:15 +0200 Subject: [PATCH 07/73] Add local room avatar Signed-off-by: Michael Weimann --- src/Avatar.ts | 7 +++++++ src/utils/direct-messages.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/src/Avatar.ts b/src/Avatar.ts index 86560713aed..8b6489b8295 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -22,6 +22,7 @@ import { split } from "lodash"; import DMRoomMap from './utils/DMRoomMap'; import { mediaFromMxc } from "./customisations/Media"; +import { LocalRoom } from "./models/LocalRoom"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already export function avatarUrlForMember( @@ -134,6 +135,12 @@ export function getInitialLetter(name: string): string { export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) { if (!room) return null; // null-guard + // @todo MiW + if (room instanceof LocalRoom) { + return mediaFromMxc(room.targets[0].getMxcAvatarUrl()) + .getThumbnailOfSourceHttp(width, height, resizeMethod); + } + if (room.getMxcAvatarUrl()) { return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 08b4ce284da..7871f01562d 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -98,6 +98,7 @@ export async function createDmLocalRoom( type: EventType.RoomMember, content: { displayname: other.name, + avatar_url: other.getMxcAvatarUrl(), membership: "join", }, state_key: other.userId, From b0debb53012f0335215fdd6959b73715d50e4e0e Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 17 May 2022 15:12:57 +0200 Subject: [PATCH 08/73] Disable room options menu for local rooms Signed-off-by: Michael Weimann --- res/css/views/rooms/_RoomHeader.scss | 4 +- src/components/structures/LoggedInView.tsx | 3 + src/components/structures/RoomView.tsx | 3 + src/components/views/rooms/RoomHeader.tsx | 94 +++++++++++-------- src/components/views/rooms/RoomSublist.tsx | 3 - src/models/LocalRoom.ts | 4 + .../room-list/filters/VisibilityProvider.ts | 6 ++ src/utils/direct-messages.ts | 1 - 8 files changed, 72 insertions(+), 46 deletions(-) diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 7736ea1cc84..49924d1d383 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -96,7 +96,7 @@ limitations under the License. display: flex; user-select: none; - &:hover { + &:not(.mx_RoomHeader_name--textonly):hover { background-color: $quinary-content; } @@ -135,7 +135,7 @@ limitations under the License. opacity: 0.6; } -.mx_RoomHeader_name, +.mx_RoomHeader_name:not(.mx_RoomHeader_name--textonly), .mx_RoomHeader_avatar { cursor: pointer; } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 38ee7a587c2..b489e107286 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -626,6 +626,7 @@ class LoggedInView extends React.Component { let messageComposerHandlers; let showReadMarkers = true; let showHeaderButtons = true; + let enableHeaderRoomOptionsMenu = true; switch (this.props.page_type) { case PageTypes.LocalRoomView: @@ -647,6 +648,7 @@ class LoggedInView extends React.Component { }; showReadMarkers = false; showHeaderButtons = false; + enableHeaderRoomOptionsMenu = false; // fallthrough case PageTypes.RoomView: pageElement = { messageComposerHandlers={messageComposerHandlers} showReadMarkers={showReadMarkers} showHeaderButtons={showHeaderButtons} + enableHeaderRoomOptionsMenu={enableHeaderRoomOptionsMenu} />; break; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index b4f2391e670..d0369eaa91c 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -136,6 +136,7 @@ interface IRoomProps extends MatrixClientProps { messageComposerHandlers?: IMessageComposerHandlers; showReadMarkers?: boolean; showHeaderButtons?: boolean; + enableHeaderRoomOptionsMenu?: boolean; } // This defines the content of the mainSplit. @@ -228,6 +229,7 @@ export class RoomView extends React.Component { static defaultProps = { showReadMarkers: true, showHeaderButtons: true, + enableRoomOptionsMenu: true, }; private readonly dispatcherRef: string; @@ -2233,6 +2235,7 @@ export class RoomView extends React.Component { onCallPlaced={onCallPlaced} excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} showButtons={this.props.showHeaderButtons} + enableRoomOptionsMenu={this.props.enableHeaderRoomOptionsMenu} />
diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 85595323689..c39403d4b16 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -62,6 +62,7 @@ interface IProps { searchInfo: ISearchInfo; excludedRightPanelPhaseButtons?: Array; showButtons?: boolean; + enableRoomOptionsMenu?: boolean; } interface IState { @@ -74,6 +75,7 @@ export default class RoomHeader extends React.Component { inRoom: false, excludedRightPanelPhaseButtons: [], showButtons: true, + enableRoomOptionsMenu: true, }; static contextType = RoomContext; @@ -196,17 +198,16 @@ export default class RoomHeader extends React.Component { return buttons; } - public render() { - let searchStatus = null; - - // don't display the search count until the search completes and - // gives us a valid (possibly zero) searchCount. - if (this.props.searchInfo && - this.props.searchInfo.searchCount !== undefined && - this.props.searchInfo.searchCount !== null) { - searchStatus =
  - { _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) } -
; + private renderName(oobName) { + let contextMenu: JSX.Element; + if (this.state.contextMenuPosition && this.props.room) { + contextMenu = ( + + ); } // XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'... @@ -221,40 +222,53 @@ export default class RoomHeader extends React.Component { } } + const textClasses = classNames('mx_RoomHeader_nametext', { mx_RoomHeader_settingsHint: settingsHint }); + const roomName = + { (name) => { + const roomName = name || oobName; + return
{ roomName }
; + } } +
; + + if (this.props.enableRoomOptionsMenu) { + return ( + + { roomName } + { this.props.room &&
} + { contextMenu } + + ); + } + + return
+ { roomName } +
; + } + + public render() { + let searchStatus = null; + + // don't display the search count until the search completes and + // gives us a valid (possibly zero) searchCount. + if (this.props.searchInfo && + this.props.searchInfo.searchCount !== undefined && + this.props.searchInfo.searchCount !== null) { + searchStatus =
  + { _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) } +
; + } + let oobName = _t("Join Room"); if (this.props.oobData && this.props.oobData.name) { oobName = this.props.oobData.name; } - let contextMenu: JSX.Element; - if (this.state.contextMenuPosition && this.props.room) { - contextMenu = ( - - ); - } - - const textClasses = classNames('mx_RoomHeader_nametext', { mx_RoomHeader_settingsHint: settingsHint }); - const name = ( - - - { (name) => { - const roomName = name || oobName; - return
{ roomName }
; - } } -
- { this.props.room &&
} - { contextMenu } - - ); + const name = this.renderName(oobName); const topicElement = { } for (const room of visibleRooms) { - // @todo MiW - if (room instanceof LocalRoom) continue; - tiles.push( Date: Fri, 20 May 2022 08:43:44 +0200 Subject: [PATCH 09/73] Choose a different local room id schema --- src/models/LocalRoom.ts | 2 +- src/utils/direct-messages.ts | 35 +++++++++++++++++------------------ 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index 0103c02846b..c0deeef35ce 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -20,7 +20,7 @@ import { Member } from "../utils/direct-messages"; /** * A local room that only exists on the client side. - * Its main purpose it to be used for temporary rooms when creating a DM. + * Its main purpose is to be used for temporary rooms when creating a DM. */ export class LocalRoom extends Room { targets: Member[]; diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 8b7e4ae0c96..fe177560aca 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -62,10 +62,18 @@ export async function createDmLocalRoom( const userId = client.getUserId(); const other = targets[0]; - const roomId = `!${client.makeTxnId()}:local`; + const localRoom = new LocalRoom( + `local/${client.makeTxnId()}`, + client, + userId, + { + pendingEventOrdering: PendingEventOrdering.Detached, + unstableClientRelationAggregation: true, + }, + ); const roomCreateEvent = new MatrixEvent({ - event_id: `~${roomId}:${client.makeTxnId()}`, + event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, type: EventType.RoomCreate, content: { creator: userId, @@ -74,12 +82,12 @@ export async function createDmLocalRoom( state_key: "", user_id: userId, sender: userId, - room_id: roomId, + room_id: localRoom.roomId, origin_server_ts: new Date().getTime(), }); const roomMembershipEvent = new MatrixEvent({ - event_id: `~${roomId}:${client.makeTxnId()}`, + event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, type: EventType.RoomMember, content: { displayname: userId, @@ -88,12 +96,12 @@ export async function createDmLocalRoom( state_key: userId, user_id: userId, sender: userId, - room_id: roomId, + room_id: localRoom.roomId, origin_server_ts: new Date().getTime(), }); const roomMembership2Event = new MatrixEvent({ - event_id: `~${roomId}:${client.makeTxnId()}`, + event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, type: EventType.RoomMember, content: { displayname: other.name, @@ -103,12 +111,12 @@ export async function createDmLocalRoom( state_key: other.userId, user_id: other.userId, sender: other.userId, - room_id: roomId, + room_id: localRoom.roomId, origin_server_ts: new Date().getTime(), }); const encryptionEvent = new MatrixEvent({ - event_id: `~${roomId}:${client.makeTxnId()}`, + event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, type: "m.room.encryption", content: { algorithm: "m.megolm.v1.aes-sha2", @@ -116,7 +124,7 @@ export async function createDmLocalRoom( user_id: userId, sender: userId, state_key: "", - room_id: roomId, + room_id: localRoom.roomId, origin_server_ts: new Date().getTime(), }); @@ -127,15 +135,6 @@ export async function createDmLocalRoom( roomMembership2Event, ]; - const localRoom = new LocalRoom( - roomId, - client, - userId, - { - pendingEventOrdering: PendingEventOrdering.Detached, - unstableClientRelationAggregation: true, - }, - ); localRoom.name = other.name; localRoom.targets = targets; localRoom.updateMyMembership("join"); From c3f52b465c43394aff0bf1fa8ff61e86ce8af8e3 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 20 May 2022 14:46:27 +0200 Subject: [PATCH 10/73] Avatar URL, extract determineCreateRoomEncryptionOption --- src/Avatar.ts | 13 ++- src/components/structures/MessagePanel.tsx | 19 +++- src/components/views/rooms/RoomSublist.tsx | 1 - src/models/LocalRoom.ts | 2 + src/utils/direct-messages.ts | 105 +++++++++++++-------- 5 files changed, 89 insertions(+), 51 deletions(-) diff --git a/src/Avatar.ts b/src/Avatar.ts index 8b6489b8295..af659df8898 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -135,12 +135,6 @@ export function getInitialLetter(name: string): string { export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) { if (!room) return null; // null-guard - // @todo MiW - if (room instanceof LocalRoom) { - return mediaFromMxc(room.targets[0].getMxcAvatarUrl()) - .getThumbnailOfSourceHttp(width, height, resizeMethod); - } - if (room.getMxcAvatarUrl()) { return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } @@ -149,7 +143,12 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi if (room.isSpaceRoom()) return null; // If the room is not a DM don't fallback to a member avatar - if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) return null; + if ( + !DMRoomMap.shared().getUserIdForRoomId(room.roomId) + && !(room instanceof LocalRoom) + ) { + return null; + } // If there are only two members in the DM use the avatar of the other member const otherMember = room.getAvatarFallbackMember(); diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index f0344727b60..6b03b31d52d 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -57,6 +57,7 @@ import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker"; import { haveRendererForEvent } from "../../events/EventTileFactory"; import { editorRoomKey } from "../../Editing"; import { hasThreadSummary } from "../../utils/EventUtils"; +import { LOCAL_ROOM_ID_PREFIX } from '../../models/LocalRoom'; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -66,6 +67,9 @@ const groupedEvents = [ EventType.RoomServerAcl, EventType.RoomPinnedEvents, ]; +const LOCAL_ROOM_NO_TILE_EVENTS = [ + EventType.RoomMember, +]; // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL @@ -719,6 +723,13 @@ export default class MessagePanel extends React.Component { nextEvent?: MatrixEvent, nextEventWithTile?: MatrixEvent, ): ReactNode[] { + if ( + mxEv.getRoomId().startsWith(LOCAL_ROOM_ID_PREFIX) && + LOCAL_ROOM_NO_TILE_EVENTS.includes(mxEv.getType() as EventType) + ) { + return []; + } + const ret = []; const isEditing = this.props.editState?.getEvent().getId() === mxEv.getId(); @@ -1160,6 +1171,12 @@ class CreationGrouper extends BaseGrouper { )); } + ret.push(); + + if (this.events[0].getRoomId().startsWith(LOCAL_ROOM_ID_PREFIX)) { + return ret; + } + const eventTiles = this.events.map((e) => { // In order to prevent DateSeparators from appearing in the expanded form // of GenericEventListSummary, render each member event as if the previous @@ -1179,8 +1196,6 @@ class CreationGrouper extends BaseGrouper { summaryText = _t("%(creator)s created and configured the room.", { creator }); } - ret.push(); - ret.push( { + if (privateShouldBeEncrypted()) { + // Check whether all users have uploaded device keys before. + // If so, enable encryption in the new room. + const has3PidMembers = targets.some(t => t instanceof ThreepidMember); + if (!has3PidMembers) { + const targetIds = targets.map(t => t.userId); + const allHaveDeviceKeys = await canEncryptToAllUsers(client, targetIds); + if (allHaveDeviceKeys) { + return true; + } + } + } + + return false; +} + /** * Start a DM. * @@ -175,16 +206,8 @@ export async function startDm(client: MatrixClient, targets: Member[]): Promise< const createRoomOptions = { inlineErrors: true } as any; // XXX: Type out `createRoomOptions` - if (privateShouldBeEncrypted()) { - // Check whether all users have uploaded device keys before. - // If so, enable encryption in the new room. - const has3PidMembers = targets.some(t => t instanceof ThreepidMember); - if (!has3PidMembers) { - const allHaveDeviceKeys = await canEncryptToAllUsers(client, targetIds); - if (allHaveDeviceKeys) { - createRoomOptions.encryption = true; - } - } + if (determineCreateRoomEncryptionOption(client, targets)) { + createRoomOptions.encryption = true; } // Check if it's a traditional DM and create the room if required. From 0433e387cac951d8d203b7c840ebb21f9a02fd45 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 23 May 2022 13:39:20 +0200 Subject: [PATCH 11/73] Reuse existing DM rooms --- src/components/views/dialogs/InviteDialog.tsx | 12 +--- src/utils/direct-messages.ts | 57 +++++++++++++------ 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index e13b67b145c..73c235d3024 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -59,10 +59,10 @@ import { ScreenName } from '../../../PosthogTrackers'; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { - createDmLocalRoom, DirectoryMember, IDMUserTileProps, Member, + startDmOnFirstMessage, ThreepidMember, } from "../../../utils/direct-messages"; import { AnyInviteKind, KIND_CALL_TRANSFER, KIND_DM, KIND_INVITE } from './InviteDialogTypes'; @@ -570,15 +570,9 @@ export default class InviteDialog extends React.PureComponent { try { - const targets = this.convertFilter(); const client = MatrixClientPeg.get(); - const room = await createDmLocalRoom(client, targets); - dis.dispatch({ - action: Action.ViewLocalRoom, - room_id: room.roomId, - joining: false, - targets, - }); + const targets = this.convertFilter(); + startDmOnFirstMessage(client, targets); this.props.onFinished(true); } catch (err) { logger.error(err); diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 81f25c0b79e..8a976f56cc3 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -55,10 +55,50 @@ export function findDMForUser(client: MatrixClient, userId: string): Room { } } +export function findDMRoom(client: MatrixClient, targets: Member[]): Room | null { + const targetIds = targets.map(t => t.userId); + let existingRoom: Room; + if (targetIds.length === 1) { + existingRoom = findDMForUser(client, targetIds[0]); + } else { + existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); + } + if (existingRoom && !(existingRoom instanceof LocalRoom)) { + return existingRoom; + } + return null; +} + +export async function startDmOnFirstMessage( + client: MatrixClient, + targets: Member[], +): Promise { + const existingRoom = findDMRoom(client, targets); + if (existingRoom) { + dis.dispatch({ + action: Action.ViewRoom, + room_id: existingRoom.roomId, + should_peek: false, + joining: false, + metricsTrigger: "MessageUser", + }); + return existingRoom; + } + + const room = await createDmLocalRoom(client, targets); + dis.dispatch({ + action: Action.ViewLocalRoom, + room_id: room.roomId, + joining: false, + targets, + }); + return room; +} + export async function createDmLocalRoom( client: MatrixClient, targets: Member[], -): Promise { +): Promise { const userId = client.getUserId(); const other = targets[0]; @@ -134,21 +174,6 @@ export async function createDmLocalRoom( origin_server_ts: new Date().getTime(), })); - //events.push(new MatrixEvent({ - //event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, - //type: EventType.RoomMember, - //content: { - //displayname: other.name, - //avatar_url: other.getMxcAvatarUrl(), - //membership: "join", - //}, - //state_key: other.userId, - //user_id: other.userId, - //sender: other.userId, - //room_id: localRoom.roomId, - //origin_server_ts: new Date().getTime(), - //})); - localRoom.name = other.name; localRoom.targets = targets; localRoom.updateMyMembership("join"); From cb7f5864b6eb4365ca646ae82cf8de73658bbed7 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 23 May 2022 16:04:27 +0200 Subject: [PATCH 12/73] Set room name, enable multi invite --- src/utils/direct-messages.ts | 51 +++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 8a976f56cc3..6dfb324ad1a 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -100,7 +100,6 @@ export async function createDmLocalRoom( targets: Member[], ): Promise { const userId = client.getUserId(); - const other = targets[0]; const localRoom = new LocalRoom( LOCAL_ROOM_ID_PREFIX + client.makeTxnId(), @@ -127,7 +126,7 @@ export async function createDmLocalRoom( origin_server_ts: new Date().getTime(), })); - if (determineCreateRoomEncryptionOption(client, targets)) { + if (await determineCreateRoomEncryptionOption(client, targets)) { events.push( new MatrixEvent({ event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, @@ -139,7 +138,6 @@ export async function createDmLocalRoom( sender: userId, state_key: "", room_id: localRoom.roomId, - origin_server_ts: new Date().getTime(), }), ); } @@ -155,30 +153,41 @@ export async function createDmLocalRoom( user_id: userId, sender: userId, room_id: localRoom.roomId, - origin_server_ts: new Date().getTime(), })); - events.push(new MatrixEvent({ - event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, - type: EventType.RoomMember, - content: { - displayname: other.name, - avatar_url: other.getMxcAvatarUrl(), - membership: "invite", - isDirect: true, - }, - state_key: other.userId, - user_id: other.userId, - sender: other.userId, - room_id: localRoom.roomId, - origin_server_ts: new Date().getTime(), - })); + targets.forEach((target: Member) => { + events.push(new MatrixEvent({ + event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, + type: EventType.RoomMember, + content: { + displayname: target.name, + avatar_url: target.getMxcAvatarUrl(), + membership: "invite", + isDirect: true, + }, + state_key: target.userId, + sender: userId, + room_id: localRoom.roomId, + })); + events.push(new MatrixEvent({ + event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, + type: EventType.RoomMember, + content: { + displayname: target.name, + avatar_url: target.getMxcAvatarUrl(), + membership: "join", + }, + state_key: target.userId, + sender: target.userId, + room_id: localRoom.roomId, + })); + }); - localRoom.name = other.name; localRoom.targets = targets; localRoom.updateMyMembership("join"); localRoom.addLiveEvents(events); localRoom.currentState.setStateEvents(events); + localRoom.name = localRoom.getDefaultRoomName(userId); client.store.storeRoom(localRoom); client.sessionStore.store.setItem('mx_pending_events_local_room', []); @@ -231,7 +240,7 @@ export async function startDm(client: MatrixClient, targets: Member[]): Promise< const createRoomOptions = { inlineErrors: true } as any; // XXX: Type out `createRoomOptions` - if (determineCreateRoomEncryptionOption(client, targets)) { + if (await determineCreateRoomEncryptionOption(client, targets)) { createRoomOptions.encryption = true; } From a99bb4eb90bddc4a4181d968e800b36782657b0b Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 23 May 2022 17:30:25 +0200 Subject: [PATCH 13/73] Implement start DM with poll --- src/components/structures/LoggedInView.tsx | 15 ++++++++++ .../views/elements/PollCreateDialog.tsx | 28 +++++++++++++++---- .../views/rooms/MessageComposer.tsx | 7 +++++ .../views/rooms/MessageComposerButtons.tsx | 12 +++++--- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index b489e107286..7e8a245bb95 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -645,6 +645,21 @@ class LoggedInView extends React.Component { const rooomId = await startDm(this._matrixClient, room.targets); return this._matrixClient.sendMessage(rooomId, threadId, content); }, + sendEvent: async ( + localRoomId: string, + threadId: string | null, + eventType: string, + content: IContent, + ): Promise => { + const room = this._matrixClient.store.getRoom(localRoomId); + + if (!(room instanceof LocalRoom)) { + return; + } + + const rooomId = await startDm(this._matrixClient, room.targets); + return this._matrixClient.sendEvent(rooomId, threadId, eventType, content); + }, }; showReadMarkers = false; showHeaderButtons = false; diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx index f713a779acc..d081921db4a 100644 --- a/src/components/views/elements/PollCreateDialog.tsx +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -25,6 +25,7 @@ import { PollStartEvent, } from "matrix-events-sdk"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { ISendEventResponse } from 'matrix-js-sdk/src/@types/requests'; import ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal"; import { IDialogProps } from "../dialogs/IDialogProps"; @@ -35,11 +36,13 @@ import { arrayFastClone, arraySeed } from "../../../utils/arrays"; import Field from "./Field"; import AccessibleButton from "./AccessibleButton"; import Spinner from "./Spinner"; +import { IMessageComposerHandlers } from "../rooms/MessageComposer"; interface IProps extends IDialogProps { room: Room; threadId?: string; editingMxEvent?: MatrixEvent; // Truthy if we are editing an existing poll + handlers?: IMessageComposerHandlers; } enum FocusTarget { @@ -163,12 +166,25 @@ export default class PollCreateDialog extends ScrollableBaseModal; + + if (this.props.handlers?.sendEvent) { + sendEventPromise = this.props.handlers.sendEvent( + this.props.room.roomId, + this.props.threadId, + pollEvent.type, + pollEvent.content, + ); + } else { + sendEventPromise = this.matrixClient.sendEvent( + this.props.room.roomId, + this.props.threadId, + pollEvent.type, + pollEvent.content, + ); + } + + sendEventPromise.then( () => this.props.onFinished(true), ).catch(e => { console.error("Failed to post poll:", e); diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index ac4e67f516a..57e867ea5f2 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -97,6 +97,12 @@ export interface IMessageComposerHandlers { sendMessage: ( roomId: string, threadId: string | null, + content: IContent + ) => Promise; + sendEvent: ( + roomId: string, + threadId: string | null, + eventType: string, content: IContent, ) => Promise; } @@ -487,6 +493,7 @@ export default class MessageComposer extends React.Component { showPollsButton={this.state.showPollsButton} showStickersButton={this.state.showStickersButton} toggleButtonMenu={this.toggleButtonMenu} + handlers={this.props.handlers} /> } { showSendButton && ( boolean; @@ -53,6 +54,7 @@ interface IProps { showPollsButton: boolean; showStickersButton: boolean; toggleButtonMenu: () => void; + handlers?: IMessageComposerHandlers; } type OverflowMenuCloser = () => void; @@ -76,7 +78,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { uploadButton(), // props passed via UploadButtonContext showStickersButton(props), voiceRecordingButton(props, narrow), - props.showPollsButton && pollButton(room, props.relation), + props.showPollsButton && pollButton(room, props.relation, props.handlers), showLocationButton(props, room, roomId, matrixClient), ]; } else { @@ -87,7 +89,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { moreButtons = [ showStickersButton(props), voiceRecordingButton(props, narrow), - props.showPollsButton && pollButton(room, props.relation), + props.showPollsButton && pollButton(room, props.relation, props.handlers), showLocationButton(props, room, roomId, matrixClient), ]; } @@ -295,13 +297,14 @@ function voiceRecordingButton(props: IProps, narrow: boolean): ReactElement { ); } -function pollButton(room: Room, relation?: IEventRelation): ReactElement { - return ; +function pollButton(room: Room, relation?: IEventRelation, handlers?: IMessageComposerHandlers): ReactElement { + return ; } interface IPollButtonProps { room: Room; relation?: IEventRelation; + handlers?: IMessageComposerHandlers; } class PollButton extends React.PureComponent { @@ -338,6 +341,7 @@ class PollButton extends React.PureComponent { { room: this.props.room, threadId, + handlers: this.props.handlers }, 'mx_CompoundDialog', false, // isPriorityModal From 09569f82a008b29bdc7f67c55cfdb1cc1da8e1c1 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 23 May 2022 18:03:58 +0200 Subject: [PATCH 14/73] Tweak local room intro messages --- src/components/views/messages/EncryptionEvent.tsx | 6 +++++- src/components/views/rooms/NewRoomIntro.tsx | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx index 809cf75f760..dd1e5588229 100644 --- a/src/components/views/messages/EncryptionEvent.tsx +++ b/src/components/views/messages/EncryptionEvent.tsx @@ -24,6 +24,7 @@ import EventTileBubble from "./EventTileBubble"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import DMRoomMap from "../../../utils/DMRoomMap"; import { objectHasDiff } from "../../../utils/objects"; +import { LocalRoom } from '../../../models/LocalRoom'; interface IProps { mxEvent: MatrixEvent; @@ -46,12 +47,15 @@ const EncryptionEvent = forwardRef(({ mxEvent, timestamp if (content.algorithm === ALGORITHM && isRoomEncrypted) { let subtitle: string; const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); + const room = cli?.getRoom(roomId); if (prevContent.algorithm === ALGORITHM) { subtitle = _t("Some encryption parameters have been changed."); } else if (dmPartner) { - const displayName = cli?.getRoom(roomId)?.getMember(dmPartner)?.rawDisplayName || dmPartner; + const displayName = room.getMember(dmPartner)?.rawDisplayName || dmPartner; subtitle = _t("Messages here are end-to-end encrypted. " + "Verify %(displayName)s in their profile - tap on their avatar.", { displayName }); + } else if (room instanceof LocalRoom) { + subtitle = _t("Messages in this chat will be end-to-end encrypted."); } else { subtitle = _t("Messages in this room are end-to-end encrypted. " + "When people join, you can verify them in their profile, just tap on their avatar."); diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 6439da46247..2cec0ef97b9 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -213,7 +213,10 @@ const NewRoomIntro = () => { ); let subButton; - if (room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.get())) { + if ( + room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.get()) + && !(room instanceof LocalRoom) + ) { subButton = ( { _t("Enable encryption in settings.") } ); From 0f908e29b1aae9547f969aa7820dd68b91b6c2a6 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 24 May 2022 10:24:22 +0200 Subject: [PATCH 15/73] Implement create DM on file upload --- src/ContentMessages.ts | 17 +++++++++-- src/components/structures/LoggedInView.tsx | 30 ++++++++++++++++--- .../views/rooms/MessageComposer.tsx | 11 ++++++- .../views/rooms/MessageComposerButtons.tsx | 10 +++++-- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 7cb0ad1db9c..ed90bb1d227 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -50,6 +50,7 @@ import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog" import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog"; import { createThumbnail } from "./utils/image-media"; import { attachRelation } from "./components/views/rooms/SendMessageComposer"; +import { IMessageComposerHandlers } from "./components/views/rooms/MessageComposer"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre @@ -364,6 +365,7 @@ export default class ContentMessages { relation: IEventRelation | undefined, matrixClient: MatrixClient, context = TimelineRenderingType.Room, + handlers?: IMessageComposerHandlers, ): Promise { if (matrixClient.isGuest()) { dis.dispatch({ action: 'require_registration' }); @@ -419,7 +421,18 @@ export default class ContentMessages { } } - promBefore = this.sendContentToRoom(file, roomId, relation, matrixClient, replyToEvent, promBefore); + if (handlers) { + promBefore = handlers.sendContentToRoom( + file, + roomId, + relation, + matrixClient, + replyToEvent, + promBefore, + ); + } else { + promBefore = this.sendContentToRoom(file, roomId, relation, matrixClient, replyToEvent, promBefore); + } } if (replyToEvent) { @@ -458,7 +471,7 @@ export default class ContentMessages { } } - private sendContentToRoom( + public sendContentToRoom( file: File, roomId: string, relation: IEventRelation | undefined, diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 7e8a245bb95..f67d87391e9 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -24,6 +24,7 @@ import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import { IContent } from "matrix-js-sdk/src/models/event"; +import { IEventRelation } from "matrix-js-sdk/src/matrix"; import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; @@ -75,6 +76,7 @@ import { IConfigOptions } from "../../IConfigOptions"; import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning'; import { startDm } from '../../utils/direct-messages'; import { LocalRoom } from '../../models/LocalRoom'; +import ContentMessages from '../../ContentMessages'; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -642,8 +644,8 @@ class LoggedInView extends React.Component { return; } - const rooomId = await startDm(this._matrixClient, room.targets); - return this._matrixClient.sendMessage(rooomId, threadId, content); + const roomId = await startDm(this._matrixClient, room.targets); + return this._matrixClient.sendMessage(roomId, threadId, content); }, sendEvent: async ( localRoomId: string, @@ -657,9 +659,29 @@ class LoggedInView extends React.Component { return; } - const rooomId = await startDm(this._matrixClient, room.targets); - return this._matrixClient.sendEvent(rooomId, threadId, eventType, content); + const roomId = await startDm(this._matrixClient, room.targets); + return this._matrixClient.sendEvent(roomId, threadId, eventType, content); }, + sendContentToRoom: async ( + file: File, + localRoomId: string, + relation: IEventRelation | undefined, + matrixClient: MatrixClient, + replyToEvent: MatrixEvent | undefined, + promBefore: Promise, + ): Promise => { + const room = this._matrixClient.store.getRoom(localRoomId); + + if (!(room instanceof LocalRoom)) { + return; + } + + const roomId = await startDm(this._matrixClient, room.targets); + ContentMessages.sharedInstance().sendContentToRoom( + file, roomId, relation, matrixClient, replyToEvent, promBefore, + ); + }, + }; showReadMarkers = false; showHeaderButtons = false; diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 57e867ea5f2..c0f3196870f 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -24,6 +24,7 @@ import { Optional } from "matrix-events-sdk"; import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; import { ISendEventResponse } from 'matrix-js-sdk/src/@types/requests'; import { IContent } from 'matrix-js-sdk/src/models/event'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -48,7 +49,7 @@ import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInse import { Action } from "../../../dispatcher/actions"; import EditorModel from "../../../editor/model"; import UIStore, { UI_EVENTS } from '../../../stores/UIStore'; -import RoomContext from '../../../contexts/RoomContext'; +import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext'; import { SettingUpdatedPayload } from "../../../dispatcher/payloads/SettingUpdatedPayload"; import MessageComposerButtons from './MessageComposerButtons'; import { ButtonEvent } from '../elements/AccessibleButton'; @@ -105,6 +106,14 @@ export interface IMessageComposerHandlers { eventType: string, content: IContent, ) => Promise; + sendContentToRoom: ( + file: File, + roomId: string, + relation: IEventRelation | undefined, + matrixClient: MatrixClient, + replyToEvent: MatrixEvent | undefined, + promBefore: Promise, + ) => Promise; } export default class MessageComposer extends React.Component { diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index d380804ffc8..2bb15f648cf 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -103,7 +103,11 @@ const MessageComposerButtons: React.FC = (props: IProps) => { mx_MessageComposer_closeButtonMenu: props.isMenuOpen, }); - return + return { mainButtons } { moreButtons.length > 0 && (null); interface IUploadButtonProps { roomId: string; relation?: IEventRelation | null; + handlers?: IMessageComposerHandlers; } // We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes. -const UploadButtonContextProvider: React.FC = ({ roomId, relation, children }) => { +const UploadButtonContextProvider: React.FC = ({ roomId, relation, handlers, children }) => { const cli = useContext(MatrixClientContext); const roomContext = useContext(RoomContext); const uploadInput = useRef(); @@ -225,6 +230,7 @@ const UploadButtonContextProvider: React.FC = ({ roomId, rel relation, cli, roomContext.timelineRenderingType, + handlers, ); // This is the onChange handler for a file form control, but we're From a793cb81799a6914ad9208c7217f302734b86c8b Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 24 May 2022 10:46:34 +0200 Subject: [PATCH 16/73] Implement create DM on sending sticker --- src/components/structures/LoggedInView.tsx | 21 ++++++++++++++- src/components/structures/RoomView.tsx | 26 +++++++++++++------ .../views/rooms/MessageComposer.tsx | 11 +++++++- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index f67d87391e9..e40d8212782 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -20,7 +20,7 @@ import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import classNames from 'classnames'; import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; -import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials'; +import { IUsageLimit, IImageInfo } from 'matrix-js-sdk/src/@types/partials'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import { IContent } from "matrix-js-sdk/src/models/event"; @@ -681,6 +681,25 @@ class LoggedInView extends React.Component { file, roomId, relation, matrixClient, replyToEvent, promBefore, ); }, + sendStickerContentToRoom: async ( + url: string, + localRoomId: string, + threadId: string | null, + info: IImageInfo, + text: string, + matrixClient: MatrixClient, + ): Promise => { + const room = this._matrixClient.store.getRoom(localRoomId); + + if (!(room instanceof LocalRoom)) { + return; + } + + const roomId = await startDm(this._matrixClient, room.targets); + ContentMessages.sharedInstance().sendStickerContentToRoom( + url, roomId, threadId, info, text, matrixClient, + ); + }, }; showReadMarkers = false; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index d0369eaa91c..608638240a6 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -37,6 +37,7 @@ import { ClientEvent } from "matrix-js-sdk/src/client"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; import { HistoryVisibility } from 'matrix-js-sdk/src/@types/partials'; +import { ISendEventResponse } from 'matrix-js-sdk/src/@types/requests'; import shouldHideEvent from '../../shouldHideEvent'; import { _t } from '../../languageHandler'; @@ -1303,14 +1304,23 @@ export class RoomView extends React.Component { return; } - ContentMessages.sharedInstance() - .sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context) - .then(undefined, (error) => { - if (error.name === "UnknownDeviceError") { - // Let the staus bar handle this - return; - } - }); + let sendStickerPromise: Promise; + + if (this.props.messageComposerHandlers) { + sendStickerPromise = this.props.messageComposerHandlers.sendStickerContentToRoom( + url, this.state.room.roomId, threadId, info, text, this.context, + ); + } else { + sendStickerPromise = ContentMessages.sharedInstance() + .sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context); + } + + sendStickerPromise.then(undefined, (error) => { + if (error.name === "UnknownDeviceError") { + // Let the staus bar handle this + return; + } + }); } private onSearch = (term: string, scope: SearchScope) => { diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index c0f3196870f..32fc78e59f9 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -20,6 +20,7 @@ import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { EventType } from 'matrix-js-sdk/src/@types/event'; +import { IImageInfo } from 'matrix-js-sdk/src/@types/partials'; import { Optional } from "matrix-events-sdk"; import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; import { ISendEventResponse } from 'matrix-js-sdk/src/@types/requests'; @@ -49,7 +50,7 @@ import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInse import { Action } from "../../../dispatcher/actions"; import EditorModel from "../../../editor/model"; import UIStore, { UI_EVENTS } from '../../../stores/UIStore'; -import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext'; +import RoomContext from '../../../contexts/RoomContext'; import { SettingUpdatedPayload } from "../../../dispatcher/payloads/SettingUpdatedPayload"; import MessageComposerButtons from './MessageComposerButtons'; import { ButtonEvent } from '../elements/AccessibleButton'; @@ -114,6 +115,14 @@ export interface IMessageComposerHandlers { replyToEvent: MatrixEvent | undefined, promBefore: Promise, ) => Promise; + sendStickerContentToRoom: ( + url: string, + roomId: string, + threadId: string | null, + info: IImageInfo, + text: string, + matrixClient: MatrixClient, + ) => Promise; } export default class MessageComposer extends React.Component { From afe2fdad19ad2fc33277bd856159b8a141a49d73 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 24 May 2022 18:47:39 +0200 Subject: [PATCH 17/73] Introduce fake client --- src/ContentMessages.ts | 16 +-- src/components/structures/LoggedInView.tsx | 109 ++++++------------ src/components/structures/RoomView.tsx | 29 ++--- .../views/elements/PollCreateDialog.tsx | 33 ++---- .../views/location/shareLocation.ts | 1 + .../views/rooms/MessageComposer.tsx | 37 ------ .../views/rooms/MessageComposerButtons.tsx | 24 ++-- .../views/rooms/SendMessageComposer.tsx | 7 +- .../views/rooms/VoiceRecordComposerTile.tsx | 11 +- src/stores/OwnBeaconStore.ts | 6 +- src/utils/direct-messages.ts | 1 + 11 files changed, 83 insertions(+), 191 deletions(-) diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index ed90bb1d227..bb2a7bcb6b6 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -50,7 +50,6 @@ import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog" import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog"; import { createThumbnail } from "./utils/image-media"; import { attachRelation } from "./components/views/rooms/SendMessageComposer"; -import { IMessageComposerHandlers } from "./components/views/rooms/MessageComposer"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre @@ -365,7 +364,6 @@ export default class ContentMessages { relation: IEventRelation | undefined, matrixClient: MatrixClient, context = TimelineRenderingType.Room, - handlers?: IMessageComposerHandlers, ): Promise { if (matrixClient.isGuest()) { dis.dispatch({ action: 'require_registration' }); @@ -420,19 +418,7 @@ export default class ContentMessages { uploadAll = true; } } - - if (handlers) { - promBefore = handlers.sendContentToRoom( - file, - roomId, - relation, - matrixClient, - replyToEvent, - promBefore, - ); - } else { - promBefore = this.sendContentToRoom(file, roomId, relation, matrixClient, replyToEvent, promBefore); - } + promBefore = this.sendContentToRoom(file, roomId, relation, matrixClient, replyToEvent, promBefore); } if (replyToEvent) { diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index e40d8212782..b7abf53b4d4 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -20,11 +20,9 @@ import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import classNames from 'classnames'; import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; -import { IUsageLimit, IImageInfo } from 'matrix-js-sdk/src/@types/partials'; +import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; -import { IContent } from "matrix-js-sdk/src/models/event"; -import { IEventRelation } from "matrix-js-sdk/src/matrix"; import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; @@ -76,7 +74,6 @@ import { IConfigOptions } from "../../IConfigOptions"; import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning'; import { startDm } from '../../utils/direct-messages'; import { LocalRoom } from '../../models/LocalRoom'; -import ContentMessages from '../../ContentMessages'; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -630,77 +627,41 @@ class LoggedInView extends React.Component { let showHeaderButtons = true; let enableHeaderRoomOptionsMenu = true; + const onSendToLocalRoom = async ( + localRoomId: string, + sendToRealRoom: (roomId: string) => Promise, + ) => { + const room = this._matrixClient.store.getRoom(localRoomId); + + if (!(room instanceof LocalRoom)) { + return; + } + + const roomId = await startDm(this._matrixClient, room.targets); + return sendToRealRoom(roomId); + }; + + let client = this._matrixClient; + switch (this.props.page_type) { case PageTypes.LocalRoomView: - messageComposerHandlers = { - sendMessage: async ( - localRoomId: string, - threadId: string | null, - content: IContent, - ): Promise => { - const room = this._matrixClient.store.getRoom(localRoomId); - - if (!(room instanceof LocalRoom)) { - return; - } - - const roomId = await startDm(this._matrixClient, room.targets); - return this._matrixClient.sendMessage(roomId, threadId, content); - }, - sendEvent: async ( - localRoomId: string, - threadId: string | null, - eventType: string, - content: IContent, - ): Promise => { - const room = this._matrixClient.store.getRoom(localRoomId); - - if (!(room instanceof LocalRoom)) { - return; - } - - const roomId = await startDm(this._matrixClient, room.targets); - return this._matrixClient.sendEvent(roomId, threadId, eventType, content); - }, - sendContentToRoom: async ( - file: File, - localRoomId: string, - relation: IEventRelation | undefined, - matrixClient: MatrixClient, - replyToEvent: MatrixEvent | undefined, - promBefore: Promise, - ): Promise => { - const room = this._matrixClient.store.getRoom(localRoomId); - - if (!(room instanceof LocalRoom)) { - return; - } - - const roomId = await startDm(this._matrixClient, room.targets); - ContentMessages.sharedInstance().sendContentToRoom( - file, roomId, relation, matrixClient, replyToEvent, promBefore, - ); - }, - sendStickerContentToRoom: async ( - url: string, - localRoomId: string, - threadId: string | null, - info: IImageInfo, - text: string, - matrixClient: MatrixClient, - ): Promise => { - const room = this._matrixClient.store.getRoom(localRoomId); - - if (!(room instanceof LocalRoom)) { - return; - } - - const roomId = await startDm(this._matrixClient, room.targets); - ContentMessages.sharedInstance().sendStickerContentToRoom( - url, roomId, threadId, info, text, matrixClient, - ); - }, - + client = Object.assign(Object.create(Object.getPrototypeOf(this._matrixClient)), this._matrixClient); + //client.sendMessage = async (localRoomId: string, ...rest): Promise => { + //return onSendToLocalRoom(localRoomId, (roomId) => { + //return this._matrixClient.sendMessage(roomId, ...rest); + //}); + //}; + client.sendEvent = async (localRoomId: string, ...rest): Promise => { + return onSendToLocalRoom(localRoomId, (roomId) => { + return this._matrixClient.sendEvent(roomId, ...rest); + }); + }; + client.unstable_createLiveBeacon = async ( + localRoomId: string, ...rest + ): Promise => { + return onSendToLocalRoom(localRoomId, (roomId) => { + return this._matrixClient.unstable_createLiveBeacon(roomId, ...rest); + }); }; showReadMarkers = false; showHeaderButtons = false; @@ -752,7 +713,7 @@ class LoggedInView extends React.Component { }); return ( - +
{ return; } - let sendStickerPromise: Promise; - - if (this.props.messageComposerHandlers) { - sendStickerPromise = this.props.messageComposerHandlers.sendStickerContentToRoom( - url, this.state.room.roomId, threadId, info, text, this.context, + ContentMessages.sharedInstance() + .sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context) + .then(undefined, (error) => { + if (error.name === "UnknownDeviceError") { + // Let the staus bar handle this + return; + } + }, ); - } else { - sendStickerPromise = ContentMessages.sharedInstance() - .sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context); - } - - sendStickerPromise.then(undefined, (error) => { - if (error.name === "UnknownDeviceError") { - // Let the staus bar handle this - return; - } - }); } private onSearch = (term: string, scope: SearchScope) => { @@ -2034,7 +2024,6 @@ export class RoomView extends React.Component { resizeNotifier={this.props.resizeNotifier} replyToEvent={this.state.replyToEvent} permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} - handlers={this.props.messageComposerHandlers} />; } diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx index d081921db4a..faed54f8f09 100644 --- a/src/components/views/elements/PollCreateDialog.tsx +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -25,7 +25,7 @@ import { PollStartEvent, } from "matrix-events-sdk"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { ISendEventResponse } from 'matrix-js-sdk/src/@types/requests'; +import { MatrixClient } from "matrix-js-sdk/src/client"; import ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal"; import { IDialogProps } from "../dialogs/IDialogProps"; @@ -36,13 +36,12 @@ import { arrayFastClone, arraySeed } from "../../../utils/arrays"; import Field from "./Field"; import AccessibleButton from "./AccessibleButton"; import Spinner from "./Spinner"; -import { IMessageComposerHandlers } from "../rooms/MessageComposer"; interface IProps extends IDialogProps { room: Room; threadId?: string; editingMxEvent?: MatrixEvent; // Truthy if we are editing an existing poll - handlers?: IMessageComposerHandlers; + mxClient?: MatrixClient; } enum FocusTarget { @@ -163,28 +162,20 @@ export default class PollCreateDialog extends ScrollableBaseModal; - - if (this.props.handlers?.sendEvent) { - sendEventPromise = this.props.handlers.sendEvent( - this.props.room.roomId, - this.props.threadId, - pollEvent.type, - pollEvent.content, - ); - } else { - sendEventPromise = this.matrixClient.sendEvent( - this.props.room.roomId, - this.props.threadId, - pollEvent.type, - pollEvent.content, - ); - } - sendEventPromise.then( + this.matrixClient.sendEvent( + this.props.room.roomId, + this.props.threadId, + pollEvent.type, + pollEvent.content, + ).then( () => this.props.onFinished(true), ).catch(e => { console.error("Failed to post poll:", e); diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts index 895f2c23c6c..a083bf1a0cb 100644 --- a/src/components/views/location/shareLocation.ts +++ b/src/components/views/location/shareLocation.ts @@ -79,6 +79,7 @@ export const shareLiveLocation = ( description, LocationAssetType.Self, ), + client, ); } catch (error) { handleShareError(error, openMenu, LocationShareType.Live); diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 32fc78e59f9..07912586378 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -20,12 +20,8 @@ import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { EventType } from 'matrix-js-sdk/src/@types/event'; -import { IImageInfo } from 'matrix-js-sdk/src/@types/partials'; import { Optional } from "matrix-events-sdk"; import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; -import { ISendEventResponse } from 'matrix-js-sdk/src/@types/requests'; -import { IContent } from 'matrix-js-sdk/src/models/event'; -import { MatrixClient } from 'matrix-js-sdk/src/client'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -81,7 +77,6 @@ interface IProps { relation?: IEventRelation; e2eStatus?: E2EStatus; compact?: boolean; - handlers?: IMessageComposerHandlers; } interface IState { @@ -95,36 +90,6 @@ interface IState { showPollsButton: boolean; } -export interface IMessageComposerHandlers { - sendMessage: ( - roomId: string, - threadId: string | null, - content: IContent - ) => Promise; - sendEvent: ( - roomId: string, - threadId: string | null, - eventType: string, - content: IContent, - ) => Promise; - sendContentToRoom: ( - file: File, - roomId: string, - relation: IEventRelation | undefined, - matrixClient: MatrixClient, - replyToEvent: MatrixEvent | undefined, - promBefore: Promise, - ) => Promise; - sendStickerContentToRoom: ( - url: string, - roomId: string, - threadId: string | null, - info: IImageInfo, - text: string, - matrixClient: MatrixClient, - ) => Promise; -} - export default class MessageComposer extends React.Component { private dispatcherRef: string; private messageComposerInput = createRef(); @@ -412,7 +377,6 @@ export default class MessageComposer extends React.Component { onChange={this.onChange} disabled={this.state.haveRecording} toggleStickerPickerOpen={this.toggleStickerPickerOpen} - handlers={this.props.handlers} />, ); @@ -511,7 +475,6 @@ export default class MessageComposer extends React.Component { showPollsButton={this.state.showPollsButton} showStickersButton={this.state.showStickersButton} toggleButtonMenu={this.toggleButtonMenu} - handlers={this.props.handlers} /> } { showSendButton && ( boolean; @@ -54,7 +53,6 @@ interface IProps { showPollsButton: boolean; showStickersButton: boolean; toggleButtonMenu: () => void; - handlers?: IMessageComposerHandlers; } type OverflowMenuCloser = () => void; @@ -78,7 +76,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { uploadButton(), // props passed via UploadButtonContext showStickersButton(props), voiceRecordingButton(props, narrow), - props.showPollsButton && pollButton(room, props.relation, props.handlers), + props.showPollsButton && pollButton(room, props.relation, matrixClient), showLocationButton(props, room, roomId, matrixClient), ]; } else { @@ -89,7 +87,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { moreButtons = [ showStickersButton(props), voiceRecordingButton(props, narrow), - props.showPollsButton && pollButton(room, props.relation, props.handlers), + props.showPollsButton && pollButton(room, props.relation, matrixClient), showLocationButton(props, room, roomId, matrixClient), ]; } @@ -103,11 +101,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { mx_MessageComposer_closeButtonMenu: props.isMenuOpen, }); - return + return { mainButtons } { moreButtons.length > 0 && (null); interface IUploadButtonProps { roomId: string; relation?: IEventRelation | null; - handlers?: IMessageComposerHandlers; } // We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes. -const UploadButtonContextProvider: React.FC = ({ roomId, relation, handlers, children }) => { +const UploadButtonContextProvider: React.FC = ({ roomId, relation, children }) => { const cli = useContext(MatrixClientContext); const roomContext = useContext(RoomContext); const uploadInput = useRef(); @@ -230,7 +223,6 @@ const UploadButtonContextProvider: React.FC = ({ roomId, rel relation, cli, roomContext.timelineRenderingType, - handlers, ); // This is the onChange handler for a file form control, but we're @@ -303,14 +295,14 @@ function voiceRecordingButton(props: IProps, narrow: boolean): ReactElement { ); } -function pollButton(room: Room, relation?: IEventRelation, handlers?: IMessageComposerHandlers): ReactElement { - return ; +function pollButton(room: Room, relation?: IEventRelation, mxClient?: MatrixClient): ReactElement { + return ; } interface IPollButtonProps { room: Room; relation?: IEventRelation; - handlers?: IMessageComposerHandlers; + mxClient?: MatrixClient; } class PollButton extends React.PureComponent { @@ -347,7 +339,7 @@ class PollButton extends React.PureComponent { { room: this.props.room, threadId, - handlers: this.props.handlers + mxClient: this.props.mxClient, }, 'mx_CompoundDialog', false, // isPriorityModal diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 32faa75d2aa..c309e0a16c1 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -58,7 +58,6 @@ import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } fr import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { addReplyToMessageContent } from '../../../utils/Reply'; -import { IMessageComposerHandlers } from './MessageComposer'; // Merges favouring the given relation export function attachRelation(content: IContent, relation?: IEventRelation): void { @@ -140,7 +139,6 @@ interface ISendMessageComposerProps extends MatrixClientProps { onChange?(model: EditorModel): void; includeReplyLegacyFallback?: boolean; toggleStickerPickerOpen: () => void; - handlers?: IMessageComposerHandlers; } export class SendMessageComposer extends React.Component { @@ -403,10 +401,7 @@ export class SendMessageComposer extends React.Component { + public static contextType = MatrixClientContext; + public context: React.ContextType; + public constructor(props) { super(props); @@ -103,7 +108,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent { public createLiveBeacon = async ( roomId: Room['roomId'], beaconInfoContent: MBeaconInfoEventContent, + matrixClient?: MatrixClient, ): Promise => { + matrixClient = matrixClient || this.matrixClient; + // eslint-disable-next-line camelcase - const { event_id } = await this.matrixClient.unstable_createLiveBeacon( + const { event_id } = await matrixClient.unstable_createLiveBeacon( roomId, beaconInfoContent, ); diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 6dfb324ad1a..089d8ab51ac 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -138,6 +138,7 @@ export async function createDmLocalRoom( sender: userId, state_key: "", room_id: localRoom.roomId, + origin_server_ts: Date.now(), }), ); } From 0a67b9b867628d869ef01428b0f7d00c3f285389 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 24 May 2022 19:37:13 +0200 Subject: [PATCH 18/73] Collect and replay LocalRoom events --- src/components/structures/LoggedInView.tsx | 37 +++++++--------------- src/models/LocalRoom.ts | 20 +++++++++++- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index b7abf53b4d4..e7125a6cdb3 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -72,7 +72,6 @@ import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload import LegacyGroupView from "./LegacyGroupView"; import { IConfigOptions } from "../../IConfigOptions"; import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning'; -import { startDm } from '../../utils/direct-messages'; import { LocalRoom } from '../../models/LocalRoom'; // We need to fetch each pinned message individually (if we don't already have it) @@ -622,46 +621,33 @@ class LoggedInView extends React.Component { render() { let pageElement; - let messageComposerHandlers; let showReadMarkers = true; let showHeaderButtons = true; let enableHeaderRoomOptionsMenu = true; - const onSendToLocalRoom = async ( - localRoomId: string, - sendToRealRoom: (roomId: string) => Promise, - ) => { - const room = this._matrixClient.store.getRoom(localRoomId); - - if (!(room instanceof LocalRoom)) { - return; - } - - const roomId = await startDm(this._matrixClient, room.targets); - return sendToRealRoom(roomId); - }; - let client = this._matrixClient; + let room: LocalRoom; switch (this.props.page_type) { case PageTypes.LocalRoomView: + room = this._matrixClient.store.getRoom(this.props.currentRoomId) as LocalRoom; + client = Object.assign(Object.create(Object.getPrototypeOf(this._matrixClient)), this._matrixClient); - //client.sendMessage = async (localRoomId: string, ...rest): Promise => { - //return onSendToLocalRoom(localRoomId, (roomId) => { - //return this._matrixClient.sendMessage(roomId, ...rest); - //}); - //}; client.sendEvent = async (localRoomId: string, ...rest): Promise => { - return onSendToLocalRoom(localRoomId, (roomId) => { - return this._matrixClient.sendEvent(roomId, ...rest); + room.createRealRoom(this._matrixClient); + room.afterCreateCallbacks.push(async (client, roomId) => { + await client.sendEvent(roomId, ...rest); }); + return; }; client.unstable_createLiveBeacon = async ( localRoomId: string, ...rest ): Promise => { - return onSendToLocalRoom(localRoomId, (roomId) => { - return this._matrixClient.unstable_createLiveBeacon(roomId, ...rest); + room.createRealRoom(this._matrixClient); + room.afterCreateCallbacks.push(async (client, roomId) => { + await client.unstable_createLiveBeacon(roomId, ...rest); }); + return; }; showReadMarkers = false; showHeaderButtons = false; @@ -677,7 +663,6 @@ class LoggedInView extends React.Component { resizeNotifier={this.props.resizeNotifier} justCreatedOpts={this.props.roomJustCreatedOpts} forceTimeline={this.props.forceTimeline} - messageComposerHandlers={messageComposerHandlers} showReadMarkers={showReadMarkers} showHeaderButtons={showHeaderButtons} enableHeaderRoomOptionsMenu={enableHeaderRoomOptionsMenu} diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index 770efb397c6..3f81c41fb48 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { Room } from "matrix-js-sdk/src/models/room"; -import { Member } from "../utils/direct-messages"; +import { Member, startDm } from "../utils/direct-messages"; export const LOCAL_ROOM_ID_PREFIX = 'local/'; @@ -26,4 +27,21 @@ export const LOCAL_ROOM_ID_PREFIX = 'local/'; */ export class LocalRoom extends Room { targets: Member[]; + afterCreateCallbacks: Function[] = []; + createRealRoomPromise: Promise; + + public createRealRoom = (client: MatrixClient) => { + if (!this.createRealRoomPromise) { + this.createRealRoomPromise = startDm(client, this.targets); + this.createRealRoomPromise.then((roomId) => { + this.applyAfterCreateCallbacks(client, roomId); + }); + } + }; + + public applyAfterCreateCallbacks = async (client: MatrixClient, roomId: string) => { + this.afterCreateCallbacks.forEach(async (afterCreateCallback) => { + await afterCreateCallback(client, roomId); + }); + }; } From 79df6215c4e42ec295dc7a4892e71db40580428e Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 25 May 2022 13:13:19 +0200 Subject: [PATCH 19/73] Add local echo; Refactor tile rendering condition --- src/components/structures/LoggedInView.tsx | 31 +++++++++++++++++-- src/components/structures/MessagePanel.tsx | 15 ++------- src/components/views/rooms/EventTile.tsx | 7 ++++- src/models/LocalRoom.ts | 13 ++++---- src/stores/TypingStore.ts | 3 +- .../room-list/filters/VisibilityProvider.ts | 2 +- src/utils/EventRenderingUtils.ts | 16 ++++++++++ src/utils/direct-messages.ts | 6 +++- 8 files changed, 68 insertions(+), 25 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index e7125a6cdb3..802b6087c47 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -23,6 +23,7 @@ import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; +import { EventStatus, EventType, IContent } from 'matrix-js-sdk/src/matrix'; import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; @@ -634,19 +635,45 @@ class LoggedInView extends React.Component { client = Object.assign(Object.create(Object.getPrototypeOf(this._matrixClient)), this._matrixClient); client.sendEvent = async (localRoomId: string, ...rest): Promise => { - room.createRealRoom(this._matrixClient); + let eventType: EventType; + let content: IContent; + + if (typeof rest[1] === 'object') { + eventType = rest[0]; + content = rest[1]; + } + + if (typeof rest[2] === 'object') { + eventType = rest[1]; + content = rest[2]; + } + + const txnId = this._matrixClient.makeTxnId(); + const event = new MatrixEvent({ + type: eventType, + content, + event_id: "~" + localRoomId + ":" + txnId, + user_id: this._matrixClient.getUserId(), + sender: this._matrixClient.getUserId(), + room_id: localRoomId, + origin_server_ts: new Date().getTime(), + }); + event.setTxnId(txnId); + event.setStatus(EventStatus.SENDING); + room.addLiveEvents([event]); room.afterCreateCallbacks.push(async (client, roomId) => { await client.sendEvent(roomId, ...rest); }); + room.createRealRoom(this._matrixClient); return; }; client.unstable_createLiveBeacon = async ( localRoomId: string, ...rest ): Promise => { - room.createRealRoom(this._matrixClient); room.afterCreateCallbacks.push(async (client, roomId) => { await client.unstable_createLiveBeacon(roomId, ...rest); }); + room.createRealRoom(this._matrixClient); return; }; showReadMarkers = false; diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 6b03b31d52d..45ffef634fd 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -52,12 +52,11 @@ import Spinner from "../views/elements/Spinner"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import EditorStateTransfer from "../../utils/EditorStateTransfer"; import { Action } from '../../dispatcher/actions'; -import { getEventDisplayInfo } from "../../utils/EventRenderingUtils"; +import { getEventDisplayInfo, shouldRenderEventTiles } from "../../utils/EventRenderingUtils"; import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker"; import { haveRendererForEvent } from "../../events/EventTileFactory"; import { editorRoomKey } from "../../Editing"; import { hasThreadSummary } from "../../utils/EventUtils"; -import { LOCAL_ROOM_ID_PREFIX } from '../../models/LocalRoom'; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -67,9 +66,6 @@ const groupedEvents = [ EventType.RoomServerAcl, EventType.RoomPinnedEvents, ]; -const LOCAL_ROOM_NO_TILE_EVENTS = [ - EventType.RoomMember, -]; // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL @@ -723,10 +719,7 @@ export default class MessagePanel extends React.Component { nextEvent?: MatrixEvent, nextEventWithTile?: MatrixEvent, ): ReactNode[] { - if ( - mxEv.getRoomId().startsWith(LOCAL_ROOM_ID_PREFIX) && - LOCAL_ROOM_NO_TILE_EVENTS.includes(mxEv.getType() as EventType) - ) { + if (!this.showHiddenEvents && !shouldRenderEventTiles(mxEv)) { return []; } @@ -1173,10 +1166,6 @@ class CreationGrouper extends BaseGrouper { ret.push(); - if (this.events[0].getRoomId().startsWith(LOCAL_ROOM_ID_PREFIX)) { - return ret; - } - const eventTiles = this.events.map((e) => { // In order to prevent DateSeparators from appearing in the expanded form // of GenericEventListSummary, render each member event as if the previous diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 9ccf7d44dd5..c49b8118714 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -80,6 +80,7 @@ import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../event import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; import { ReadReceiptGroup } from './ReadReceiptGroup'; import { useTooltip } from "../../../utils/useTooltip"; +import { LOCAL_ROOM_ID_PREFIX } from '../../../models/LocalRoom'; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -766,6 +767,11 @@ export class UnwrappedEventTile extends React.Component { private renderE2EPadlock() { const ev = this.props.mxEvent; + // no icon for local rooms + if (ev.getRoomId().startsWith(LOCAL_ROOM_ID_PREFIX)) { + return; + } + // event could not be decrypted if (ev.getContent().msgtype === 'm.bad.encrypted') { return ; @@ -942,7 +948,6 @@ export class UnwrappedEventTile extends React.Component { isSeeingThroughMessageHiddenForModeration, } = getEventDisplayInfo(this.props.mxEvent, this.context.showHiddenEvents, this.shouldHideEvent()); const { isQuoteExpanded } = this.state; - // This shouldn't happen: the caller should check we support this type // before trying to instantiate us if (!hasRenderer) { diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index 3f81c41fb48..1f8654007b5 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -19,7 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { Member, startDm } from "../utils/direct-messages"; -export const LOCAL_ROOM_ID_PREFIX = 'local/'; +export const LOCAL_ROOM_ID_PREFIX = 'local+'; /** * A local room that only exists on the client side. @@ -30,12 +30,13 @@ export class LocalRoom extends Room { afterCreateCallbacks: Function[] = []; createRealRoomPromise: Promise; - public createRealRoom = (client: MatrixClient) => { + public createRealRoom = async (client: MatrixClient) => { if (!this.createRealRoomPromise) { - this.createRealRoomPromise = startDm(client, this.targets); - this.createRealRoomPromise.then((roomId) => { - this.applyAfterCreateCallbacks(client, roomId); - }); + const roomId = await startDm(client, this.targets); + this.applyAfterCreateCallbacks(client, roomId); + // MiW: failed hacky approach to replace the local room by the real one + //const room = client.getRoom(roomId); + //client.store.storeRoomId(this.roomId, room); } }; diff --git a/src/stores/TypingStore.ts b/src/stores/TypingStore.ts index 331dd4460c3..829822c9b0c 100644 --- a/src/stores/TypingStore.ts +++ b/src/stores/TypingStore.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { MatrixClientPeg } from "../MatrixClientPeg"; +import { LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; import SettingsStore from "../settings/SettingsStore"; import Timer from "../utils/Timer"; @@ -65,7 +66,7 @@ export default class TypingStore { */ public setSelfTyping(roomId: string, threadId: string | null, isTyping: boolean): void { // No typing notifications for local rooms - if (roomId.endsWith(':local')) return; + if (roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) return; if (!SettingsStore.getValue('sendTypingNotifications')) return; if (SettingsStore.getValue('lowBandwidth')) return; diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts index 36175075723..26bfcd78ea9 100644 --- a/src/stores/room-list/filters/VisibilityProvider.ts +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -56,7 +56,7 @@ export class VisibilityProvider { } if (room instanceof LocalRoom) { - // local rooms should not show up in any room list + // local rooms shouldn't show up anywhere return false; } diff --git a/src/utils/EventRenderingUtils.ts b/src/utils/EventRenderingUtils.ts index ced38748049..ab6edc95079 100644 --- a/src/utils/EventRenderingUtils.ts +++ b/src/utils/EventRenderingUtils.ts @@ -23,6 +23,11 @@ import SettingsStore from "../settings/SettingsStore"; import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils"; +import { LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; + +const LOCAL_ROOM_NO_TILE_EVENTS = [ + EventType.RoomMember, +]; export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: boolean, hideEvent?: boolean): { isInfoMessage: boolean; @@ -108,3 +113,14 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: bool isSeeingThroughMessageHiddenForModeration, }; } + +export function shouldRenderEventTiles(mxEvent: MatrixEvent): boolean { + if ( + mxEvent.getRoomId().startsWith(LOCAL_ROOM_ID_PREFIX) + && LOCAL_ROOM_NO_TILE_EVENTS.includes(mxEvent.getType() as EventType) + ) { + return false; + } + + return true; +} diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 089d8ab51ac..3b7add61aa1 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -130,7 +130,7 @@ export async function createDmLocalRoom( events.push( new MatrixEvent({ event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, - type: "m.room.encryption", + type: EventType.RoomEncryption, content: { algorithm: "m.megolm.v1.aes-sha2", }, @@ -272,6 +272,10 @@ export async function startDm(client: MatrixClient, targets: Member[]): Promise< ); } + // MiW + //createRoomOptions.andView = false; + createRoomOptions.spinner = false; + return createRoom(createRoomOptions); } From 0a5a7bd155dcc5c1045859240621148d5e88fb62 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 25 May 2022 13:51:09 +0200 Subject: [PATCH 20/73] Implement local room timeline layout --- res/css/structures/_ScrollPanel.scss | 4 +++ src/components/structures/LoggedInView.tsx | 1 + src/components/structures/RoomView.tsx | 2 ++ src/components/views/rooms/RoomHeader.tsx | 2 +- src/models/LocalRoom.ts | 29 ++++++++++++++++------ 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss index a668594bba6..e497256535f 100644 --- a/res/css/structures/_ScrollPanel.scss +++ b/res/css/structures/_ScrollPanel.scss @@ -25,3 +25,7 @@ limitations under the License. contain-intrinsic-size: 50px; } } + +.mx_RoomView_newLocal .mx_ScrollPanel .mx_RoomView_MessageList { + justify-content: flex-start; +} diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 802b6087c47..34900cd81b5 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -665,6 +665,7 @@ class LoggedInView extends React.Component { await client.sendEvent(roomId, ...rest); }); room.createRealRoom(this._matrixClient); + this._matrixClient.emit(ClientEvent.Room, room); return; }; client.unstable_createLiveBeacon = async ( diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 2fd647f632b..1ac1cb7fee6 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -110,6 +110,7 @@ import FileDropTarget from './FileDropTarget'; import Measured from '../views/elements/Measured'; import { FocusComposerPayload } from '../../dispatcher/payloads/FocusComposerPayload'; import { haveRendererForEvent } from "../../events/EventTileFactory"; +import { LocalRoom } from '../../models/LocalRoom'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -2130,6 +2131,7 @@ export class RoomView extends React.Component { const mainClasses = classNames("mx_RoomView", { mx_RoomView_inCall: Boolean(activeCall), mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Video, + mx_RoomView_newLocal: this.state.room instanceof LocalRoom && this.state.room.isNew, }); const showChatEffects = SettingsStore.getValue('showChatEffects'); diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index c39403d4b16..b1efa8b08b2 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -290,7 +290,7 @@ export default class RoomHeader extends React.Component { buttons =
{ this.renderButtons() } -
; +
; } diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index 1f8654007b5..79e96ef6170 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -21,6 +21,12 @@ import { Member, startDm } from "../utils/direct-messages"; export const LOCAL_ROOM_ID_PREFIX = 'local+'; +export enum LocalRoomState { + NEW, + CREATING, + CREATED, +} + /** * A local room that only exists on the client side. * Its main purpose is to be used for temporary rooms when creating a DM. @@ -28,16 +34,21 @@ export const LOCAL_ROOM_ID_PREFIX = 'local+'; export class LocalRoom extends Room { targets: Member[]; afterCreateCallbacks: Function[] = []; - createRealRoomPromise: Promise; + state: LocalRoomState = LocalRoomState.NEW; public createRealRoom = async (client: MatrixClient) => { - if (!this.createRealRoomPromise) { - const roomId = await startDm(client, this.targets); - this.applyAfterCreateCallbacks(client, roomId); - // MiW: failed hacky approach to replace the local room by the real one - //const room = client.getRoom(roomId); - //client.store.storeRoomId(this.roomId, room); + if (!this.isNew) { + return; } + + this.state = LocalRoomState.CREATING; + const roomId = await startDm(client, this.targets); + this.applyAfterCreateCallbacks(client, roomId); + this.state = LocalRoomState.CREATED; + // MiW: failed hacky approach to replace the local room by the real one + //const room = client.getRoom(roomId); + //client.store.storeRoomId(this.roomId, room); + //} }; public applyAfterCreateCallbacks = async (client: MatrixClient, roomId: string) => { @@ -45,4 +56,8 @@ export class LocalRoom extends Room { await afterCreateCallback(client, roomId); }); }; + + public get isNew(): boolean { + return this.state === LocalRoomState.NEW; + } } From d14121805abc9538b4187eaf394c9dfa6bed7bda Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 25 May 2022 15:41:33 +0200 Subject: [PATCH 21/73] Remove local rooms from history --- src/components/structures/MatrixChat.tsx | 11 +++++++- src/components/structures/RoomView.tsx | 6 +++- src/models/LocalRoom.ts | 36 +++++++++++------------- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 89e179bed3f..5a2ff4ce1f0 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -131,6 +131,7 @@ import { IConfigOptions } from "../../IConfigOptions"; import { SnakedObject } from "../../utils/SnakedObject"; import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; import VideoChannelStore from "../../stores/VideoChannelStore"; +import { LOCAL_ROOM_ID_PREFIX } from '../../models/LocalRoom'; // legacy export export { default as Views } from "../../Views"; @@ -900,7 +901,15 @@ export default class MatrixChat extends React.PureComponent { } // If we are redirecting to a Room Alias and it is for the room we already showing then replace history item - const replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId; + let replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId; + + if ( + !roomInfo.room_id.startsWith(LOCAL_ROOM_ID_PREFIX) + && this.state.currentRoomId.startsWith(LOCAL_ROOM_ID_PREFIX) + ) { + // Replace local room history items + replaceLast = true; + } if (roomInfo.room_id === this.state.currentRoomId) { // if we are re-viewing the same room then copy any state we already know diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 1ac1cb7fee6..4fb9a05f2bf 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -770,6 +770,10 @@ export class RoomView extends React.Component { for (const watcher of this.settingWatchers) { SettingsStore.unwatchSetting(watcher); } + + if (this.state.room instanceof LocalRoom) { + this.context.store.removeRoom(this.state.room.roomId); + } } private onRightPanelStoreUpdate = () => { @@ -2131,7 +2135,7 @@ export class RoomView extends React.Component { const mainClasses = classNames("mx_RoomView", { mx_RoomView_inCall: Boolean(activeCall), mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Video, - mx_RoomView_newLocal: this.state.room instanceof LocalRoom && this.state.room.isNew, + mx_RoomView_newLocal: this.state.room instanceof LocalRoom && this.state.room.isDraft, }); const showChatEffects = SettingsStore.getValue('showChatEffects'); diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index 79e96ef6170..2406c376548 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -22,9 +22,8 @@ import { Member, startDm } from "../utils/direct-messages"; export const LOCAL_ROOM_ID_PREFIX = 'local+'; export enum LocalRoomState { - NEW, - CREATING, - CREATED, + DRAFT, // local room created; only known to the client + CREATED, // room has been created via API; events applied } /** @@ -34,30 +33,27 @@ export enum LocalRoomState { export class LocalRoom extends Room { targets: Member[]; afterCreateCallbacks: Function[] = []; - state: LocalRoomState = LocalRoomState.NEW; + state: LocalRoomState = LocalRoomState.DRAFT; - public createRealRoom = async (client: MatrixClient) => { - if (!this.isNew) { + public async createRealRoom(client: MatrixClient) { + if (!this.isDraft) { return; } - this.state = LocalRoomState.CREATING; const roomId = await startDm(client, this.targets); - this.applyAfterCreateCallbacks(client, roomId); + await this.applyAfterCreateCallbacks(client, roomId); this.state = LocalRoomState.CREATED; - // MiW: failed hacky approach to replace the local room by the real one - //const room = client.getRoom(roomId); - //client.store.storeRoomId(this.roomId, room); - //} - }; - - public applyAfterCreateCallbacks = async (client: MatrixClient, roomId: string) => { - this.afterCreateCallbacks.forEach(async (afterCreateCallback) => { + } + + private async applyAfterCreateCallbacks(client: MatrixClient, roomId: string) { + for (const afterCreateCallback of this.afterCreateCallbacks) { await afterCreateCallback(client, roomId); - }); - }; + } + + this.afterCreateCallbacks = []; + } - public get isNew(): boolean { - return this.state === LocalRoomState.NEW; + public get isDraft(): boolean { + return this.state === LocalRoomState.DRAFT; } } From 9c117a0778385849753a3f8381893f0d7bf51d58 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 25 May 2022 16:04:36 +0200 Subject: [PATCH 22/73] Update translation files --- src/i18n/strings/en_EN.json | 9 +- src/i18n/strings/en_EN_orig.json | 3425 ++++++++++++++++++++++++++++++ 2 files changed, 3430 insertions(+), 4 deletions(-) create mode 100644 src/i18n/strings/en_EN_orig.json diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 86917dbb866..1a9e7237df2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1772,15 +1772,15 @@ "Room %(name)s": "Room %(name)s", "Recently visited rooms": "Recently visited rooms", "No recently visited rooms": "No recently visited rooms", - "(~%(count)s results)|other": "(~%(count)s results)", - "(~%(count)s results)|one": "(~%(count)s result)", - "Join Room": "Join Room", - "Room options": "Room options", "Forget room": "Forget room", "Hide Widgets": "Hide Widgets", "Show Widgets": "Show Widgets", "Search": "Search", "Invite": "Invite", + "Room options": "Room options", + "(~%(count)s results)|other": "(~%(count)s results)", + "(~%(count)s results)|one": "(~%(count)s result)", + "Join Room": "Join Room", "Video room": "Video room", "Public space": "Public space", "Public room": "Public room", @@ -2112,6 +2112,7 @@ "View Source": "View Source", "Some encryption parameters have been changed.": "Some encryption parameters have been changed.", "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.", + "Messages in this chat will be end-to-end encrypted.": "Messages in this chat will be end-to-end encrypted.", "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.", "Encryption enabled": "Encryption enabled", "Ignored attempt to disable encryption": "Ignored attempt to disable encryption", diff --git a/src/i18n/strings/en_EN_orig.json b/src/i18n/strings/en_EN_orig.json new file mode 100644 index 00000000000..86917dbb866 --- /dev/null +++ b/src/i18n/strings/en_EN_orig.json @@ -0,0 +1,3425 @@ +{ + "This email address is already in use": "This email address is already in use", + "This phone number is already in use": "This phone number is already in use", + "Use Single Sign On to continue": "Use Single Sign On to continue", + "Confirm adding this email address by using Single Sign On to prove your identity.": "Confirm adding this email address by using Single Sign On to prove your identity.", + "Single Sign On": "Single Sign On", + "Confirm adding email": "Confirm adding email", + "Click the button below to confirm adding this email address.": "Click the button below to confirm adding this email address.", + "Confirm": "Confirm", + "Add Email Address": "Add Email Address", + "Failed to verify email address: make sure you clicked the link in the email": "Failed to verify email address: make sure you clicked the link in the email", + "Confirm adding this phone number by using Single Sign On to prove your identity.": "Confirm adding this phone number by using Single Sign On to prove your identity.", + "Confirm adding phone number": "Confirm adding phone number", + "Click the button below to confirm adding this phone number.": "Click the button below to confirm adding this phone number.", + "Add Phone Number": "Add Phone Number", + "The platform you're on": "The platform you're on", + "The version of %(brand)s": "The version of %(brand)s", + "Whether or not you're logged in (we don't record your username)": "Whether or not you're logged in (we don't record your username)", + "Your language of choice": "Your language of choice", + "Which officially provided instance you are using, if any": "Which officially provided instance you are using, if any", + "Whether or not you're using the Richtext mode of the Rich Text Editor": "Whether or not you're using the Richtext mode of the Rich Text Editor", + "Your homeserver's URL": "Your homeserver's URL", + "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Whether you're using %(brand)s on a device where touch is the primary input mechanism", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)", + "Whether you're using %(brand)s as an installed Progressive Web App": "Whether you're using %(brand)s as an installed Progressive Web App", + "e.g. %(exampleValue)s": "e.g. %(exampleValue)s", + "Every page you use in the app": "Every page you use in the app", + "e.g. ": "e.g. ", + "Your user agent": "Your user agent", + "Your device resolution": "Your device resolution", + "Our complete cookie policy can be found here.": "Our complete cookie policy can be found here.", + "Analytics": "Analytics", + "Some examples of the information being sent to us to help make %(brand)s better includes:": "Some examples of the information being sent to us to help make %(brand)s better includes:", + "Where this page includes identifiable information, such as a room, user ID, that data is removed before being sent to the server.": "Where this page includes identifiable information, such as a room, user ID, that data is removed before being sent to the server.", + "Error": "Error", + "Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", + "Dismiss": "Dismiss", + "Call Failed": "Call Failed", + "User Busy": "User Busy", + "The user you called is busy.": "The user you called is busy.", + "The call could not be established": "The call could not be established", + "Answered Elsewhere": "Answered Elsewhere", + "The call was answered on another device.": "The call was answered on another device.", + "Call failed due to misconfigured server": "Call failed due to misconfigured server", + "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.", + "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.", + "Try using turn.matrix.org": "Try using turn.matrix.org", + "OK": "OK", + "Unable to access microphone": "Unable to access microphone", + "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.", + "Unable to access webcam / microphone": "Unable to access webcam / microphone", + "Call failed because webcam or microphone could not be accessed. Check that:": "Call failed because webcam or microphone could not be accessed. Check that:", + "A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly", + "Permission is granted to use the webcam": "Permission is granted to use the webcam", + "No other application is using the webcam": "No other application is using the webcam", + "Already in call": "Already in call", + "You're already in a call with this person.": "You're already in a call with this person.", + "Calls are unsupported": "Calls are unsupported", + "You cannot place calls in this browser.": "You cannot place calls in this browser.", + "Connectivity to the server has been lost": "Connectivity to the server has been lost", + "You cannot place calls without a connection to the server.": "You cannot place calls without a connection to the server.", + "Too Many Calls": "Too Many Calls", + "You've reached the maximum number of simultaneous calls.": "You've reached the maximum number of simultaneous calls.", + "You cannot place a call with yourself.": "You cannot place a call with yourself.", + "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", + "Unable to transfer call": "Unable to transfer call", + "Transfer Failed": "Transfer Failed", + "Failed to transfer call": "Failed to transfer call", + "Permission Required": "Permission Required", + "You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room", + "End conference": "End conference", + "This will end the conference for everyone. Continue?": "This will end the conference for everyone. Continue?", + "The file '%(fileName)s' failed to upload.": "The file '%(fileName)s' failed to upload.", + "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", + "Upload Failed": "Upload Failed", + "Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.", + "The server does not support the room version specified.": "The server does not support the room version specified.", + "Failure to create room": "Failure to create room", + "Sun": "Sun", + "Mon": "Mon", + "Tue": "Tue", + "Wed": "Wed", + "Thu": "Thu", + "Fri": "Fri", + "Sat": "Sat", + "Jan": "Jan", + "Feb": "Feb", + "Mar": "Mar", + "Apr": "Apr", + "May": "May", + "Jun": "Jun", + "Jul": "Jul", + "Aug": "Aug", + "Sep": "Sep", + "Oct": "Oct", + "Nov": "Nov", + "Dec": "Dec", + "PM": "PM", + "AM": "AM", + "%(weekDayName)s %(time)s": "%(weekDayName)s %(time)s", + "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(time)s", + "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s", + "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s", + "%(date)s at %(time)s": "%(date)s at %(time)s", + "%(value)sd": "%(value)sd", + "%(value)sh": "%(value)sh", + "%(value)sm": "%(value)sm", + "%(value)ss": "%(value)ss", + "That link is no longer supported": "That link is no longer supported", + "You're trying to access a community link (%(groupId)s).
Communities are no longer supported and have been replaced by spaces.Learn more about spaces here.": "You're trying to access a community link (%(groupId)s).
Communities are no longer supported and have been replaced by spaces.Learn more about spaces here.", + "Identity server has no terms of service": "Identity server has no terms of service", + "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.", + "Only continue if you trust the owner of the server.": "Only continue if you trust the owner of the server.", + "Trust": "Trust", + "We couldn't log you in": "We couldn't log you in", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.", + "Try again": "Try again", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.", + "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.", + "%(name)s is requesting verification": "%(name)s is requesting verification", + "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s does not have permission to send you notifications - please check your browser settings", + "%(brand)s was not given permission to send notifications - please try again": "%(brand)s was not given permission to send notifications - please try again", + "Unable to enable Notifications": "Unable to enable Notifications", + "This email address was not found": "This email address was not found", + "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "Your email address does not appear to be associated with a Matrix ID on this Homeserver.", + "United Kingdom": "United Kingdom", + "United States": "United States", + "Afghanistan": "Afghanistan", + "Åland Islands": "Åland Islands", + "Albania": "Albania", + "Algeria": "Algeria", + "American Samoa": "American Samoa", + "Andorra": "Andorra", + "Angola": "Angola", + "Anguilla": "Anguilla", + "Antarctica": "Antarctica", + "Antigua & Barbuda": "Antigua & Barbuda", + "Argentina": "Argentina", + "Armenia": "Armenia", + "Aruba": "Aruba", + "Australia": "Australia", + "Austria": "Austria", + "Azerbaijan": "Azerbaijan", + "Bahamas": "Bahamas", + "Bahrain": "Bahrain", + "Bangladesh": "Bangladesh", + "Barbados": "Barbados", + "Belarus": "Belarus", + "Belgium": "Belgium", + "Belize": "Belize", + "Benin": "Benin", + "Bermuda": "Bermuda", + "Bhutan": "Bhutan", + "Bolivia": "Bolivia", + "Bosnia": "Bosnia", + "Botswana": "Botswana", + "Bouvet Island": "Bouvet Island", + "Brazil": "Brazil", + "British Indian Ocean Territory": "British Indian Ocean Territory", + "British Virgin Islands": "British Virgin Islands", + "Brunei": "Brunei", + "Bulgaria": "Bulgaria", + "Burkina Faso": "Burkina Faso", + "Burundi": "Burundi", + "Cambodia": "Cambodia", + "Cameroon": "Cameroon", + "Canada": "Canada", + "Cape Verde": "Cape Verde", + "Caribbean Netherlands": "Caribbean Netherlands", + "Cayman Islands": "Cayman Islands", + "Central African Republic": "Central African Republic", + "Chad": "Chad", + "Chile": "Chile", + "China": "China", + "Christmas Island": "Christmas Island", + "Cocos (Keeling) Islands": "Cocos (Keeling) Islands", + "Colombia": "Colombia", + "Comoros": "Comoros", + "Congo - Brazzaville": "Congo - Brazzaville", + "Congo - Kinshasa": "Congo - Kinshasa", + "Cook Islands": "Cook Islands", + "Costa Rica": "Costa Rica", + "Croatia": "Croatia", + "Cuba": "Cuba", + "Curaçao": "Curaçao", + "Cyprus": "Cyprus", + "Czech Republic": "Czech Republic", + "Côte d’Ivoire": "Côte d’Ivoire", + "Denmark": "Denmark", + "Djibouti": "Djibouti", + "Dominica": "Dominica", + "Dominican Republic": "Dominican Republic", + "Ecuador": "Ecuador", + "Egypt": "Egypt", + "El Salvador": "El Salvador", + "Equatorial Guinea": "Equatorial Guinea", + "Eritrea": "Eritrea", + "Estonia": "Estonia", + "Ethiopia": "Ethiopia", + "Falkland Islands": "Falkland Islands", + "Faroe Islands": "Faroe Islands", + "Fiji": "Fiji", + "Finland": "Finland", + "France": "France", + "French Guiana": "French Guiana", + "French Polynesia": "French Polynesia", + "French Southern Territories": "French Southern Territories", + "Gabon": "Gabon", + "Gambia": "Gambia", + "Georgia": "Georgia", + "Germany": "Germany", + "Ghana": "Ghana", + "Gibraltar": "Gibraltar", + "Greece": "Greece", + "Greenland": "Greenland", + "Grenada": "Grenada", + "Guadeloupe": "Guadeloupe", + "Guam": "Guam", + "Guatemala": "Guatemala", + "Guernsey": "Guernsey", + "Guinea": "Guinea", + "Guinea-Bissau": "Guinea-Bissau", + "Guyana": "Guyana", + "Haiti": "Haiti", + "Heard & McDonald Islands": "Heard & McDonald Islands", + "Honduras": "Honduras", + "Hong Kong": "Hong Kong", + "Hungary": "Hungary", + "Iceland": "Iceland", + "India": "India", + "Indonesia": "Indonesia", + "Iran": "Iran", + "Iraq": "Iraq", + "Ireland": "Ireland", + "Isle of Man": "Isle of Man", + "Israel": "Israel", + "Italy": "Italy", + "Jamaica": "Jamaica", + "Japan": "Japan", + "Jersey": "Jersey", + "Jordan": "Jordan", + "Kazakhstan": "Kazakhstan", + "Kenya": "Kenya", + "Kiribati": "Kiribati", + "Kosovo": "Kosovo", + "Kuwait": "Kuwait", + "Kyrgyzstan": "Kyrgyzstan", + "Laos": "Laos", + "Latvia": "Latvia", + "Lebanon": "Lebanon", + "Lesotho": "Lesotho", + "Liberia": "Liberia", + "Libya": "Libya", + "Liechtenstein": "Liechtenstein", + "Lithuania": "Lithuania", + "Luxembourg": "Luxembourg", + "Macau": "Macau", + "Macedonia": "Macedonia", + "Madagascar": "Madagascar", + "Malawi": "Malawi", + "Malaysia": "Malaysia", + "Maldives": "Maldives", + "Mali": "Mali", + "Malta": "Malta", + "Marshall Islands": "Marshall Islands", + "Martinique": "Martinique", + "Mauritania": "Mauritania", + "Mauritius": "Mauritius", + "Mayotte": "Mayotte", + "Mexico": "Mexico", + "Micronesia": "Micronesia", + "Moldova": "Moldova", + "Monaco": "Monaco", + "Mongolia": "Mongolia", + "Montenegro": "Montenegro", + "Montserrat": "Montserrat", + "Morocco": "Morocco", + "Mozambique": "Mozambique", + "Myanmar": "Myanmar", + "Namibia": "Namibia", + "Nauru": "Nauru", + "Nepal": "Nepal", + "Netherlands": "Netherlands", + "New Caledonia": "New Caledonia", + "New Zealand": "New Zealand", + "Nicaragua": "Nicaragua", + "Niger": "Niger", + "Nigeria": "Nigeria", + "Niue": "Niue", + "Norfolk Island": "Norfolk Island", + "North Korea": "North Korea", + "Northern Mariana Islands": "Northern Mariana Islands", + "Norway": "Norway", + "Oman": "Oman", + "Pakistan": "Pakistan", + "Palau": "Palau", + "Palestine": "Palestine", + "Panama": "Panama", + "Papua New Guinea": "Papua New Guinea", + "Paraguay": "Paraguay", + "Peru": "Peru", + "Philippines": "Philippines", + "Pitcairn Islands": "Pitcairn Islands", + "Poland": "Poland", + "Portugal": "Portugal", + "Puerto Rico": "Puerto Rico", + "Qatar": "Qatar", + "Romania": "Romania", + "Russia": "Russia", + "Rwanda": "Rwanda", + "Réunion": "Réunion", + "Samoa": "Samoa", + "San Marino": "San Marino", + "Saudi Arabia": "Saudi Arabia", + "Senegal": "Senegal", + "Serbia": "Serbia", + "Seychelles": "Seychelles", + "Sierra Leone": "Sierra Leone", + "Singapore": "Singapore", + "Sint Maarten": "Sint Maarten", + "Slovakia": "Slovakia", + "Slovenia": "Slovenia", + "Solomon Islands": "Solomon Islands", + "Somalia": "Somalia", + "South Africa": "South Africa", + "South Georgia & South Sandwich Islands": "South Georgia & South Sandwich Islands", + "South Korea": "South Korea", + "South Sudan": "South Sudan", + "Spain": "Spain", + "Sri Lanka": "Sri Lanka", + "St. Barthélemy": "St. Barthélemy", + "St. Helena": "St. Helena", + "St. Kitts & Nevis": "St. Kitts & Nevis", + "St. Lucia": "St. Lucia", + "St. Martin": "St. Martin", + "St. Pierre & Miquelon": "St. Pierre & Miquelon", + "St. Vincent & Grenadines": "St. Vincent & Grenadines", + "Sudan": "Sudan", + "Suriname": "Suriname", + "Svalbard & Jan Mayen": "Svalbard & Jan Mayen", + "Swaziland": "Swaziland", + "Sweden": "Sweden", + "Switzerland": "Switzerland", + "Syria": "Syria", + "São Tomé & Príncipe": "São Tomé & Príncipe", + "Taiwan": "Taiwan", + "Tajikistan": "Tajikistan", + "Tanzania": "Tanzania", + "Thailand": "Thailand", + "Timor-Leste": "Timor-Leste", + "Togo": "Togo", + "Tokelau": "Tokelau", + "Tonga": "Tonga", + "Trinidad & Tobago": "Trinidad & Tobago", + "Tunisia": "Tunisia", + "Turkey": "Turkey", + "Turkmenistan": "Turkmenistan", + "Turks & Caicos Islands": "Turks & Caicos Islands", + "Tuvalu": "Tuvalu", + "U.S. Virgin Islands": "U.S. Virgin Islands", + "Uganda": "Uganda", + "Ukraine": "Ukraine", + "United Arab Emirates": "United Arab Emirates", + "Uruguay": "Uruguay", + "Uzbekistan": "Uzbekistan", + "Vanuatu": "Vanuatu", + "Vatican City": "Vatican City", + "Venezuela": "Venezuela", + "Vietnam": "Vietnam", + "Wallis & Futuna": "Wallis & Futuna", + "Western Sahara": "Western Sahara", + "Yemen": "Yemen", + "Zambia": "Zambia", + "Zimbabwe": "Zimbabwe", + "Sign In or Create Account": "Sign In or Create Account", + "Use your account or create a new one to continue.": "Use your account or create a new one to continue.", + "Create Account": "Create Account", + "Sign In": "Sign In", + "Default": "Default", + "Restricted": "Restricted", + "Moderator": "Moderator", + "Admin": "Admin", + "Custom (%(level)s)": "Custom (%(level)s)", + "Failed to invite": "Failed to invite", + "Operation failed": "Operation failed", + "Failed to invite users to %(roomName)s": "Failed to invite users to %(roomName)s", + "We sent the others, but the below people couldn't be invited to ": "We sent the others, but the below people couldn't be invited to ", + "Some invites couldn't be sent": "Some invites couldn't be sent", + "You need to be logged in.": "You need to be logged in.", + "You need to be able to invite users to do that.": "You need to be able to invite users to do that.", + "Unable to create widget.": "Unable to create widget.", + "Missing roomId.": "Missing roomId.", + "Failed to send request.": "Failed to send request.", + "This room is not recognised.": "This room is not recognised.", + "Power level must be positive integer.": "Power level must be positive integer.", + "You are not in this room.": "You are not in this room.", + "You do not have permission to do that in this room.": "You do not have permission to do that in this room.", + "Missing room_id in request": "Missing room_id in request", + "Room %(roomId)s not visible": "Room %(roomId)s not visible", + "Missing user_id in request": "Missing user_id in request", + "Cancel entering passphrase?": "Cancel entering passphrase?", + "Are you sure you want to cancel entering passphrase?": "Are you sure you want to cancel entering passphrase?", + "Go Back": "Go Back", + "Cancel": "Cancel", + "Setting up keys": "Setting up keys", + "Messages": "Messages", + "Actions": "Actions", + "Advanced": "Advanced", + "Effects": "Effects", + "Other": "Other", + "Command error: Unable to handle slash command.": "Command error: Unable to handle slash command.", + "Command error: Unable to find rendering type (%(renderingType)s)": "Command error: Unable to find rendering type (%(renderingType)s)", + "Usage": "Usage", + "Sends the given message as a spoiler": "Sends the given message as a spoiler", + "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Prepends ¯\\_(ツ)_/¯ to a plain-text message", + "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message", + "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message", + "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message", + "Sends a message as plain text, without interpreting it as markdown": "Sends a message as plain text, without interpreting it as markdown", + "Sends a message as html, without interpreting it as markdown": "Sends a message as html, without interpreting it as markdown", + "Upgrades a room to a new version": "Upgrades a room to a new version", + "You do not have the required permissions to use this command.": "You do not have the required permissions to use this command.", + "Jump to the given date in the timeline": "Jump to the given date in the timeline", + "We were unable to understand the given date (%(inputDate)s). Try using the format YYYY-MM-DD.": "We were unable to understand the given date (%(inputDate)s). Try using the format YYYY-MM-DD.", + "Changes your display nickname": "Changes your display nickname", + "Changes your display nickname in the current room only": "Changes your display nickname in the current room only", + "Changes the avatar of the current room": "Changes the avatar of the current room", + "Changes your avatar in this current room only": "Changes your avatar in this current room only", + "Changes your avatar in all rooms": "Changes your avatar in all rooms", + "Gets or sets the room topic": "Gets or sets the room topic", + "Failed to get room topic: Unable to find room (%(roomId)s": "Failed to get room topic: Unable to find room (%(roomId)s", + "This room has no topic.": "This room has no topic.", + "Sets the room name": "Sets the room name", + "Invites user with given id to current room": "Invites user with given id to current room", + "Use an identity server": "Use an identity server", + "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.", + "Continue": "Continue", + "Use an identity server to invite by email. Manage in Settings.": "Use an identity server to invite by email. Manage in Settings.", + "Joins room with given address": "Joins room with given address", + "Leave room": "Leave room", + "Unrecognised room address: %(roomAlias)s": "Unrecognised room address: %(roomAlias)s", + "Removes user with given id from this room": "Removes user with given id from this room", + "Bans user with given id": "Bans user with given id", + "Unbans user with given ID": "Unbans user with given ID", + "Ignores a user, hiding their messages from you": "Ignores a user, hiding their messages from you", + "Ignored user": "Ignored user", + "You are now ignoring %(userId)s": "You are now ignoring %(userId)s", + "Stops ignoring a user, showing their messages going forward": "Stops ignoring a user, showing their messages going forward", + "Unignored user": "Unignored user", + "You are no longer ignoring %(userId)s": "You are no longer ignoring %(userId)s", + "Define the power level of a user": "Define the power level of a user", + "Command failed: Unable to find room (%(roomId)s": "Command failed: Unable to find room (%(roomId)s", + "Could not find user in room": "Could not find user in room", + "Deops user with given id": "Deops user with given id", + "Opens the Developer Tools dialog": "Opens the Developer Tools dialog", + "Adds a custom widget by URL to the room": "Adds a custom widget by URL to the room", + "Please supply a widget URL or embed code": "Please supply a widget URL or embed code", + "Please supply a https:// or http:// widget URL": "Please supply a https:// or http:// widget URL", + "You cannot modify widgets in this room.": "You cannot modify widgets in this room.", + "Verifies a user, session, and pubkey tuple": "Verifies a user, session, and pubkey tuple", + "Unknown (user, session) pair: (%(userId)s, %(deviceId)s)": "Unknown (user, session) pair: (%(userId)s, %(deviceId)s)", + "Session already verified!": "Session already verified!", + "WARNING: Session already verified, but keys do NOT MATCH!": "WARNING: Session already verified, but keys do NOT MATCH!", + "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!", + "Verified key": "Verified key", + "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.": "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.", + "Forces the current outbound group session in an encrypted room to be discarded": "Forces the current outbound group session in an encrypted room to be discarded", + "Sends the given message coloured as a rainbow": "Sends the given message coloured as a rainbow", + "Sends the given emote coloured as a rainbow": "Sends the given emote coloured as a rainbow", + "Displays list of commands with usages and descriptions": "Displays list of commands with usages and descriptions", + "Displays information about a user": "Displays information about a user", + "Send a bug report with logs": "Send a bug report with logs", + "Switches to this room's virtual room, if it has one": "Switches to this room's virtual room, if it has one", + "No virtual room for this room": "No virtual room for this room", + "Opens chat with the given user": "Opens chat with the given user", + "Unable to find Matrix ID for phone number": "Unable to find Matrix ID for phone number", + "Sends a message to the given user": "Sends a message to the given user", + "Places the call in the current room on hold": "Places the call in the current room on hold", + "No active call in this room": "No active call in this room", + "Takes the call in the current room off hold": "Takes the call in the current room off hold", + "Converts the room to a DM": "Converts the room to a DM", + "Converts the DM to a room": "Converts the DM to a room", + "Displays action": "Displays action", + "Someone": "Someone", + "%(senderName)s placed a voice call.": "%(senderName)s placed a voice call.", + "%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s placed a voice call. (not supported by this browser)", + "%(senderName)s placed a video call.": "%(senderName)s placed a video call.", + "%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s placed a video call. (not supported by this browser)", + "%(targetName)s accepted the invitation for %(displayName)s": "%(targetName)s accepted the invitation for %(displayName)s", + "%(targetName)s accepted an invitation": "%(targetName)s accepted an invitation", + "%(senderName)s invited %(targetName)s": "%(senderName)s invited %(targetName)s", + "%(senderName)s banned %(targetName)s: %(reason)s": "%(senderName)s banned %(targetName)s: %(reason)s", + "%(senderName)s banned %(targetName)s": "%(senderName)s banned %(targetName)s", + "%(oldDisplayName)s changed their display name to %(displayName)s": "%(oldDisplayName)s changed their display name to %(displayName)s", + "%(senderName)s set their display name to %(displayName)s": "%(senderName)s set their display name to %(displayName)s", + "%(senderName)s removed their display name (%(oldDisplayName)s)": "%(senderName)s removed their display name (%(oldDisplayName)s)", + "%(senderName)s removed their profile picture": "%(senderName)s removed their profile picture", + "%(senderName)s changed their profile picture": "%(senderName)s changed their profile picture", + "%(senderName)s set a profile picture": "%(senderName)s set a profile picture", + "%(senderName)s made no change": "%(senderName)s made no change", + "%(targetName)s joined the room": "%(targetName)s joined the room", + "%(targetName)s rejected the invitation": "%(targetName)s rejected the invitation", + "%(targetName)s left the room: %(reason)s": "%(targetName)s left the room: %(reason)s", + "%(targetName)s left the room": "%(targetName)s left the room", + "%(senderName)s unbanned %(targetName)s": "%(senderName)s unbanned %(targetName)s", + "%(senderName)s withdrew %(targetName)s's invitation: %(reason)s": "%(senderName)s withdrew %(targetName)s's invitation: %(reason)s", + "%(senderName)s withdrew %(targetName)s's invitation": "%(senderName)s withdrew %(targetName)s's invitation", + "%(senderName)s removed %(targetName)s: %(reason)s": "%(senderName)s removed %(targetName)s: %(reason)s", + "%(senderName)s removed %(targetName)s": "%(senderName)s removed %(targetName)s", + "%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s changed the topic to \"%(topic)s\".", + "%(senderDisplayName)s changed the room avatar.": "%(senderDisplayName)s changed the room avatar.", + "%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s removed the room name.", + "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.", + "%(senderDisplayName)s changed the room name to %(roomName)s.": "%(senderDisplayName)s changed the room name to %(roomName)s.", + "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s upgraded this room.", + "%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s made the room public to whoever knows the link.", + "%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s made the room invite only.", + "%(senderDisplayName)s changed who can join this room. View settings.": "%(senderDisplayName)s changed who can join this room. View settings.", + "%(senderDisplayName)s changed who can join this room.": "%(senderDisplayName)s changed who can join this room.", + "%(senderDisplayName)s changed the join rule to %(rule)s": "%(senderDisplayName)s changed the join rule to %(rule)s", + "%(senderDisplayName)s has allowed guests to join the room.": "%(senderDisplayName)s has allowed guests to join the room.", + "%(senderDisplayName)s has prevented guests from joining the room.": "%(senderDisplayName)s has prevented guests from joining the room.", + "%(senderDisplayName)s changed guest access to %(rule)s": "%(senderDisplayName)s changed guest access to %(rule)s", + "%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s set the server ACLs for this room.", + "%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s changed the server ACLs for this room.", + "🎉 All servers are banned from participating! This room can no longer be used.": "🎉 All servers are banned from participating! This room can no longer be used.", + "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.", + "%(senderDisplayName)s sent a sticker.": "%(senderDisplayName)s sent a sticker.", + "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.", + "%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.", + "%(senderName)s added the alternative addresses %(addresses)s for this room.|other": "%(senderName)s added the alternative addresses %(addresses)s for this room.", + "%(senderName)s added the alternative addresses %(addresses)s for this room.|one": "%(senderName)s added alternative address %(addresses)s for this room.", + "%(senderName)s removed the alternative addresses %(addresses)s for this room.|other": "%(senderName)s removed the alternative addresses %(addresses)s for this room.", + "%(senderName)s removed the alternative addresses %(addresses)s for this room.|one": "%(senderName)s removed alternative address %(addresses)s for this room.", + "%(senderName)s changed the alternative addresses for this room.": "%(senderName)s changed the alternative addresses for this room.", + "%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s changed the main and alternative addresses for this room.", + "%(senderName)s changed the addresses for this room.": "%(senderName)s changed the addresses for this room.", + "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.", + "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.", + "%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s made future room history visible to all room members, from the point they are invited.", + "%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s made future room history visible to all room members, from the point they joined.", + "%(senderName)s made future room history visible to all room members.": "%(senderName)s made future room history visible to all room members.", + "%(senderName)s made future room history visible to anyone.": "%(senderName)s made future room history visible to anyone.", + "%(senderName)s made future room history visible to unknown (%(visibility)s).": "%(senderName)s made future room history visible to unknown (%(visibility)s).", + "%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s changed the power level of %(powerLevelDiffText)s.", + "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s": "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s", + "%(senderName)s pinned a message to this room. See all pinned messages.": "%(senderName)s pinned a message to this room. See all pinned messages.", + "%(senderName)s pinned a message to this room. See all pinned messages.": "%(senderName)s pinned a message to this room. See all pinned messages.", + "%(senderName)s unpinned a message from this room. See all pinned messages.": "%(senderName)s unpinned a message from this room. See all pinned messages.", + "%(senderName)s unpinned a message from this room. See all pinned messages.": "%(senderName)s unpinned a message from this room. See all pinned messages.", + "%(senderName)s changed the pinned messages for the room.": "%(senderName)s changed the pinned messages for the room.", + "%(senderName)s changed the pinned messages for the room.": "%(senderName)s changed the pinned messages for the room.", + "%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s widget modified by %(senderName)s", + "%(widgetName)s widget added by %(senderName)s": "%(widgetName)s widget added by %(senderName)s", + "%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s", + "%(senderName)s has updated the room layout": "%(senderName)s has updated the room layout", + "%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s removed the rule banning users matching %(glob)s", + "%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s removed the rule banning rooms matching %(glob)s", + "%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s removed the rule banning servers matching %(glob)s", + "%(senderName)s removed a ban rule matching %(glob)s": "%(senderName)s removed a ban rule matching %(glob)s", + "%(senderName)s updated an invalid ban rule": "%(senderName)s updated an invalid ban rule", + "%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s": "%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s", + "%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s", + "%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s", + "%(senderName)s updated a ban rule matching %(glob)s for %(reason)s": "%(senderName)s updated a ban rule matching %(glob)s for %(reason)s", + "%(senderName)s created a rule banning users matching %(glob)s for %(reason)s": "%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", + "%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", + "%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", + "%(senderName)s created a ban rule matching %(glob)s for %(reason)s": "%(senderName)s created a ban rule matching %(glob)s for %(reason)s", + "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", + "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", + "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", + "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", + "%(senderName)s has shared their location": "%(senderName)s has shared their location", + "Message deleted": "Message deleted", + "Message deleted by %(name)s": "Message deleted by %(name)s", + "%(senderName)s has started a poll - %(pollQuestion)s": "%(senderName)s has started a poll - %(pollQuestion)s", + "%(senderName)s has ended a poll": "%(senderName)s has ended a poll", + "Light": "Light", + "Light high contrast": "Light high contrast", + "Dark": "Dark", + "%(displayName)s is typing …": "%(displayName)s is typing …", + "%(names)s and %(count)s others are typing …|other": "%(names)s and %(count)s others are typing …", + "%(names)s and %(count)s others are typing …|one": "%(names)s and one other is typing …", + "%(names)s and %(lastPerson)s are typing …": "%(names)s and %(lastPerson)s are typing …", + "Remain on your screen when viewing another room, when running": "Remain on your screen when viewing another room, when running", + "Remain on your screen while running": "Remain on your screen while running", + "Send stickers into this room": "Send stickers into this room", + "Send stickers into your active room": "Send stickers into your active room", + "Change which room you're viewing": "Change which room you're viewing", + "Change which room, message, or user you're viewing": "Change which room, message, or user you're viewing", + "Change the topic of this room": "Change the topic of this room", + "See when the topic changes in this room": "See when the topic changes in this room", + "Change the topic of your active room": "Change the topic of your active room", + "See when the topic changes in your active room": "See when the topic changes in your active room", + "Change the name of this room": "Change the name of this room", + "See when the name changes in this room": "See when the name changes in this room", + "Change the name of your active room": "Change the name of your active room", + "See when the name changes in your active room": "See when the name changes in your active room", + "Change the avatar of this room": "Change the avatar of this room", + "See when the avatar changes in this room": "See when the avatar changes in this room", + "Change the avatar of your active room": "Change the avatar of your active room", + "See when the avatar changes in your active room": "See when the avatar changes in your active room", + "Remove, ban, or invite people to this room, and make you leave": "Remove, ban, or invite people to this room, and make you leave", + "See when people join, leave, or are invited to this room": "See when people join, leave, or are invited to this room", + "Remove, ban, or invite people to your active room, and make you leave": "Remove, ban, or invite people to your active room, and make you leave", + "See when people join, leave, or are invited to your active room": "See when people join, leave, or are invited to your active room", + "Send stickers to this room as you": "Send stickers to this room as you", + "See when a sticker is posted in this room": "See when a sticker is posted in this room", + "Send stickers to your active room as you": "Send stickers to your active room as you", + "See when anyone posts a sticker to your active room": "See when anyone posts a sticker to your active room", + "with an empty state key": "with an empty state key", + "with state key %(stateKey)s": "with state key %(stateKey)s", + "The above, but in any room you are joined or invited to as well": "The above, but in any room you are joined or invited to as well", + "The above, but in as well": "The above, but in as well", + "Send %(eventType)s events as you in this room": "Send %(eventType)s events as you in this room", + "See %(eventType)s events posted to this room": "See %(eventType)s events posted to this room", + "Send %(eventType)s events as you in your active room": "Send %(eventType)s events as you in your active room", + "See %(eventType)s events posted to your active room": "See %(eventType)s events posted to your active room", + "The %(capability)s capability": "The %(capability)s capability", + "Send messages as you in this room": "Send messages as you in this room", + "Send messages as you in your active room": "Send messages as you in your active room", + "See messages posted to this room": "See messages posted to this room", + "See messages posted to your active room": "See messages posted to your active room", + "Send text messages as you in this room": "Send text messages as you in this room", + "Send text messages as you in your active room": "Send text messages as you in your active room", + "See text messages posted to this room": "See text messages posted to this room", + "See text messages posted to your active room": "See text messages posted to your active room", + "Send emotes as you in this room": "Send emotes as you in this room", + "Send emotes as you in your active room": "Send emotes as you in your active room", + "See emotes posted to this room": "See emotes posted to this room", + "See emotes posted to your active room": "See emotes posted to your active room", + "Send images as you in this room": "Send images as you in this room", + "Send images as you in your active room": "Send images as you in your active room", + "See images posted to this room": "See images posted to this room", + "See images posted to your active room": "See images posted to your active room", + "Send videos as you in this room": "Send videos as you in this room", + "Send videos as you in your active room": "Send videos as you in your active room", + "See videos posted to this room": "See videos posted to this room", + "See videos posted to your active room": "See videos posted to your active room", + "Send general files as you in this room": "Send general files as you in this room", + "Send general files as you in your active room": "Send general files as you in your active room", + "See general files posted to this room": "See general files posted to this room", + "See general files posted to your active room": "See general files posted to your active room", + "Send %(msgtype)s messages as you in this room": "Send %(msgtype)s messages as you in this room", + "Send %(msgtype)s messages as you in your active room": "Send %(msgtype)s messages as you in your active room", + "See %(msgtype)s messages posted to this room": "See %(msgtype)s messages posted to this room", + "See %(msgtype)s messages posted to your active room": "See %(msgtype)s messages posted to your active room", + "Cannot reach homeserver": "Cannot reach homeserver", + "Ensure you have a stable internet connection, or get in touch with the server admin": "Ensure you have a stable internet connection, or get in touch with the server admin", + "Your %(brand)s is misconfigured": "Your %(brand)s is misconfigured", + "Ask your %(brand)s admin to check your config for incorrect or duplicate entries.": "Ask your %(brand)s admin to check your config for incorrect or duplicate entries.", + "Cannot reach identity server": "Cannot reach identity server", + "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.", + "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.", + "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.", + "No homeserver URL provided": "No homeserver URL provided", + "Unexpected error resolving homeserver configuration": "Unexpected error resolving homeserver configuration", + "Unexpected error resolving identity server configuration": "Unexpected error resolving identity server configuration", + "This homeserver has hit its Monthly Active User limit.": "This homeserver has hit its Monthly Active User limit.", + "This homeserver has been blocked by its administrator.": "This homeserver has been blocked by its administrator.", + "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", + "Please contact your service administrator to continue using the service.": "Please contact your service administrator to continue using the service.", + "Unable to connect to Homeserver. Retrying...": "Unable to connect to Homeserver. Retrying...", + "Attachment": "Attachment", + "%(items)s and %(count)s others|other": "%(items)s and %(count)s others", + "%(items)s and %(count)s others|one": "%(items)s and one other", + "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", + "a few seconds ago": "a few seconds ago", + "about a minute ago": "about a minute ago", + "%(num)s minutes ago": "%(num)s minutes ago", + "about an hour ago": "about an hour ago", + "%(num)s hours ago": "%(num)s hours ago", + "about a day ago": "about a day ago", + "%(num)s days ago": "%(num)s days ago", + "a few seconds from now": "a few seconds from now", + "about a minute from now": "about a minute from now", + "%(num)s minutes from now": "%(num)s minutes from now", + "about an hour from now": "about an hour from now", + "%(num)s hours from now": "%(num)s hours from now", + "about a day from now": "about a day from now", + "%(num)s days from now": "%(num)s days from now", + "%(space1Name)s and %(space2Name)s": "%(space1Name)s and %(space2Name)s", + "%(spaceName)s and %(count)s others|other": "%(spaceName)s and %(count)s others", + "%(spaceName)s and %(count)s others|zero": "%(spaceName)s", + "%(spaceName)s and %(count)s others|one": "%(spaceName)s and %(count)s other", + "%(name)s (%(userId)s)": "%(name)s (%(userId)s)", + "Unexpected server error trying to leave the room": "Unexpected server error trying to leave the room", + "Can't leave Server Notices room": "Can't leave Server Notices room", + "This room is used for important messages from the Homeserver, so you cannot leave it.": "This room is used for important messages from the Homeserver, so you cannot leave it.", + "Error leaving room": "Error leaving room", + "Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions", + "Not a valid %(brand)s keyfile": "Not a valid %(brand)s keyfile", + "Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?", + "Unrecognised address": "Unrecognised address", + "You do not have permission to invite people to this space.": "You do not have permission to invite people to this space.", + "You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.", + "User is already invited to the space": "User is already invited to the space", + "User is already invited to the room": "User is already invited to the room", + "User is already in the space": "User is already in the space", + "User is already in the room": "User is already in the room", + "User does not exist": "User does not exist", + "User may or may not exist": "User may or may not exist", + "The user must be unbanned before they can be invited.": "The user must be unbanned before they can be invited.", + "The user's homeserver does not support the version of the space.": "The user's homeserver does not support the version of the space.", + "The user's homeserver does not support the version of the room.": "The user's homeserver does not support the version of the room.", + "Unknown server error": "Unknown server error", + "Use a few words, avoid common phrases": "Use a few words, avoid common phrases", + "No need for symbols, digits, or uppercase letters": "No need for symbols, digits, or uppercase letters", + "Use a longer keyboard pattern with more turns": "Use a longer keyboard pattern with more turns", + "Avoid repeated words and characters": "Avoid repeated words and characters", + "Avoid sequences": "Avoid sequences", + "Avoid recent years": "Avoid recent years", + "Avoid years that are associated with you": "Avoid years that are associated with you", + "Avoid dates and years that are associated with you": "Avoid dates and years that are associated with you", + "Capitalization doesn't help very much": "Capitalization doesn't help very much", + "All-uppercase is almost as easy to guess as all-lowercase": "All-uppercase is almost as easy to guess as all-lowercase", + "Reversed words aren't much harder to guess": "Reversed words aren't much harder to guess", + "Predictable substitutions like '@' instead of 'a' don't help very much": "Predictable substitutions like '@' instead of 'a' don't help very much", + "Add another word or two. Uncommon words are better.": "Add another word or two. Uncommon words are better.", + "Repeats like \"aaa\" are easy to guess": "Repeats like \"aaa\" are easy to guess", + "Repeats like \"abcabcabc\" are only slightly harder to guess than \"abc\"": "Repeats like \"abcabcabc\" are only slightly harder to guess than \"abc\"", + "Sequences like abc or 6543 are easy to guess": "Sequences like abc or 6543 are easy to guess", + "Recent years are easy to guess": "Recent years are easy to guess", + "Dates are often easy to guess": "Dates are often easy to guess", + "This is a top-10 common password": "This is a top-10 common password", + "This is a top-100 common password": "This is a top-100 common password", + "This is a very common password": "This is a very common password", + "This is similar to a commonly used password": "This is similar to a commonly used password", + "A word by itself is easy to guess": "A word by itself is easy to guess", + "Names and surnames by themselves are easy to guess": "Names and surnames by themselves are easy to guess", + "Common names and surnames are easy to guess": "Common names and surnames are easy to guess", + "Straight rows of keys are easy to guess": "Straight rows of keys are easy to guess", + "Short keyboard patterns are easy to guess": "Short keyboard patterns are easy to guess", + "Unnamed room": "Unnamed room", + "Unable to join network": "Unable to join network", + "%(brand)s does not know how to join a room on this network": "%(brand)s does not know how to join a room on this network", + "Room not found": "Room not found", + "Couldn't find a matching Matrix room": "Couldn't find a matching Matrix room", + "Fetching third party location failed": "Fetching third party location failed", + "Unable to look up room ID from server": "Unable to look up room ID from server", + "Error upgrading room": "Error upgrading room", + "Double check that your server supports the room version chosen and try again.": "Double check that your server supports the room version chosen and try again.", + "Invite to %(spaceName)s": "Invite to %(spaceName)s", + "Share your public space": "Share your public space", + "Unknown App": "Unknown App", + "This homeserver is not configured to display maps.": "This homeserver is not configured to display maps.", + "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.": "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.", + "Are you sure you want to exit during this export?": "Are you sure you want to exit during this export?", + "Generating a ZIP": "Generating a ZIP", + "Fetched %(count)s events out of %(total)s|other": "Fetched %(count)s events out of %(total)s", + "Fetched %(count)s events out of %(total)s|one": "Fetched %(count)s event out of %(total)s", + "Fetched %(count)s events so far|other": "Fetched %(count)s events so far", + "Fetched %(count)s events so far|one": "Fetched %(count)s event so far", + "HTML": "HTML", + "JSON": "JSON", + "Plain Text": "Plain Text", + "From the beginning": "From the beginning", + "Specify a number of messages": "Specify a number of messages", + "Current Timeline": "Current Timeline", + "Media omitted": "Media omitted", + "Media omitted - file size limit exceeded": "Media omitted - file size limit exceeded", + "%(creatorName)s created this room.": "%(creatorName)s created this room.", + "This is the start of export of . Exported by at %(exportDate)s.": "This is the start of export of . Exported by at %(exportDate)s.", + "Topic: %(topic)s": "Topic: %(topic)s", + "Error fetching file": "Error fetching file", + "Processing event %(number)s out of %(total)s": "Processing event %(number)s out of %(total)s", + "Starting export...": "Starting export...", + "Fetched %(count)s events in %(seconds)ss|other": "Fetched %(count)s events in %(seconds)ss", + "Fetched %(count)s events in %(seconds)ss|one": "Fetched %(count)s event in %(seconds)ss", + "Creating HTML...": "Creating HTML...", + "Export successful!": "Export successful!", + "Exported %(count)s events in %(seconds)s seconds|other": "Exported %(count)s events in %(seconds)s seconds", + "Exported %(count)s events in %(seconds)s seconds|one": "Exported %(count)s event in %(seconds)s seconds", + "File Attached": "File Attached", + "Starting export process...": "Starting export process...", + "Fetching events...": "Fetching events...", + "Creating output...": "Creating output...", + "Enable": "Enable", + "That's fine": "That's fine", + "Stop": "Stop", + "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.", + "Help improve %(analyticsOwner)s": "Help improve %(analyticsOwner)s", + "You previously consented to share anonymous usage data with us. We're updating how that works.": "You previously consented to share anonymous usage data with us. We're updating how that works.", + "Learn more": "Learn more", + "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More": "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More", + "Yes": "Yes", + "No": "No", + "You have unverified logins": "You have unverified logins", + "Review to ensure your account is safe": "Review to ensure your account is safe", + "Review": "Review", + "Later": "Later", + "Don't miss a reply": "Don't miss a reply", + "Notifications": "Notifications", + "Enable desktop notifications": "Enable desktop notifications", + "Unknown caller": "Unknown caller", + "Voice call": "Voice call", + "Video call": "Video call", + "Decline": "Decline", + "Accept": "Accept", + "Sound on": "Sound on", + "Silence call": "Silence call", + "Use app for a better experience": "Use app for a better experience", + "%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.": "%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.", + "Use app": "Use app", + "Your homeserver has exceeded its user limit.": "Your homeserver has exceeded its user limit.", + "Your homeserver has exceeded one of its resource limits.": "Your homeserver has exceeded one of its resource limits.", + "Contact your server admin.": "Contact your server admin.", + "Warning": "Warning", + "Ok": "Ok", + "Set up Secure Backup": "Set up Secure Backup", + "Encryption upgrade available": "Encryption upgrade available", + "Verify this session": "Verify this session", + "Upgrade": "Upgrade", + "Verify": "Verify", + "Safeguard against losing access to encrypted messages & data": "Safeguard against losing access to encrypted messages & data", + "Other users may not trust it": "Other users may not trust it", + "New login. Was this you?": "New login. Was this you?", + "%(deviceId)s from %(ip)s": "%(deviceId)s from %(ip)s", + "Check your devices": "Check your devices", + "What's new?": "What's new?", + "What's New": "What's New", + "Update": "Update", + "Update %(brand)s": "Update %(brand)s", + "New version of %(brand)s is available": "New version of %(brand)s is available", + "Guest": "Guest", + "There was an error joining.": "There was an error joining.", + "Sorry, your homeserver is too old to participate here.": "Sorry, your homeserver is too old to participate here.", + "Please contact your homeserver administrator.": "Please contact your homeserver administrator.", + "The person who invited you has already left.": "The person who invited you has already left.", + "The person who invited you has already left, or their server is offline.": "The person who invited you has already left, or their server is offline.", + "Failed to join": "Failed to join", + "All rooms": "All rooms", + "Home": "Home", + "Favourites": "Favourites", + "People": "People", + "Other rooms": "Other rooms", + "You joined the call": "You joined the call", + "%(senderName)s joined the call": "%(senderName)s joined the call", + "Call in progress": "Call in progress", + "You ended the call": "You ended the call", + "%(senderName)s ended the call": "%(senderName)s ended the call", + "Call ended": "Call ended", + "You started a call": "You started a call", + "%(senderName)s started a call": "%(senderName)s started a call", + "Waiting for answer": "Waiting for answer", + "%(senderName)s is calling": "%(senderName)s is calling", + "* %(senderName)s %(emote)s": "* %(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "Threads": "Threads", + "Back to chat": "Back to chat", + "Room information": "Room information", + "Room members": "Room members", + "Back to thread": "Back to thread", + "Change notification settings": "Change notification settings", + "Messaging": "Messaging", + "Profile": "Profile", + "Spaces": "Spaces", + "Widgets": "Widgets", + "Rooms": "Rooms", + "Moderation": "Moderation", + "Message Previews": "Message Previews", + "Themes": "Themes", + "Encryption": "Encryption", + "Experimental": "Experimental", + "Developer": "Developer", + "Let moderators hide messages pending moderation.": "Let moderators hide messages pending moderation.", + "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators": "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators", + "Render LaTeX maths in messages": "Render LaTeX maths in messages", + "Message Pinning": "Message Pinning", + "Threaded messaging": "Threaded messaging", + "Keep discussions organised with threads.": "Keep discussions organised with threads.", + "Threads help keep conversations on-topic and easy to track. Learn more.": "Threads help keep conversations on-topic and easy to track. Learn more.", + "How can I start a thread?": "How can I start a thread?", + "Use “%(replyInThread)s” when hovering over a message.": "Use “%(replyInThread)s” when hovering over a message.", + "Reply in thread": "Reply in thread", + "How can I leave the beta?": "How can I leave the beta?", + "To leave, return to this page and use the “%(leaveTheBeta)s” button.": "To leave, return to this page and use the “%(leaveTheBeta)s” button.", + "Leave the beta": "Leave the beta", + "Thank you for trying the beta, please go into as much detail as you can so we can improve it.": "Thank you for trying the beta, please go into as much detail as you can so we can improve it.", + "Video rooms (under active development)": "Video rooms (under active development)", + "Render simple counters in room header": "Render simple counters in room header", + "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", + "Support adding custom themes": "Support adding custom themes", + "Show message previews for reactions in DMs": "Show message previews for reactions in DMs", + "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", + "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", + "Show extensible event representation of events": "Show extensible event representation of events", + "Show current avatar and name for users in message history": "Show current avatar and name for users in message history", + "Show info about bridges in room settings": "Show info about bridges in room settings", + "Use new room breadcrumbs": "Use new room breadcrumbs", + "New search experience": "New search experience", + "The new search": "The new search", + "A new, quick way to search spaces and rooms you're in.": "A new, quick way to search spaces and rooms you're in.", + "This feature is a work in progress, we'd love to hear your feedback.": "This feature is a work in progress, we'd love to hear your feedback.", + "How can I give feedback?": "How can I give feedback?", + "To feedback, join the beta, start a search and click on feedback.": "To feedback, join the beta, start a search and click on feedback.", + "To leave, just return to this page or click on the beta badge when you search.": "To leave, just return to this page or click on the beta badge when you search.", + "Right panel stays open (defaults to room member list)": "Right panel stays open (defaults to room member list)", + "Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)", + "Don't send read receipts": "Don't send read receipts", + "Right-click message context menu": "Right-click message context menu", + "Location sharing - pin drop": "Location sharing - pin drop", + "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", + "Font size": "Font size", + "Use custom size": "Use custom size", + "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", + "Show stickers button": "Show stickers button", + "Show polls button": "Show polls button", + "Insert a trailing colon after user mentions at the start of a message": "Insert a trailing colon after user mentions at the start of a message", + "Use a more compact 'Modern' layout": "Use a more compact 'Modern' layout", + "Show a placeholder for removed messages": "Show a placeholder for removed messages", + "Show join/leave messages (invites/removes/bans unaffected)": "Show join/leave messages (invites/removes/bans unaffected)", + "Show avatar changes": "Show avatar changes", + "Show display name changes": "Show display name changes", + "Show read receipts sent by other users": "Show read receipts sent by other users", + "Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)", + "Always show message timestamps": "Always show message timestamps", + "Autoplay GIFs": "Autoplay GIFs", + "Autoplay videos": "Autoplay videos", + "Enable automatic language detection for syntax highlighting": "Enable automatic language detection for syntax highlighting", + "Expand code blocks by default": "Expand code blocks by default", + "Show line numbers in code blocks": "Show line numbers in code blocks", + "Jump to the bottom of the timeline when you send a message": "Jump to the bottom of the timeline when you send a message", + "Show avatars in user and room mentions": "Show avatars in user and room mentions", + "Enable big emoji in chat": "Enable big emoji in chat", + "Send typing notifications": "Send typing notifications", + "Show typing notifications": "Show typing notifications", + "Use Command + F to search timeline": "Use Command + F to search timeline", + "Use Ctrl + F to search timeline": "Use Ctrl + F to search timeline", + "Use Command + Enter to send a message": "Use Command + Enter to send a message", + "Use Ctrl + Enter to send a message": "Use Ctrl + Enter to send a message", + "Surround selected text when typing special characters": "Surround selected text when typing special characters", + "Automatically replace plain text Emoji": "Automatically replace plain text Emoji", + "Enable Markdown": "Enable Markdown", + "Start messages with /plain to send without markdown and /md to send with.": "Start messages with /plain to send without markdown and /md to send with.", + "Mirror local video feed": "Mirror local video feed", + "Match system theme": "Match system theme", + "Use a system font": "Use a system font", + "System font name": "System font name", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)", + "Send analytics data": "Send analytics data", + "Never send encrypted messages to unverified sessions from this session": "Never send encrypted messages to unverified sessions from this session", + "Never send encrypted messages to unverified sessions in this room from this session": "Never send encrypted messages to unverified sessions in this room from this session", + "Enable inline URL previews by default": "Enable inline URL previews by default", + "Enable URL previews for this room (only affects you)": "Enable URL previews for this room (only affects you)", + "Enable URL previews by default for participants in this room": "Enable URL previews by default for participants in this room", + "Enable widget screenshots on supported widgets": "Enable widget screenshots on supported widgets", + "Prompt before sending invites to potentially invalid matrix IDs": "Prompt before sending invites to potentially invalid matrix IDs", + "Order rooms by name": "Order rooms by name", + "Show rooms with unread notifications first": "Show rooms with unread notifications first", + "Show shortcuts to recently viewed rooms above the room list": "Show shortcuts to recently viewed rooms above the room list", + "Show hidden events in timeline": "Show hidden events in timeline", + "Low bandwidth mode (requires compatible homeserver)": "Low bandwidth mode (requires compatible homeserver)", + "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)", + "Show previews/thumbnails for images": "Show previews/thumbnails for images", + "Enable message search in encrypted rooms": "Enable message search in encrypted rooms", + "How fast should messages be downloaded.": "How fast should messages be downloaded.", + "Manually verify all remote sessions": "Manually verify all remote sessions", + "IRC display name width": "IRC display name width", + "Show chat effects (animations when receiving e.g. confetti)": "Show chat effects (animations when receiving e.g. confetti)", + "Show all rooms in Home": "Show all rooms in Home", + "All rooms you're in will appear in Home.": "All rooms you're in will appear in Home.", + "Developer mode": "Developer mode", + "Automatically send debug logs on any error": "Automatically send debug logs on any error", + "Automatically send debug logs on decryption errors": "Automatically send debug logs on decryption errors", + "Automatically send debug logs when key backup is not functioning": "Automatically send debug logs when key backup is not functioning", + "Partial Support for Threads": "Partial Support for Threads", + "Your homeserver does not currently support threads, so this feature may be unreliable. Some threaded messages may not be reliably available. Learn more.": "Your homeserver does not currently support threads, so this feature may be unreliable. Some threaded messages may not be reliably available. Learn more.", + "Do you want to enable threads anyway?": "Do you want to enable threads anyway?", + "Yes, enable": "Yes, enable", + "Collecting app version information": "Collecting app version information", + "Collecting logs": "Collecting logs", + "Uploading logs": "Uploading logs", + "Downloading logs": "Downloading logs", + "Waiting for response from server": "Waiting for response from server", + "Messages containing my display name": "Messages containing my display name", + "Messages containing my username": "Messages containing my username", + "Messages containing @room": "Messages containing @room", + "Messages in one-to-one chats": "Messages in one-to-one chats", + "Encrypted messages in one-to-one chats": "Encrypted messages in one-to-one chats", + "Messages in group chats": "Messages in group chats", + "Encrypted messages in group chats": "Encrypted messages in group chats", + "When I'm invited to a room": "When I'm invited to a room", + "Call invitation": "Call invitation", + "Messages sent by bot": "Messages sent by bot", + "When rooms are upgraded": "When rooms are upgraded", + "My Ban List": "My Ban List", + "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", + "Sends the given message with confetti": "Sends the given message with confetti", + "sends confetti": "sends confetti", + "Sends the given message with fireworks": "Sends the given message with fireworks", + "sends fireworks": "sends fireworks", + "Sends the given message with rainfall": "Sends the given message with rainfall", + "sends rainfall": "sends rainfall", + "Sends the given message with snowfall": "Sends the given message with snowfall", + "sends snowfall": "sends snowfall", + "Sends the given message with a space themed effect": "Sends the given message with a space themed effect", + "sends space invaders": "sends space invaders", + "Sends the given message with hearts": "Sends the given message with hearts", + "sends hearts": "sends hearts", + "Server error": "Server error", + "Command error": "Command error", + "Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.", + "Unknown Command": "Unknown Command", + "Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s", + "You can use /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", + "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", + "Send as message": "Send as message", + "You are presenting": "You are presenting", + "%(sharerName)s is presenting": "%(sharerName)s is presenting", + "Your camera is turned off": "Your camera is turned off", + "Your camera is still enabled": "Your camera is still enabled", + "unknown person": "unknown person", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Consulting with %(transferTarget)s. Transfer to %(transferee)s", + "You held the call Switch": "You held the call Switch", + "You held the call Resume": "You held the call Resume", + "%(peerName)s held the call": "%(peerName)s held the call", + "Connecting": "Connecting", + "Dial": "Dial", + "%(count)s people joined|other": "%(count)s people joined", + "%(count)s people joined|one": "%(count)s person joined", + "Audio devices": "Audio devices", + "Mute microphone": "Mute microphone", + "Unmute microphone": "Unmute microphone", + "Video devices": "Video devices", + "Turn off camera": "Turn off camera", + "Turn on camera": "Turn on camera", + "Join": "Join", + "Dialpad": "Dialpad", + "Mute the microphone": "Mute the microphone", + "Unmute the microphone": "Unmute the microphone", + "Stop the camera": "Stop the camera", + "Start the camera": "Start the camera", + "Stop sharing your screen": "Stop sharing your screen", + "Start sharing your screen": "Start sharing your screen", + "Hide sidebar": "Hide sidebar", + "Show sidebar": "Show sidebar", + "More": "More", + "Hangup": "Hangup", + "Fill Screen": "Fill Screen", + "Pin": "Pin", + "Return to call": "Return to call", + "%(name)s on hold": "%(name)s on hold", + "Call": "Call", + "The other party cancelled the verification.": "The other party cancelled the verification.", + "Verified!": "Verified!", + "You've successfully verified this user.": "You've successfully verified this user.", + "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.", + "Got It": "Got It", + "Confirm the emoji below are displayed on both devices, in the same order:": "Confirm the emoji below are displayed on both devices, in the same order:", + "Verify this user by confirming the following emoji appear on their screen.": "Verify this user by confirming the following emoji appear on their screen.", + "Verify this device by confirming the following number appears on its screen.": "Verify this device by confirming the following number appears on its screen.", + "Verify this user by confirming the following number appears on their screen.": "Verify this user by confirming the following number appears on their screen.", + "Unable to find a supported verification method.": "Unable to find a supported verification method.", + "Waiting for you to verify on your other device, %(deviceName)s (%(deviceId)s)…": "Waiting for you to verify on your other device, %(deviceName)s (%(deviceId)s)…", + "Waiting for you to verify on your other device…": "Waiting for you to verify on your other device…", + "Waiting for %(displayName)s to verify…": "Waiting for %(displayName)s to verify…", + "Cancelling…": "Cancelling…", + "They don't match": "They don't match", + "They match": "They match", + "To be secure, do this in person or use a trusted way to communicate.": "To be secure, do this in person or use a trusted way to communicate.", + "Dog": "Dog", + "Cat": "Cat", + "Lion": "Lion", + "Horse": "Horse", + "Unicorn": "Unicorn", + "Pig": "Pig", + "Elephant": "Elephant", + "Rabbit": "Rabbit", + "Panda": "Panda", + "Rooster": "Rooster", + "Penguin": "Penguin", + "Turtle": "Turtle", + "Fish": "Fish", + "Octopus": "Octopus", + "Butterfly": "Butterfly", + "Flower": "Flower", + "Tree": "Tree", + "Cactus": "Cactus", + "Mushroom": "Mushroom", + "Globe": "Globe", + "Moon": "Moon", + "Cloud": "Cloud", + "Fire": "Fire", + "Banana": "Banana", + "Apple": "Apple", + "Strawberry": "Strawberry", + "Corn": "Corn", + "Pizza": "Pizza", + "Cake": "Cake", + "Heart": "Heart", + "Smiley": "Smiley", + "Robot": "Robot", + "Hat": "Hat", + "Glasses": "Glasses", + "Spanner": "Spanner", + "Santa": "Santa", + "Thumbs up": "Thumbs up", + "Umbrella": "Umbrella", + "Hourglass": "Hourglass", + "Clock": "Clock", + "Gift": "Gift", + "Light bulb": "Light bulb", + "Book": "Book", + "Pencil": "Pencil", + "Paperclip": "Paperclip", + "Scissors": "Scissors", + "Lock": "Lock", + "Key": "Key", + "Hammer": "Hammer", + "Telephone": "Telephone", + "Flag": "Flag", + "Train": "Train", + "Bicycle": "Bicycle", + "Aeroplane": "Aeroplane", + "Rocket": "Rocket", + "Trophy": "Trophy", + "Ball": "Ball", + "Guitar": "Guitar", + "Trumpet": "Trumpet", + "Bell": "Bell", + "Anchor": "Anchor", + "Headphones": "Headphones", + "Folder": "Folder", + "Your server isn't responding to some requests.": "Your server isn't responding to some requests.", + "Decline (%(counter)s)": "Decline (%(counter)s)", + "Accept to continue:": "Accept to continue:", + "Quick settings": "Quick settings", + "All settings": "All settings", + "Developer tools": "Developer tools", + "Pin to sidebar": "Pin to sidebar", + "More options": "More options", + "Settings": "Settings", + "Match system": "Match system", + "Theme": "Theme", + "Space selection": "Space selection", + "Delete avatar": "Delete avatar", + "Delete": "Delete", + "Upload avatar": "Upload avatar", + "Upload": "Upload", + "Name": "Name", + "Description": "Description", + "No results": "No results", + "Search %(spaceName)s": "Search %(spaceName)s", + "Please enter a name for the space": "Please enter a name for the space", + "Spaces are a new feature.": "Spaces are a new feature.", + "Spaces feedback": "Spaces feedback", + "Thank you for trying Spaces. Your feedback will help inform the next versions.": "Thank you for trying Spaces. Your feedback will help inform the next versions.", + "Give feedback.": "Give feedback.", + "e.g. my-space": "e.g. my-space", + "Address": "Address", + "Create a space": "Create a space", + "Spaces are a new way to group rooms and people. What kind of Space do you want to create? You can change this later.": "Spaces are a new way to group rooms and people. What kind of Space do you want to create? You can change this later.", + "Public": "Public", + "Open space for anyone, best for communities": "Open space for anyone, best for communities", + "Private": "Private", + "Invite only, best for yourself or teams": "Invite only, best for yourself or teams", + "To join a space you'll need an invite.": "To join a space you'll need an invite.", + "Go back": "Go back", + "Your public space": "Your public space", + "Your private space": "Your private space", + "Add some details to help people recognise it.": "Add some details to help people recognise it.", + "You can change these anytime.": "You can change these anytime.", + "Creating...": "Creating...", + "Create": "Create", + "Show all rooms": "Show all rooms", + "Options": "Options", + "Expand": "Expand", + "Collapse": "Collapse", + "Click to copy": "Click to copy", + "Copied!": "Copied!", + "Failed to copy": "Failed to copy", + "Share invite link": "Share invite link", + "Invite people": "Invite people", + "Invite with email or username": "Invite with email or username", + "Failed to save space settings.": "Failed to save space settings.", + "General": "General", + "Edit settings relating to your space.": "Edit settings relating to your space.", + "Saving...": "Saving...", + "Save Changes": "Save Changes", + "Leave Space": "Leave Space", + "Failed to update the guest access of this space": "Failed to update the guest access of this space", + "Failed to update the history visibility of this space": "Failed to update the history visibility of this space", + "Hide advanced": "Hide advanced", + "Show advanced": "Show advanced", + "Enable guest access": "Enable guest access", + "Guests can join a space without having an account.": "Guests can join a space without having an account.", + "This may be useful for public spaces.": "This may be useful for public spaces.", + "Visibility": "Visibility", + "Access": "Access", + "Decide who can view and join %(spaceName)s.": "Decide who can view and join %(spaceName)s.", + "Failed to update the visibility of this space": "Failed to update the visibility of this space", + "Preview Space": "Preview Space", + "Allow people to preview your space before they join.": "Allow people to preview your space before they join.", + "Recommended for public spaces.": "Recommended for public spaces.", + "Jump to first unread room.": "Jump to first unread room.", + "Jump to first invite.": "Jump to first invite.", + "Space options": "Space options", + "Remove": "Remove", + "This bridge was provisioned by .": "This bridge was provisioned by .", + "This bridge is managed by .": "This bridge is managed by .", + "Workspace: ": "Workspace: ", + "Channel: ": "Channel: ", + "Failed to upload profile picture!": "Failed to upload profile picture!", + "Upload new:": "Upload new:", + "No display name": "No display name", + "Warning!": "Warning!", + "Changing your password on this homeserver will cause all of your other devices to be signed out. This will delete the message encryption keys stored on them, and may make encrypted chat history unreadable.": "Changing your password on this homeserver will cause all of your other devices to be signed out. This will delete the message encryption keys stored on them, and may make encrypted chat history unreadable.", + "If you want to retain access to your chat history in encrypted rooms you should first export your room keys and re-import them afterwards.": "If you want to retain access to your chat history in encrypted rooms you should first export your room keys and re-import them afterwards.", + "You can also ask your homeserver admin to upgrade the server to change this behaviour.": "You can also ask your homeserver admin to upgrade the server to change this behaviour.", + "Export E2E room keys": "Export E2E room keys", + "New passwords don't match": "New passwords don't match", + "Passwords can't be empty": "Passwords can't be empty", + "Do you want to set an email address?": "Do you want to set an email address?", + "Confirm password": "Confirm password", + "Passwords don't match": "Passwords don't match", + "Current password": "Current password", + "New Password": "New Password", + "Change Password": "Change Password", + "Your homeserver does not support cross-signing.": "Your homeserver does not support cross-signing.", + "Cross-signing is ready for use.": "Cross-signing is ready for use.", + "Cross-signing is ready but keys are not backed up.": "Cross-signing is ready but keys are not backed up.", + "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.": "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.", + "Cross-signing is not set up.": "Cross-signing is not set up.", + "Reset": "Reset", + "Cross-signing public keys:": "Cross-signing public keys:", + "in memory": "in memory", + "not found": "not found", + "Cross-signing private keys:": "Cross-signing private keys:", + "in secret storage": "in secret storage", + "not found in storage": "not found in storage", + "Master private key:": "Master private key:", + "cached locally": "cached locally", + "not found locally": "not found locally", + "Self signing private key:": "Self signing private key:", + "User signing private key:": "User signing private key:", + "Homeserver feature support:": "Homeserver feature support:", + "exists": "exists", + "": "", + "Import E2E room keys": "Import E2E room keys", + "Cryptography": "Cryptography", + "Session ID:": "Session ID:", + "Session key:": "Session key:", + "Your homeserver does not support device management.": "Your homeserver does not support device management.", + "Unable to load device list": "Unable to load device list", + "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.", + "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.", + "Confirm signing out these devices|other": "Confirm signing out these devices", + "Confirm signing out these devices|one": "Confirm signing out this device", + "Click the button below to confirm signing out these devices.|other": "Click the button below to confirm signing out these devices.", + "Click the button below to confirm signing out these devices.|one": "Click the button below to confirm signing out this device.", + "Sign out devices|other": "Sign out devices", + "Sign out devices|one": "Sign out device", + "Authentication": "Authentication", + "Deselect all": "Deselect all", + "Select all": "Select all", + "Verified devices": "Verified devices", + "Unverified devices": "Unverified devices", + "Devices without encryption support": "Devices without encryption support", + "Sign out %(count)s selected devices|other": "Sign out %(count)s selected devices", + "Sign out %(count)s selected devices|one": "Sign out %(count)s selected device", + "You aren't signed into any other devices.": "You aren't signed into any other devices.", + "This device": "This device", + "Failed to set display name": "Failed to set display name", + "Last seen %(date)s at %(ip)s": "Last seen %(date)s at %(ip)s", + "Sign Out": "Sign Out", + "Display Name": "Display Name", + "Rename": "Rename", + "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.", + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|other": "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.", + "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|one": "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s room.", + "Manage": "Manage", + "Securely cache encrypted messages locally for them to appear in search results.": "Securely cache encrypted messages locally for them to appear in search results.", + "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.", + "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.", + "Message search initialisation failed": "Message search initialisation failed", + "Hey you. You're the best!": "Hey you. You're the best!", + "Size must be a number": "Size must be a number", + "Custom font size can only be between %(min)s pt and %(max)s pt": "Custom font size can only be between %(min)s pt and %(max)s pt", + "Use between %(min)s pt and %(max)s pt": "Use between %(min)s pt and %(max)s pt", + "Image size in the timeline": "Image size in the timeline", + "Large": "Large", + "Connecting to integration manager...": "Connecting to integration manager...", + "Cannot connect to integration manager": "Cannot connect to integration manager", + "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", + "Integration manager": "Integration manager", + "Private (invite only)": "Private (invite only)", + "Only invited people can join.": "Only invited people can join.", + "Anyone can find and join.": "Anyone can find and join.", + "Upgrade required": "Upgrade required", + "& %(count)s more|other": "& %(count)s more", + "& %(count)s more|one": "& %(count)s more", + "Currently, %(count)s spaces have access|other": "Currently, %(count)s spaces have access", + "Currently, %(count)s spaces have access|one": "Currently, a space has access", + "Anyone in a space can find and join. Edit which spaces can access here.": "Anyone in a space can find and join. Edit which spaces can access here.", + "Spaces with access": "Spaces with access", + "Anyone in can find and join. You can select other spaces too.": "Anyone in can find and join. You can select other spaces too.", + "Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.", + "Space members": "Space members", + "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.": "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.", + "This upgrade will allow members of selected spaces access to this room without an invite.": "This upgrade will allow members of selected spaces access to this room without an invite.", + "Upgrading room": "Upgrading room", + "Loading new room": "Loading new room", + "Sending invites... (%(progress)s out of %(count)s)|other": "Sending invites... (%(progress)s out of %(count)s)", + "Sending invites... (%(progress)s out of %(count)s)|one": "Sending invite...", + "Updating spaces... (%(progress)s out of %(count)s)|other": "Updating spaces... (%(progress)s out of %(count)s)", + "Updating spaces... (%(progress)s out of %(count)s)|one": "Updating space...", + "Message layout": "Message layout", + "IRC (Experimental)": "IRC (Experimental)", + "Modern": "Modern", + "Message bubbles": "Message bubbles", + "Messages containing keywords": "Messages containing keywords", + "Error saving notification preferences": "Error saving notification preferences", + "An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.", + "Enable for this account": "Enable for this account", + "Enable email notifications for %(email)s": "Enable email notifications for %(email)s", + "Enable desktop notifications for this session": "Enable desktop notifications for this session", + "Show message in desktop notification": "Show message in desktop notification", + "Enable audible notifications for this session": "Enable audible notifications for this session", + "Clear notifications": "Clear notifications", + "Keyword": "Keyword", + "New keyword": "New keyword", + "On": "On", + "Off": "Off", + "Noisy": "Noisy", + "Global": "Global", + "Mentions & keywords": "Mentions & keywords", + "Notification targets": "Notification targets", + "There was an error loading your notification settings.": "There was an error loading your notification settings.", + "Failed to save your profile": "Failed to save your profile", + "The operation could not be completed": "The operation could not be completed", + "Upgrade to your own domain": "Upgrade to your own domain", + "Profile picture": "Profile picture", + "Save": "Save", + "Delete Backup": "Delete Backup", + "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", + "Unable to load key backup status": "Unable to load key backup status", + "Restore from Backup": "Restore from Backup", + "This session is backing up your keys. ": "This session is backing up your keys. ", + "This session is not backing up your keys, but you do have an existing backup you can restore from and add to going forward.": "This session is not backing up your keys, but you do have an existing backup you can restore from and add to going forward.", + "Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.": "Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.", + "Connect this session to Key Backup": "Connect this session to Key Backup", + "Backing up %(sessionsRemaining)s keys...": "Backing up %(sessionsRemaining)s keys...", + "All keys backed up": "All keys backed up", + "Backup has a valid signature from this user": "Backup has a valid signature from this user", + "Backup has a invalid signature from this user": "Backup has a invalid signature from this user", + "Backup has a signature from unknown user with ID %(deviceId)s": "Backup has a signature from unknown user with ID %(deviceId)s", + "Backup has a signature from unknown session with ID %(deviceId)s": "Backup has a signature from unknown session with ID %(deviceId)s", + "Backup has a valid signature from this session": "Backup has a valid signature from this session", + "Backup has an invalid signature from this session": "Backup has an invalid signature from this session", + "Backup has a valid signature from verified session ": "Backup has a valid signature from verified session ", + "Backup has a valid signature from unverified session ": "Backup has a valid signature from unverified session ", + "Backup has an invalid signature from verified session ": "Backup has an invalid signature from verified session ", + "Backup has an invalid signature from unverified session ": "Backup has an invalid signature from unverified session ", + "Backup is not signed by any of your sessions": "Backup is not signed by any of your sessions", + "This backup is trusted because it has been restored on this session": "This backup is trusted because it has been restored on this session", + "Backup version:": "Backup version:", + "Algorithm:": "Algorithm:", + "Your keys are not being backed up from this session.": "Your keys are not being backed up from this session.", + "Back up your keys before signing out to avoid losing them.": "Back up your keys before signing out to avoid losing them.", + "Set up": "Set up", + "well formed": "well formed", + "unexpected type": "unexpected type", + "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.": "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.", + "Backup key stored:": "Backup key stored:", + "not stored": "not stored", + "Backup key cached:": "Backup key cached:", + "Secret storage public key:": "Secret storage public key:", + "in account data": "in account data", + "Secret storage:": "Secret storage:", + "ready": "ready", + "not ready": "not ready", + "Identity server URL must be HTTPS": "Identity server URL must be HTTPS", + "Not a valid identity server (status code %(code)s)": "Not a valid identity server (status code %(code)s)", + "Could not connect to identity server": "Could not connect to identity server", + "Checking server": "Checking server", + "Change identity server": "Change identity server", + "Disconnect from the identity server and connect to instead?": "Disconnect from the identity server and connect to instead?", + "Terms of service not accepted or the identity server is invalid.": "Terms of service not accepted or the identity server is invalid.", + "The identity server you have chosen does not have any terms of service.": "The identity server you have chosen does not have any terms of service.", + "Disconnect identity server": "Disconnect identity server", + "Disconnect from the identity server ?": "Disconnect from the identity server ?", + "Disconnect": "Disconnect", + "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.", + "You should:": "You should:", + "check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "check your browser plugins for anything that might block the identity server (such as Privacy Badger)", + "contact the administrators of identity server ": "contact the administrators of identity server ", + "wait and try again later": "wait and try again later", + "Disconnect anyway": "Disconnect anyway", + "You are still sharing your personal data on the identity server .": "You are still sharing your personal data on the identity server .", + "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.", + "Identity server (%(server)s)": "Identity server (%(server)s)", + "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.": "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.", + "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.": "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.", + "Identity server": "Identity server", + "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.": "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.", + "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.", + "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.", + "Do not use an identity server": "Do not use an identity server", + "Enter a new identity server": "Enter a new identity server", + "Change": "Change", + "Use an integration manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Use an integration manager (%(serverName)s) to manage bots, widgets, and sticker packs.", + "Use an integration manager to manage bots, widgets, and sticker packs.": "Use an integration manager to manage bots, widgets, and sticker packs.", + "Manage integrations": "Manage integrations", + "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.", + "Add": "Add", + "Invalid theme schema.": "Invalid theme schema.", + "Error downloading theme information.": "Error downloading theme information.", + "Theme added!": "Theme added!", + "Use high contrast": "Use high contrast", + "Custom theme URL": "Custom theme URL", + "Add theme": "Add theme", + "Error encountered (%(errorDetail)s).": "Error encountered (%(errorDetail)s).", + "Checking for an update...": "Checking for an update...", + "No update available.": "No update available.", + "Downloading update...": "Downloading update...", + "New version available. Update now.": "New version available. Update now.", + "Check for update": "Check for update", + "Set the name of a font installed on your system & %(brand)s will attempt to use it.": "Set the name of a font installed on your system & %(brand)s will attempt to use it.", + "Customise your appearance": "Customise your appearance", + "Appearance Settings only affect this %(brand)s session.": "Appearance Settings only affect this %(brand)s session.", + "Failed to change password. Is your password correct?": "Failed to change password. Is your password correct?", + "Your password was successfully changed.": "Your password was successfully changed.", + "You will not receive push notifications on other devices until you sign back in to them.": "You will not receive push notifications on other devices until you sign back in to them.", + "Success": "Success", + "Email addresses": "Email addresses", + "Phone numbers": "Phone numbers", + "Set a new account password...": "Set a new account password...", + "Account": "Account", + "Language and region": "Language and region", + "Spell check dictionaries": "Spell check dictionaries", + "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.", + "Account management": "Account management", + "Deactivating your account is a permanent action - be careful!": "Deactivating your account is a permanent action - be careful!", + "Deactivate Account": "Deactivate Account", + "Deactivate account": "Deactivate account", + "Discovery": "Discovery", + "%(brand)s version:": "%(brand)s version:", + "Olm version:": "Olm version:", + "Legal": "Legal", + "Credits": "Credits", + "For help with using %(brand)s, click here.": "For help with using %(brand)s, click here.", + "For help with using %(brand)s, click here or start a chat with our bot using the button below.": "For help with using %(brand)s, click here or start a chat with our bot using the button below.", + "Chat with %(brand)s Bot": "Chat with %(brand)s Bot", + "Bug reporting": "Bug reporting", + "If you've submitted a bug via GitHub, debug logs can help us track down the problem. ": "If you've submitted a bug via GitHub, debug logs can help us track down the problem. ", + "Debug logs contain application usage data including your username, the IDs or aliases of the rooms you have visited, which UI elements you last interacted with, and the usernames of other users. They do not contain messages.": "Debug logs contain application usage data including your username, the IDs or aliases of the rooms you have visited, which UI elements you last interacted with, and the usernames of other users. They do not contain messages.", + "Submit debug logs": "Submit debug logs", + "To report a Matrix-related security issue, please read the Matrix.org Security Disclosure Policy.": "To report a Matrix-related security issue, please read the Matrix.org Security Disclosure Policy.", + "Help & About": "Help & About", + "FAQ": "FAQ", + "Keyboard Shortcuts": "Keyboard Shortcuts", + "Versions": "Versions", + "Homeserver is": "Homeserver is", + "Identity server is": "Identity server is", + "Access Token": "Access Token", + "Your access token gives full access to your account. Do not share it with anyone.": "Your access token gives full access to your account. Do not share it with anyone.", + "Clear cache and reload": "Clear cache and reload", + "Keyboard": "Keyboard", + "Labs": "Labs", + "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.", + "Ignored/Blocked": "Ignored/Blocked", + "Error adding ignored user/server": "Error adding ignored user/server", + "Something went wrong. Please try again or view your console for hints.": "Something went wrong. Please try again or view your console for hints.", + "Error subscribing to list": "Error subscribing to list", + "Please verify the room ID or address and try again.": "Please verify the room ID or address and try again.", + "Error removing ignored user/server": "Error removing ignored user/server", + "Error unsubscribing from list": "Error unsubscribing from list", + "Please try again or view your console for hints.": "Please try again or view your console for hints.", + "None": "None", + "Ban list rules - %(roomName)s": "Ban list rules - %(roomName)s", + "Server rules": "Server rules", + "User rules": "User rules", + "Close": "Close", + "You have not ignored anyone.": "You have not ignored anyone.", + "You are currently ignoring:": "You are currently ignoring:", + "You are not subscribed to any lists": "You are not subscribed to any lists", + "Unsubscribe": "Unsubscribe", + "View rules": "View rules", + "You are currently subscribed to:": "You are currently subscribed to:", + "Ignored users": "Ignored users", + "⚠ These settings are meant for advanced users.": "⚠ These settings are meant for advanced users.", + "Add users and servers you want to ignore here. Use asterisks to have %(brand)s match any characters. For example, @bot:* would ignore all users that have the name 'bot' on any server.": "Add users and servers you want to ignore here. Use asterisks to have %(brand)s match any characters. For example, @bot:* would ignore all users that have the name 'bot' on any server.", + "Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.": "Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.", + "Personal ban list": "Personal ban list", + "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.": "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.", + "Server or user ID to ignore": "Server or user ID to ignore", + "eg: @bot:* or example.org": "eg: @bot:* or example.org", + "Ignore": "Ignore", + "Subscribed lists": "Subscribed lists", + "Subscribing to a ban list will cause you to join it!": "Subscribing to a ban list will cause you to join it!", + "If this isn't what you want, please use a different tool to ignore users.": "If this isn't what you want, please use a different tool to ignore users.", + "Room ID or address of ban list": "Room ID or address of ban list", + "Subscribe": "Subscribe", + "Start automatically after system login": "Start automatically after system login", + "Warn before quitting": "Warn before quitting", + "Always show the window menu bar": "Always show the window menu bar", + "Show tray icon and minimise window to it on close": "Show tray icon and minimise window to it on close", + "Enable hardware acceleration (restart %(appName)s to take effect)": "Enable hardware acceleration (restart %(appName)s to take effect)", + "Preferences": "Preferences", + "Room list": "Room list", + "Keyboard shortcuts": "Keyboard shortcuts", + "To view all keyboard shortcuts, click here.": "To view all keyboard shortcuts, click here.", + "Displaying time": "Displaying time", + "Composer": "Composer", + "Code blocks": "Code blocks", + "Images, GIFs and videos": "Images, GIFs and videos", + "Timeline": "Timeline", + "Autocomplete delay (ms)": "Autocomplete delay (ms)", + "Read Marker lifetime (ms)": "Read Marker lifetime (ms)", + "Read Marker off-screen lifetime (ms)": "Read Marker off-screen lifetime (ms)", + "Unignore": "Unignore", + "You have no ignored users.": "You have no ignored users.", + "Bulk options": "Bulk options", + "Accept all %(invitedRooms)s invites": "Accept all %(invitedRooms)s invites", + "Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites", + "Secure Backup": "Secure Backup", + "Message search": "Message search", + "Cross-signing": "Cross-signing", + "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.", + "Okay": "Okay", + "Privacy": "Privacy", + "Share anonymous data to help us identify issues. Nothing personal. No third parties.": "Share anonymous data to help us identify issues. Nothing personal. No third parties.", + "Where you're signed in": "Where you're signed in", + "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Manage your signed-in devices below. A device's name is visible to people you communicate with.", + "Sidebar": "Sidebar", + "Spaces to show": "Spaces to show", + "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.", + "Home is useful for getting an overview of everything.": "Home is useful for getting an overview of everything.", + "Show all your rooms in Home, even if they're in a space.": "Show all your rooms in Home, even if they're in a space.", + "Group all your favourite rooms and people in one place.": "Group all your favourite rooms and people in one place.", + "Group all your people in one place.": "Group all your people in one place.", + "Rooms outside of a space": "Rooms outside of a space", + "Group all your rooms that aren't part of a space in one place.": "Group all your rooms that aren't part of a space in one place.", + "Default Device": "Default Device", + "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", + "Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.", + "Request media permissions": "Request media permissions", + "Audio Output": "Audio Output", + "No Audio Outputs detected": "No Audio Outputs detected", + "Microphone": "Microphone", + "No Microphones detected": "No Microphones detected", + "Camera": "Camera", + "No Webcams detected": "No Webcams detected", + "Voice & Video": "Voice & Video", + "This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers", + "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. 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.": "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. 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.", + "Upgrade this space to the recommended room version": "Upgrade this space to the recommended room version", + "Upgrade this room to the recommended room version": "Upgrade this room to the recommended room version", + "View older version of %(spaceName)s.": "View older version of %(spaceName)s.", + "View older messages in %(roomName)s.": "View older messages in %(roomName)s.", + "Space information": "Space information", + "Internal room ID": "Internal room ID", + "Room version": "Room version", + "Room version:": "Room version:", + "This room is bridging messages to the following platforms. Learn more.": "This room is bridging messages to the following platforms. Learn more.", + "This room isn't bridging messages to any platforms. Learn more.": "This room isn't bridging messages to any platforms. Learn more.", + "Bridges": "Bridges", + "Room Addresses": "Room Addresses", + "Uploaded sound": "Uploaded sound", + "Get notifications as set up in your settings": "Get notifications as set up in your settings", + "All messages": "All messages", + "Get notified for every message": "Get notified for every message", + "@mentions & keywords": "@mentions & keywords", + "Get notified only with mentions and keywords as set up in your settings": "Get notified only with mentions and keywords as set up in your settings", + "You won't get any notifications": "You won't get any notifications", + "Sounds": "Sounds", + "Notification sound": "Notification sound", + "Set a new custom sound": "Set a new custom sound", + "Browse": "Browse", + "Failed to unban": "Failed to unban", + "Unban": "Unban", + "Banned by %(displayName)s": "Banned by %(displayName)s", + "Reason": "Reason", + "Error changing power level requirement": "Error changing power level requirement", + "An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.": "An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.", + "Error changing power level": "Error changing power level", + "An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.": "An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.", + "Change space avatar": "Change space avatar", + "Change room avatar": "Change room avatar", + "Change space name": "Change space name", + "Change room name": "Change room name", + "Change main address for the space": "Change main address for the space", + "Change main address for the room": "Change main address for the room", + "Manage rooms in this space": "Manage rooms in this space", + "Change history visibility": "Change history visibility", + "Change permissions": "Change permissions", + "Change description": "Change description", + "Change topic": "Change topic", + "Upgrade the room": "Upgrade the room", + "Enable room encryption": "Enable room encryption", + "Change server ACLs": "Change server ACLs", + "Send reactions": "Send reactions", + "Remove messages sent by me": "Remove messages sent by me", + "Modify widgets": "Modify widgets", + "Manage pinned events": "Manage pinned events", + "Default role": "Default role", + "Send messages": "Send messages", + "Invite users": "Invite users", + "Change settings": "Change settings", + "Remove users": "Remove users", + "Ban users": "Ban users", + "Remove messages sent by others": "Remove messages sent by others", + "Notify everyone": "Notify everyone", + "No users have specific privileges in this room": "No users have specific privileges in this room", + "Privileged Users": "Privileged Users", + "Muted Users": "Muted Users", + "Banned users": "Banned users", + "Send %(eventType)s events": "Send %(eventType)s events", + "Roles & Permissions": "Roles & Permissions", + "Permissions": "Permissions", + "Select the roles required to change various parts of the space": "Select the roles required to change various parts of the space", + "Select the roles required to change various parts of the room": "Select the roles required to change various parts of the room", + "Are you sure you want to add encryption to this public room?": "Are you sure you want to add encryption to this public room?", + "It's not recommended to add encryption to public rooms.Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "It's not recommended to add encryption to public rooms.Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.", + "To avoid these issues, create a new encrypted room for the conversation you plan to have.": "To avoid these issues, create a new encrypted room for the conversation you plan to have.", + "Enable encryption?": "Enable encryption?", + "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.": "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.", + "To link to this room, please add an address.": "To link to this room, please add an address.", + "Decide who can join %(roomName)s.": "Decide who can join %(roomName)s.", + "Failed to update the join rules": "Failed to update the join rules", + "Unknown failure": "Unknown failure", + "Are you sure you want to make this encrypted room public?": "Are you sure you want to make this encrypted room public?", + "It's not recommended to make encrypted rooms public. It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.": "It's not recommended to make encrypted rooms public. It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.", + "To avoid these issues, create a new public room for the conversation you plan to have.": "To avoid these issues, create a new public room for the conversation you plan to have.", + "Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)", + "Members only (since they were invited)": "Members only (since they were invited)", + "Members only (since they joined)": "Members only (since they joined)", + "Anyone": "Anyone", + "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.", + "Who can read history?": "Who can read history?", + "People with supported clients will be able to join the room without having a registered account.": "People with supported clients will be able to join the room without having a registered account.", + "Security & Privacy": "Security & Privacy", + "Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.", + "Encrypted": "Encrypted", + "Unable to revoke sharing for email address": "Unable to revoke sharing for email address", + "Unable to share email address": "Unable to share email address", + "Your email address hasn't been verified yet": "Your email address hasn't been verified yet", + "Click the link in the email you received to verify and then click continue again.": "Click the link in the email you received to verify and then click continue again.", + "Unable to verify email address.": "Unable to verify email address.", + "Verify the link in your inbox": "Verify the link in your inbox", + "Complete": "Complete", + "Revoke": "Revoke", + "Share": "Share", + "Discovery options will appear once you have added an email above.": "Discovery options will appear once you have added an email above.", + "Unable to revoke sharing for phone number": "Unable to revoke sharing for phone number", + "Unable to share phone number": "Unable to share phone number", + "Unable to verify phone number.": "Unable to verify phone number.", + "Incorrect verification code": "Incorrect verification code", + "Please enter verification code sent via text.": "Please enter verification code sent via text.", + "Verification code": "Verification code", + "Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.", + "Unable to remove contact information": "Unable to remove contact information", + "Remove %(email)s?": "Remove %(email)s?", + "Invalid Email Address": "Invalid Email Address", + "This doesn't appear to be a valid email address": "This doesn't appear to be a valid email address", + "Unable to add email address": "Unable to add email address", + "We've sent you an email to verify your address. Please follow the instructions there and then click the button below.": "We've sent you an email to verify your address. Please follow the instructions there and then click the button below.", + "Email Address": "Email Address", + "Remove %(phone)s?": "Remove %(phone)s?", + "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.", + "Phone Number": "Phone Number", + "This user has not verified all of their sessions.": "This user has not verified all of their sessions.", + "You have not verified this user.": "You have not verified this user.", + "You have verified this user. This user has verified all of their sessions.": "You have verified this user. This user has verified all of their sessions.", + "Someone is using an unknown session": "Someone is using an unknown session", + "This room is end-to-end encrypted": "This room is end-to-end encrypted", + "Everyone in this room is verified": "Everyone in this room is verified", + "Edit message": "Edit message", + "Mod": "Mod", + "From a thread": "From a thread", + "This event could not be displayed": "This event could not be displayed", + "Your key share request has been sent - please check your other sessions for key share requests.": "Your key share request has been sent - please check your other sessions for key share requests.", + "Key share requests are sent to your other sessions automatically. If you rejected or dismissed the key share request on your other sessions, click here to request the keys for this session again.": "Key share requests are sent to your other sessions automatically. If you rejected or dismissed the key share request on your other sessions, click here to request the keys for this session again.", + "If your other sessions do not have the key for this message you will not be able to decrypt them.": "If your other sessions do not have the key for this message you will not be able to decrypt them.", + "Key request sent.": "Key request sent.", + "Re-request encryption keys from your other sessions.": "Re-request encryption keys from your other sessions.", + "Message Actions": "Message Actions", + "View in room": "View in room", + "Copy link to thread": "Copy link to thread", + "This message cannot be decrypted": "This message cannot be decrypted", + "Encrypted by an unverified session": "Encrypted by an unverified session", + "Unencrypted": "Unencrypted", + "Encrypted by a deleted session": "Encrypted by a deleted session", + "The authenticity of this encrypted message can't be guaranteed on this device.": "The authenticity of this encrypted message can't be guaranteed on this device.", + "Sending your message...": "Sending your message...", + "Encrypting your message...": "Encrypting your message...", + "Your message was sent": "Your message was sent", + "Failed to send": "Failed to send", + "You don't have permission to view messages from before you were invited.": "You don't have permission to view messages from before you were invited.", + "You don't have permission to view messages from before you joined.": "You don't have permission to view messages from before you joined.", + "Encrypted messages before this point are unavailable.": "Encrypted messages before this point are unavailable.", + "You can't see earlier messages": "You can't see earlier messages", + "Scroll to most recent messages": "Scroll to most recent messages", + "Show %(count)s other previews|other": "Show %(count)s other previews", + "Show %(count)s other previews|one": "Show %(count)s other preview", + "Close preview": "Close preview", + "and %(count)s others...|other": "and %(count)s others...", + "and %(count)s others...|one": "and one other...", + "Invite to this room": "Invite to this room", + "Invite to this space": "Invite to this space", + "Invited": "Invited", + "Filter room members": "Filter room members", + "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", + "Send message": "Send message", + "Reply to encrypted thread…": "Reply to encrypted thread…", + "Reply to thread…": "Reply to thread…", + "Send an encrypted reply…": "Send an encrypted reply…", + "Send a reply…": "Send a reply…", + "Send an encrypted message…": "Send an encrypted message…", + "Send a message…": "Send a message…", + "The conversation continues here.": "The conversation continues here.", + "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.", + "You do not have permission to post to this room": "You do not have permission to post to this room", + "%(seconds)ss left": "%(seconds)ss left", + "Send voice message": "Send voice message", + "Emoji": "Emoji", + "Hide stickers": "Hide stickers", + "Sticker": "Sticker", + "Voice Message": "Voice Message", + "You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.", + "Poll": "Poll", + "Bold": "Bold", + "Italics": "Italics", + "Strikethrough": "Strikethrough", + "Code block": "Code block", + "Quote": "Quote", + "Insert link": "Insert link", + "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Only the two of you are in this conversation, unless either of you invites anyone to join.", + "This is the beginning of your direct message history with .": "This is the beginning of your direct message history with .", + "Topic: %(topic)s (edit)": "Topic: %(topic)s (edit)", + "Topic: %(topic)s ": "Topic: %(topic)s ", + "Add a topic to help people know what it is about.": "Add a topic to help people know what it is about.", + "You created this room.": "You created this room.", + "%(displayName)s created this room.": "%(displayName)s created this room.", + "Invite to just this room": "Invite to just this room", + "Add a photo, so people can easily spot your room.": "Add a photo, so people can easily spot your room.", + "This is the start of .": "This is the start of .", + "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.": "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.", + "Enable encryption in settings.": "Enable encryption in settings.", + "End-to-end encryption isn't enabled": "End-to-end encryption isn't enabled", + "Message didn't send. Click for info.": "Message didn't send. Click for info.", + "Unpin": "Unpin", + "View message": "View message", + "%(duration)ss": "%(duration)ss", + "%(duration)sm": "%(duration)sm", + "%(duration)sh": "%(duration)sh", + "%(duration)sd": "%(duration)sd", + "Busy": "Busy", + "Online for %(duration)s": "Online for %(duration)s", + "Idle for %(duration)s": "Idle for %(duration)s", + "Offline for %(duration)s": "Offline for %(duration)s", + "Unknown for %(duration)s": "Unknown for %(duration)s", + "Online": "Online", + "Idle": "Idle", + "Offline": "Offline", + "Unknown": "Unknown", + "Preview": "Preview", + "View": "View", + "%(members)s and more": "%(members)s and more", + "%(members)s and %(last)s": "%(members)s and %(last)s", + "Seen by %(count)s people|other": "Seen by %(count)s people", + "Seen by %(count)s people|one": "Seen by %(count)s person", + "Read receipts": "Read receipts", + "Recently viewed": "Recently viewed", + "Replying": "Replying", + "Room %(name)s": "Room %(name)s", + "Recently visited rooms": "Recently visited rooms", + "No recently visited rooms": "No recently visited rooms", + "(~%(count)s results)|other": "(~%(count)s results)", + "(~%(count)s results)|one": "(~%(count)s result)", + "Join Room": "Join Room", + "Room options": "Room options", + "Forget room": "Forget room", + "Hide Widgets": "Hide Widgets", + "Show Widgets": "Show Widgets", + "Search": "Search", + "Invite": "Invite", + "Video room": "Video room", + "Public space": "Public space", + "Public room": "Public room", + "Private space": "Private space", + "Private room": "Private room", + "%(count)s members|other": "%(count)s members", + "%(count)s members|one": "%(count)s member", + "Start new chat": "Start new chat", + "Invite to space": "Invite to space", + "You do not have permissions to invite people to this space": "You do not have permissions to invite people to this space", + "Add people": "Add people", + "Start chat": "Start chat", + "Explore rooms": "Explore rooms", + "New room": "New room", + "You do not have permissions to create new rooms in this space": "You do not have permissions to create new rooms in this space", + "New video room": "New video room", + "Add existing room": "Add existing room", + "You do not have permissions to add rooms to this space": "You do not have permissions to add rooms to this space", + "Explore public rooms": "Explore public rooms", + "Add room": "Add room", + "Invites": "Invites", + "Low priority": "Low priority", + "System Alerts": "System Alerts", + "Historical": "Historical", + "Suggested Rooms": "Suggested Rooms", + "Empty room": "Empty room", + "Can't see what you're looking for?": "Can't see what you're looking for?", + "Start a new chat": "Start a new chat", + "Explore all public rooms": "Explore all public rooms", + "%(count)s results|other": "%(count)s results", + "%(count)s results|one": "%(count)s result", + "Add space": "Add space", + "You do not have permissions to add spaces to this space": "You do not have permissions to add spaces to this space", + "Join public room": "Join public room", + "Currently joining %(count)s rooms|other": "Currently joining %(count)s rooms", + "Currently joining %(count)s rooms|one": "Currently joining %(count)s room", + "Currently removing messages in %(count)s rooms|other": "Currently removing messages in %(count)s rooms", + "Currently removing messages in %(count)s rooms|one": "Currently removing messages in %(count)s room", + "%(spaceName)s menu": "%(spaceName)s menu", + "Home options": "Home options", + "Joining space …": "Joining space …", + "Joining room …": "Joining room …", + "Joining …": "Joining …", + "Loading …": "Loading …", + "Rejecting invite …": "Rejecting invite …", + "Join the conversation with an account": "Join the conversation with an account", + "Sign Up": "Sign Up", + "Loading preview": "Loading preview", + "You were removed from %(roomName)s by %(memberName)s": "You were removed from %(roomName)s by %(memberName)s", + "You were removed by %(memberName)s": "You were removed by %(memberName)s", + "Reason: %(reason)s": "Reason: %(reason)s", + "Forget this space": "Forget this space", + "Forget this room": "Forget this room", + "Re-join": "Re-join", + "You were banned from %(roomName)s by %(memberName)s": "You were banned from %(roomName)s by %(memberName)s", + "You were banned by %(memberName)s": "You were banned by %(memberName)s", + "Something went wrong with your invite to %(roomName)s": "Something went wrong with your invite to %(roomName)s", + "Something went wrong with your invite.": "Something went wrong with your invite.", + "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to the person who invited you.": "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to the person who invited you.", + "unknown error code": "unknown error code", + "You can only join it with a working invite.": "You can only join it with a working invite.", + "Try to join anyway": "Try to join anyway", + "You can still join here.": "You can still join here.", + "Join the discussion": "Join the discussion", + "This invite to %(roomName)s was sent to %(email)s which is not associated with your account": "This invite to %(roomName)s was sent to %(email)s which is not associated with your account", + "This invite was sent to %(email)s which is not associated with your account": "This invite was sent to %(email)s which is not associated with your account", + "Link this email with your account in Settings to receive invites directly in %(brand)s.": "Link this email with your account in Settings to receive invites directly in %(brand)s.", + "This invite to %(roomName)s was sent to %(email)s": "This invite to %(roomName)s was sent to %(email)s", + "This invite was sent to %(email)s": "This invite was sent to %(email)s", + "Use an identity server in Settings to receive invites directly in %(brand)s.": "Use an identity server in Settings to receive invites directly in %(brand)s.", + "Share this email in Settings to receive invites directly in %(brand)s.": "Share this email in Settings to receive invites directly in %(brand)s.", + "Do you want to chat with %(user)s?": "Do you want to chat with %(user)s?", + " wants to chat": " wants to chat", + "Start chatting": "Start chatting", + "Do you want to join %(roomName)s?": "Do you want to join %(roomName)s?", + " invited you": " invited you", + "Reject": "Reject", + "Reject & Ignore user": "Reject & Ignore user", + "You're previewing %(roomName)s. Want to join it?": "You're previewing %(roomName)s. Want to join it?", + "%(roomName)s can't be previewed. Do you want to join it?": "%(roomName)s can't be previewed. Do you want to join it?", + "There's no preview, would you like to join?": "There's no preview, would you like to join?", + "%(roomName)s does not exist.": "%(roomName)s does not exist.", + "This room or space does not exist.": "This room or space does not exist.", + "Are you sure you're at the right place?": "Are you sure you're at the right place?", + "%(roomName)s is not accessible at this time.": "%(roomName)s is not accessible at this time.", + "This room or space is not accessible at this time.": "This room or space is not accessible at this time.", + "Try again later, or ask a room or space admin to check if you have access.": "Try again later, or ask a room or space admin to check if you have access.", + "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.": "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.", + "Leave": "Leave", + " invites you": " invites you", + "To view %(roomName)s, you need an invite": "To view %(roomName)s, you need an invite", + "To view, please enable video rooms in Labs first": "To view, please enable video rooms in Labs first", + "To join, please enable video rooms in Labs first": "To join, please enable video rooms in Labs first", + "Show Labs settings": "Show Labs settings", + "Appearance": "Appearance", + "Show rooms with unread messages first": "Show rooms with unread messages first", + "Show previews of messages": "Show previews of messages", + "Sort by": "Sort by", + "Activity": "Activity", + "A-Z": "A-Z", + "List options": "List options", + "Show %(count)s more|other": "Show %(count)s more", + "Show %(count)s more|one": "Show %(count)s more", + "Show less": "Show less", + "Use default": "Use default", + "Mentions & Keywords": "Mentions & Keywords", + "Notification options": "Notification options", + "Forget Room": "Forget Room", + "Favourited": "Favourited", + "Favourite": "Favourite", + "Low Priority": "Low Priority", + "Copy room link": "Copy room link", + "Video": "Video", + "Joining…": "Joining…", + "Joined": "Joined", + "%(count)s participants|other": "%(count)s participants", + "%(count)s participants|one": "1 participant", + "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", + "%(count)s unread messages including mentions.|one": "1 unread mention.", + "%(count)s unread messages.|other": "%(count)s unread messages.", + "%(count)s unread messages.|one": "1 unread message.", + "Unread messages.": "Unread messages.", + "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.", + "This room has already been upgraded.": "This room has already been upgraded.", + "This room is running room version , which this homeserver has marked as unstable.": "This room is running room version , which this homeserver has marked as unstable.", + "Only room administrators will see this warning": "Only room administrators will see this warning", + "This Room": "This Room", + "All Rooms": "All Rooms", + "Search…": "Search…", + "Failed to connect to integration manager": "Failed to connect to integration manager", + "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", + "Add some now": "Add some now", + "Stickerpack": "Stickerpack", + "Failed to revoke invite": "Failed to revoke invite", + "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.": "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.", + "Admin Tools": "Admin Tools", + "Revoke invite": "Revoke invite", + "Invited by %(sender)s": "Invited by %(sender)s", + "%(count)s reply|other": "%(count)s replies", + "%(count)s reply|one": "%(count)s reply", + "Open thread": "Open thread", + "Jump to first unread message.": "Jump to first unread message.", + "Mark all as read": "Mark all as read", + "Unable to access your microphone": "Unable to access your microphone", + "We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.", + "No microphone found": "No microphone found", + "We didn't find a microphone on your device. Please check your settings and try again.": "We didn't find a microphone on your device. Please check your settings and try again.", + "Stop recording": "Stop recording", + "Error updating main address": "Error updating main address", + "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.", + "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.", + "Error creating address": "Error creating address", + "There was an error creating that address. It may not be allowed by the server or a temporary failure occurred.": "There was an error creating that address. It may not be allowed by the server or a temporary failure occurred.", + "You don't have permission to delete the address.": "You don't have permission to delete the address.", + "There was an error removing that address. It may no longer exist or a temporary error occurred.": "There was an error removing that address. It may no longer exist or a temporary error occurred.", + "Error removing address": "Error removing address", + "Main address": "Main address", + "not specified": "not specified", + "This space has no local addresses": "This space has no local addresses", + "This room has no local addresses": "This room has no local addresses", + "Local address": "Local address", + "Published Addresses": "Published Addresses", + "Published addresses can be used by anyone on any server to join your space.": "Published addresses can be used by anyone on any server to join your space.", + "Published addresses can be used by anyone on any server to join your room.": "Published addresses can be used by anyone on any server to join your room.", + "To publish an address, it needs to be set as a local address first.": "To publish an address, it needs to be set as a local address first.", + "Other published addresses:": "Other published addresses:", + "No other published addresses yet, add one below": "No other published addresses yet, add one below", + "New published address (e.g. #alias:server)": "New published address (e.g. #alias:server)", + "Local Addresses": "Local Addresses", + "Set addresses for this space so users can find this space through your homeserver (%(localDomain)s)": "Set addresses for this space so users can find this space through your homeserver (%(localDomain)s)", + "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)", + "Show more": "Show more", + "Room Name": "Room Name", + "Room Topic": "Room Topic", + "Room avatar": "Room avatar", + "Publish this room to the public in %(domain)s's room directory?": "Publish this room to the public in %(domain)s's room directory?", + "You have enabled URL previews by default.": "You have enabled URL previews by default.", + "You have disabled URL previews by default.": "You have disabled URL previews by default.", + "URL previews are enabled by default for participants in this room.": "URL previews are enabled by default for participants in this room.", + "URL previews are disabled by default for participants in this room.": "URL previews are disabled by default for participants in this room.", + "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.", + "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.", + "URL Previews": "URL Previews", + "Back": "Back", + "To proceed, please accept the verification request on your other device.": "To proceed, please accept the verification request on your other device.", + "Waiting for %(displayName)s to accept…": "Waiting for %(displayName)s to accept…", + "Accepting…": "Accepting…", + "Start Verification": "Start Verification", + "Messages in this room are end-to-end encrypted.": "Messages in this room are end-to-end encrypted.", + "Your messages are secured and only you and the recipient have the unique keys to unlock them.": "Your messages are secured and only you and the recipient have the unique keys to unlock them.", + "Messages in this room are not end-to-end encrypted.": "Messages in this room are not end-to-end encrypted.", + "In encrypted rooms, your messages are secured and only you and the recipient have the unique keys to unlock them.": "In encrypted rooms, your messages are secured and only you and the recipient have the unique keys to unlock them.", + "Verify User": "Verify User", + "For extra security, verify this user by checking a one-time code on both of your devices.": "For extra security, verify this user by checking a one-time code on both of your devices.", + "Your messages are not secure": "Your messages are not secure", + "One of the following may be compromised:": "One of the following may be compromised:", + "Your homeserver": "Your homeserver", + "The homeserver the user you're verifying is connected to": "The homeserver the user you're verifying is connected to", + "Yours, or the other users' internet connection": "Yours, or the other users' internet connection", + "Yours, or the other users' session": "Yours, or the other users' session", + "Nothing pinned, yet": "Nothing pinned, yet", + "If you have permissions, open the menu on any message and select Pin to stick them here.": "If you have permissions, open the menu on any message and select Pin to stick them here.", + "Pinned messages": "Pinned messages", + "Chat": "Chat", + "Room Info": "Room Info", + "You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets", + "Maximise": "Maximise", + "Unpin this widget to view it in this panel": "Unpin this widget to view it in this panel", + "Close this widget to view it in this panel": "Close this widget to view it in this panel", + "Set my room layout for everyone": "Set my room layout for everyone", + "Edit widgets, bridges & bots": "Edit widgets, bridges & bots", + "Add widgets, bridges & bots": "Add widgets, bridges & bots", + "Not encrypted": "Not encrypted", + "About": "About", + "Files": "Files", + "Pinned": "Pinned", + "Export chat": "Export chat", + "Share room": "Share room", + "Room settings": "Room settings", + "Trusted": "Trusted", + "Not trusted": "Not trusted", + "Unable to load session list": "Unable to load session list", + "%(count)s verified sessions|other": "%(count)s verified sessions", + "%(count)s verified sessions|one": "1 verified session", + "Hide verified sessions": "Hide verified sessions", + "%(count)s sessions|other": "%(count)s sessions", + "%(count)s sessions|one": "%(count)s session", + "Hide sessions": "Hide sessions", + "Message": "Message", + "Jump to read receipt": "Jump to read receipt", + "Mention": "Mention", + "Share Link to User": "Share Link to User", + "Demote yourself?": "Demote yourself?", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.", + "Demote": "Demote", + "Disinvite from space": "Disinvite from space", + "Remove from space": "Remove from space", + "Disinvite from room": "Disinvite from room", + "Remove from room": "Remove from room", + "Disinvite from %(roomName)s": "Disinvite from %(roomName)s", + "Remove from %(roomName)s": "Remove from %(roomName)s", + "Remove them from everything I'm able to": "Remove them from everything I'm able to", + "Remove them from specific things I'm able to": "Remove them from specific things I'm able to", + "They'll still be able to access whatever you're not an admin of.": "They'll still be able to access whatever you're not an admin of.", + "Failed to remove user": "Failed to remove user", + "Remove recent messages": "Remove recent messages", + "Unban from space": "Unban from space", + "Ban from space": "Ban from space", + "Unban from room": "Unban from room", + "Ban from room": "Ban from room", + "Unban from %(roomName)s": "Unban from %(roomName)s", + "Ban from %(roomName)s": "Ban from %(roomName)s", + "Unban them from everything I'm able to": "Unban them from everything I'm able to", + "Ban them from everything I'm able to": "Ban them from everything I'm able to", + "Unban them from specific things I'm able to": "Unban them from specific things I'm able to", + "Ban them from specific things I'm able to": "Ban them from specific things I'm able to", + "They won't be able to access whatever you're not an admin of.": "They won't be able to access whatever you're not an admin of.", + "Failed to ban user": "Failed to ban user", + "Failed to mute user": "Failed to mute user", + "Unmute": "Unmute", + "Mute": "Mute", + "Failed to change power level": "Failed to change power level", + "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.": "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.", + "Are you sure?": "Are you sure?", + "Deactivate user?": "Deactivate user?", + "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?", + "Deactivate user": "Deactivate user", + "Failed to deactivate user": "Failed to deactivate user", + "Role in ": "Role in ", + "This client does not support end-to-end encryption.": "This client does not support end-to-end encryption.", + "Edit devices": "Edit devices", + "Security": "Security", + "The device you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "The device you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.", + "Scan this unique code": "Scan this unique code", + "Compare unique emoji": "Compare unique emoji", + "Compare a unique set of emoji if you don't have a camera on either device": "Compare a unique set of emoji if you don't have a camera on either device", + "Start": "Start", + "or": "or", + "Verify this device by completing one of the following:": "Verify this device by completing one of the following:", + "Verify by scanning": "Verify by scanning", + "Ask %(displayName)s to scan your code:": "Ask %(displayName)s to scan your code:", + "If you can't scan the code above, verify by comparing unique emoji.": "If you can't scan the code above, verify by comparing unique emoji.", + "Verify by comparing unique emoji.": "Verify by comparing unique emoji.", + "Verify by emoji": "Verify by emoji", + "Almost there! Is your other device showing the same shield?": "Almost there! Is your other device showing the same shield?", + "Almost there! Is %(displayName)s showing the same shield?": "Almost there! Is %(displayName)s showing the same shield?", + "Verify all users in a room to ensure it's secure.": "Verify all users in a room to ensure it's secure.", + "In encrypted rooms, verify all users to ensure it's secure.": "In encrypted rooms, verify all users to ensure it's secure.", + "You've successfully verified your device!": "You've successfully verified your device!", + "You've successfully verified %(deviceName)s (%(deviceId)s)!": "You've successfully verified %(deviceName)s (%(deviceId)s)!", + "You've successfully verified %(displayName)s!": "You've successfully verified %(displayName)s!", + "Got it": "Got it", + "Start verification again from the notification.": "Start verification again from the notification.", + "Start verification again from their profile.": "Start verification again from their profile.", + "Verification timed out.": "Verification timed out.", + "You cancelled verification on your other device.": "You cancelled verification on your other device.", + "%(displayName)s cancelled verification.": "%(displayName)s cancelled verification.", + "You cancelled verification.": "You cancelled verification.", + "Verification cancelled": "Verification cancelled", + "Call declined": "Call declined", + "Call back": "Call back", + "No answer": "No answer", + "Could not connect media": "Could not connect media", + "Connection failed": "Connection failed", + "Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone", + "An unknown error occurred": "An unknown error occurred", + "Unknown failure: %(reason)s": "Unknown failure: %(reason)s", + "Retry": "Retry", + "Missed call": "Missed call", + "The call is in an unknown state!": "The call is in an unknown state!", + "Sunday": "Sunday", + "Monday": "Monday", + "Tuesday": "Tuesday", + "Wednesday": "Wednesday", + "Thursday": "Thursday", + "Friday": "Friday", + "Saturday": "Saturday", + "Today": "Today", + "Yesterday": "Yesterday", + "Unable to find event at that date. (%(code)s)": "Unable to find event at that date. (%(code)s)", + "Last week": "Last week", + "Last month": "Last month", + "The beginning of the room": "The beginning of the room", + "Jump to date": "Jump to date", + "Downloading": "Downloading", + "Decrypting": "Decrypting", + "Download": "Download", + "View Source": "View Source", + "Some encryption parameters have been changed.": "Some encryption parameters have been changed.", + "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.", + "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.", + "Encryption enabled": "Encryption enabled", + "Ignored attempt to disable encryption": "Ignored attempt to disable encryption", + "Encryption not enabled": "Encryption not enabled", + "The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.", + "Message pending moderation: %(reason)s": "Message pending moderation: %(reason)s", + "Message pending moderation": "Message pending moderation", + "Pick a date to jump to": "Pick a date to jump to", + "Go": "Go", + "Error processing audio message": "Error processing audio message", + "View live location": "View live location", + "React": "React", + "Can't create a thread from an event with an existing relation": "Can't create a thread from an event with an existing relation", + "Beta feature": "Beta feature", + "Beta feature. Click to learn more.": "Beta feature. Click to learn more.", + "Edit": "Edit", + "Reply": "Reply", + "Collapse quotes": "Collapse quotes", + "Expand quotes": "Expand quotes", + "Click": "Click", + "Download %(text)s": "Download %(text)s", + "Error decrypting attachment": "Error decrypting attachment", + "Decrypt %(text)s": "Decrypt %(text)s", + "Invalid file%(extra)s": "Invalid file%(extra)s", + "Image": "Image", + "Error decrypting image": "Error decrypting image", + "Show image": "Show image", + "Join the conference at the top of this room": "Join the conference at the top of this room", + "Join the conference from the room information card on the right": "Join the conference from the room information card on the right", + "Video conference ended by %(senderName)s": "Video conference ended by %(senderName)s", + "Video conference updated by %(senderName)s": "Video conference updated by %(senderName)s", + "Video conference started by %(senderName)s": "Video conference started by %(senderName)s", + "You have ignored this user, so their message is hidden. Show anyways.": "You have ignored this user, so their message is hidden. Show anyways.", + "You verified %(name)s": "You verified %(name)s", + "You cancelled verifying %(name)s": "You cancelled verifying %(name)s", + "%(name)s cancelled verifying": "%(name)s cancelled verifying", + "You accepted": "You accepted", + "%(name)s accepted": "%(name)s accepted", + "You declined": "You declined", + "You cancelled": "You cancelled", + "%(name)s declined": "%(name)s declined", + "%(name)s cancelled": "%(name)s cancelled", + "Accepting …": "Accepting …", + "Declining …": "Declining …", + "%(name)s wants to verify": "%(name)s wants to verify", + "You sent a verification request": "You sent a verification request", + "Expand map": "Expand map", + "Unable to load map": "Unable to load map", + "Shared their location: ": "Shared their location: ", + "Shared a location: ": "Shared a location: ", + "Can't edit poll": "Can't edit poll", + "Sorry, you can't edit a poll after votes have been cast.": "Sorry, you can't edit a poll after votes have been cast.", + "Vote not registered": "Vote not registered", + "Sorry, your vote was not registered. Please try again.": "Sorry, your vote was not registered. Please try again.", + "Final result based on %(count)s votes|other": "Final result based on %(count)s votes", + "Final result based on %(count)s votes|one": "Final result based on %(count)s vote", + "Results will be visible when the poll is ended": "Results will be visible when the poll is ended", + "No votes cast": "No votes cast", + "%(count)s votes cast. Vote to see the results|other": "%(count)s votes cast. Vote to see the results", + "%(count)s votes cast. Vote to see the results|one": "%(count)s vote cast. Vote to see the results", + "Based on %(count)s votes|other": "Based on %(count)s votes", + "Based on %(count)s votes|one": "Based on %(count)s vote", + "edited": "edited", + "%(count)s votes|other": "%(count)s votes", + "%(count)s votes|one": "%(count)s vote", + "Error decrypting video": "Error decrypting video", + "Error processing voice message": "Error processing voice message", + "Add reaction": "Add reaction", + "Show all": "Show all", + "Reactions": "Reactions", + "%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s", + "reacted with %(shortName)s": "reacted with %(shortName)s", + "Message deleted on %(date)s": "Message deleted on %(date)s", + "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", + "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.", + "%(senderDisplayName)s changed the room avatar to ": "%(senderDisplayName)s changed the room avatar to ", + "Click here to see older messages.": "Click here to see older messages.", + "This room is a continuation of another conversation.": "This room is a continuation of another conversation.", + "Add an Integration": "Add an Integration", + "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?", + "Edited at %(date)s": "Edited at %(date)s", + "Click to view edits": "Click to view edits", + "Edited at %(date)s. Click to view edits.": "Edited at %(date)s. Click to view edits.", + "Submit logs": "Submit logs", + "Can't load this message": "Can't load this message", + "toggle event": "toggle event", + "Live location sharing": "Live location sharing", + "Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.": "Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.", + "Enable live location sharing": "Enable live location sharing", + "Share for %(duration)s": "Share for %(duration)s", + "Location": "Location", + "Could not fetch location": "Could not fetch location", + "Click to move the pin": "Click to move the pin", + "Click to drop a pin": "Click to drop a pin", + "Share location": "Share location", + "%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.": "%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.", + "Failed to fetch your location. Please try again later.": "Failed to fetch your location. Please try again later.", + "Timed out trying to fetch your location. Please try again later.": "Timed out trying to fetch your location. Please try again later.", + "Unknown error fetching location. Please try again later.": "Unknown error fetching location. Please try again later.", + "We couldn't send your location": "We couldn't send your location", + "%(brand)s could not send your location. Please try again later.": "%(brand)s could not send your location. Please try again later.", + "%(displayName)s's live location": "%(displayName)s's live location", + "My current location": "My current location", + "My live location": "My live location", + "Drop a Pin": "Drop a Pin", + "What location type do you want to share?": "What location type do you want to share?", + "Zoom in": "Zoom in", + "Zoom out": "Zoom out", + "Frequently Used": "Frequently Used", + "Smileys & People": "Smileys & People", + "Animals & Nature": "Animals & Nature", + "Food & Drink": "Food & Drink", + "Activities": "Activities", + "Travel & Places": "Travel & Places", + "Objects": "Objects", + "Symbols": "Symbols", + "Flags": "Flags", + "Categories": "Categories", + "Quick Reactions": "Quick Reactions", + "Cancel search": "Cancel search", + "Unknown Address": "Unknown Address", + "Any of the following data may be shared:": "Any of the following data may be shared:", + "Your display name": "Your display name", + "Your avatar URL": "Your avatar URL", + "Your user ID": "Your user ID", + "Your theme": "Your theme", + "%(brand)s URL": "%(brand)s URL", + "Room ID": "Room ID", + "Widget ID": "Widget ID", + "Using this widget may share data with %(widgetDomain)s & your integration manager.": "Using this widget may share data with %(widgetDomain)s & your integration manager.", + "Using this widget may share data with %(widgetDomain)s.": "Using this widget may share data with %(widgetDomain)s.", + "Widgets do not use message encryption.": "Widgets do not use message encryption.", + "Widget added by": "Widget added by", + "This widget may use cookies.": "This widget may use cookies.", + "Loading...": "Loading...", + "Error loading Widget": "Error loading Widget", + "Error - Mixed content": "Error - Mixed content", + "Popout widget": "Popout widget", + "Copy": "Copy", + "Share entire screen": "Share entire screen", + "Application window": "Application window", + "Share content": "Share content", + "Backspace": "Backspace", + "Please create a new issue on GitHub so that we can investigate this bug.": "Please create a new issue on GitHub so that we can investigate this bug.", + "Something went wrong!": "Something went wrong!", + "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", + "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times", + "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)sjoined", + "%(oneUser)sjoined %(count)s times|other": "%(oneUser)sjoined %(count)s times", + "%(oneUser)sjoined %(count)s times|one": "%(oneUser)sjoined", + "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)sleft %(count)s times", + "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)sleft", + "%(oneUser)sleft %(count)s times|other": "%(oneUser)sleft %(count)s times", + "%(oneUser)sleft %(count)s times|one": "%(oneUser)sleft", + "%(severalUsers)sjoined and left %(count)s times|other": "%(severalUsers)sjoined and left %(count)s times", + "%(severalUsers)sjoined and left %(count)s times|one": "%(severalUsers)sjoined and left", + "%(oneUser)sjoined and left %(count)s times|other": "%(oneUser)sjoined and left %(count)s times", + "%(oneUser)sjoined and left %(count)s times|one": "%(oneUser)sjoined and left", + "%(severalUsers)sleft and rejoined %(count)s times|other": "%(severalUsers)sleft and rejoined %(count)s times", + "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)sleft and rejoined", + "%(oneUser)sleft and rejoined %(count)s times|other": "%(oneUser)sleft and rejoined %(count)s times", + "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)sleft and rejoined", + "%(severalUsers)srejected their invitations %(count)s times|other": "%(severalUsers)srejected their invitations %(count)s times", + "%(severalUsers)srejected their invitations %(count)s times|one": "%(severalUsers)srejected their invitations", + "%(oneUser)srejected their invitation %(count)s times|other": "%(oneUser)srejected their invitation %(count)s times", + "%(oneUser)srejected their invitation %(count)s times|one": "%(oneUser)srejected their invitation", + "%(severalUsers)shad their invitations withdrawn %(count)s times|other": "%(severalUsers)shad their invitations withdrawn %(count)s times", + "%(severalUsers)shad their invitations withdrawn %(count)s times|one": "%(severalUsers)shad their invitations withdrawn", + "%(oneUser)shad their invitation withdrawn %(count)s times|other": "%(oneUser)shad their invitation withdrawn %(count)s times", + "%(oneUser)shad their invitation withdrawn %(count)s times|one": "%(oneUser)shad their invitation withdrawn", + "were invited %(count)s times|other": "were invited %(count)s times", + "were invited %(count)s times|one": "were invited", + "was invited %(count)s times|other": "was invited %(count)s times", + "was invited %(count)s times|one": "was invited", + "were banned %(count)s times|other": "were banned %(count)s times", + "were banned %(count)s times|one": "were banned", + "was banned %(count)s times|other": "was banned %(count)s times", + "was banned %(count)s times|one": "was banned", + "were unbanned %(count)s times|other": "were unbanned %(count)s times", + "were unbanned %(count)s times|one": "were unbanned", + "was unbanned %(count)s times|other": "was unbanned %(count)s times", + "was unbanned %(count)s times|one": "was unbanned", + "were removed %(count)s times|other": "were removed %(count)s times", + "were removed %(count)s times|one": "were removed", + "was removed %(count)s times|other": "was removed %(count)s times", + "was removed %(count)s times|one": "was removed", + "%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)schanged their name %(count)s times", + "%(severalUsers)schanged their name %(count)s times|one": "%(severalUsers)schanged their name", + "%(oneUser)schanged their name %(count)s times|other": "%(oneUser)schanged their name %(count)s times", + "%(oneUser)schanged their name %(count)s times|one": "%(oneUser)schanged their name", + "%(severalUsers)schanged their avatar %(count)s times|other": "%(severalUsers)schanged their avatar %(count)s times", + "%(severalUsers)schanged their avatar %(count)s times|one": "%(severalUsers)schanged their avatar", + "%(oneUser)schanged their avatar %(count)s times|other": "%(oneUser)schanged their avatar %(count)s times", + "%(oneUser)schanged their avatar %(count)s times|one": "%(oneUser)schanged their avatar", + "%(severalUsers)smade no changes %(count)s times|other": "%(severalUsers)smade no changes %(count)s times", + "%(severalUsers)smade no changes %(count)s times|one": "%(severalUsers)smade no changes", + "%(oneUser)smade no changes %(count)s times|other": "%(oneUser)smade no changes %(count)s times", + "%(oneUser)smade no changes %(count)s times|one": "%(oneUser)smade no changes", + "%(severalUsers)schanged the server ACLs %(count)s times|other": "%(severalUsers)schanged the server ACLs %(count)s times", + "%(severalUsers)schanged the server ACLs %(count)s times|one": "%(severalUsers)schanged the server ACLs", + "%(oneUser)schanged the server ACLs %(count)s times|other": "%(oneUser)schanged the server ACLs %(count)s times", + "%(oneUser)schanged the server ACLs %(count)s times|one": "%(oneUser)schanged the server ACLs", + "%(severalUsers)schanged the pinned messages for the room %(count)s times|other": "%(severalUsers)schanged the pinned messages for the room %(count)s times", + "%(severalUsers)schanged the pinned messages for the room %(count)s times|one": "%(severalUsers)schanged the pinned messages for the room", + "%(oneUser)schanged the pinned messages for the room %(count)s times|other": "%(oneUser)schanged the pinned messages for the room %(count)s times", + "%(oneUser)schanged the pinned messages for the room %(count)s times|one": "%(oneUser)schanged the pinned messages for the room", + "%(severalUsers)sremoved a message %(count)s times|other": "%(severalUsers)sremoved %(count)s messages", + "%(severalUsers)sremoved a message %(count)s times|one": "%(severalUsers)sremoved a message", + "%(oneUser)sremoved a message %(count)s times|other": "%(oneUser)sremoved %(count)s messages", + "%(oneUser)sremoved a message %(count)s times|one": "%(oneUser)sremoved a message", + "%(severalUsers)ssent %(count)s hidden messages|other": "%(severalUsers)ssent %(count)s hidden messages", + "%(severalUsers)ssent %(count)s hidden messages|one": "%(severalUsers)ssent a hidden message", + "%(oneUser)ssent %(count)s hidden messages|other": "%(oneUser)ssent %(count)s hidden messages", + "%(oneUser)ssent %(count)s hidden messages|one": "%(oneUser)ssent a hidden message", + "collapse": "collapse", + "expand": "expand", + "Rotate Left": "Rotate Left", + "Rotate Right": "Rotate Right", + "Information": "Information", + "Language Dropdown": "Language Dropdown", + "Create poll": "Create poll", + "Create Poll": "Create Poll", + "Edit poll": "Edit poll", + "Done": "Done", + "Failed to post poll": "Failed to post poll", + "Sorry, the poll you tried to create was not posted.": "Sorry, the poll you tried to create was not posted.", + "Poll type": "Poll type", + "Open poll": "Open poll", + "Closed poll": "Closed poll", + "What is your poll question or topic?": "What is your poll question or topic?", + "Question or topic": "Question or topic", + "Write something...": "Write something...", + "Create options": "Create options", + "Option %(number)s": "Option %(number)s", + "Write an option": "Write an option", + "Add option": "Add option", + "Voters see results as soon as they have voted": "Voters see results as soon as they have voted", + "Results are only revealed when you end the poll": "Results are only revealed when you end the poll", + "Power level": "Power level", + "Custom level": "Custom level", + "QR Code": "QR Code", + "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.", + "In reply to ": "In reply to ", + "In reply to this message": "In reply to this message", + "Room address": "Room address", + "e.g. my-room": "e.g. my-room", + "Missing domain separator e.g. (:domain.org)": "Missing domain separator e.g. (:domain.org)", + "Missing room name or separator e.g. (my-room:domain.org)": "Missing room name or separator e.g. (my-room:domain.org)", + "Some characters not allowed": "Some characters not allowed", + "Please provide an address": "Please provide an address", + "This address does not point at this room": "This address does not point at this room", + "This address is available to use": "This address is available to use", + "This address is already in use": "This address is already in use", + "This address had invalid server or is already in use": "This address had invalid server or is already in use", + "View all %(count)s members|other": "View all %(count)s members", + "View all %(count)s members|one": "View 1 member", + "Including you, %(commaSeparatedMembers)s": "Including you, %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", + "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", + "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", + "Edit topic": "Edit topic", + "Click to read topic": "Click to read topic", + "Message search initialisation failed, check your settings for more information": "Message search initialisation failed, check your settings for more information", + "Use the Desktop app to see all encrypted files": "Use the Desktop app to see all encrypted files", + "Use the Desktop app to search encrypted messages": "Use the Desktop app to search encrypted messages", + "This version of %(brand)s does not support viewing some encrypted files": "This version of %(brand)s does not support viewing some encrypted files", + "This version of %(brand)s does not support searching encrypted messages": "This version of %(brand)s does not support searching encrypted messages", + "Server Options": "Server Options", + "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.", + "Join millions for free on the largest public server": "Join millions for free on the largest public server", + "Homeserver": "Homeserver", + "Continue with %(provider)s": "Continue with %(provider)s", + "Sign in with single sign-on": "Sign in with single sign-on", + "And %(count)s more...|other": "And %(count)s more...", + "Enter a server name": "Enter a server name", + "Looks good": "Looks good", + "You are not allowed to view this server's rooms list": "You are not allowed to view this server's rooms list", + "Can't find this server or its room list": "Can't find this server or its room list", + "Your server": "Your server", + "Are you sure you want to remove %(serverName)s": "Are you sure you want to remove %(serverName)s", + "Remove server": "Remove server", + "Matrix": "Matrix", + "Add a new server": "Add a new server", + "Enter the name of a new server you want to explore.": "Enter the name of a new server you want to explore.", + "Server name": "Server name", + "Add a new server...": "Add a new server...", + "%(networkName)s rooms": "%(networkName)s rooms", + "Matrix rooms": "Matrix rooms", + "Add existing space": "Add existing space", + "Want to add a new space instead?": "Want to add a new space instead?", + "Create a new space": "Create a new space", + "Search for spaces": "Search for spaces", + "Not all selected were added": "Not all selected were added", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...", + "Direct Messages": "Direct Messages", + "Add existing rooms": "Add existing rooms", + "Want to add a new room instead?": "Want to add a new room instead?", + "Create a new room": "Create a new room", + "Search for rooms": "Search for rooms", + "Adding spaces has moved.": "Adding spaces has moved.", + "You can read all our terms here": "You can read all our terms here", + "Help us identify issues and improve %(analyticsOwner)s by sharing anonymous usage data. To understand how people use multiple devices, we'll generate a random identifier, shared by your devices.": "Help us identify issues and improve %(analyticsOwner)s by sharing anonymous usage data. To understand how people use multiple devices, we'll generate a random identifier, shared by your devices.", + "We don't record or profile any account data": "We don't record or profile any account data", + "We don't share information with third parties": "We don't share information with third parties", + "You can turn this off anytime in settings": "You can turn this off anytime in settings", + "The following users may not exist": "The following users may not exist", + "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?", + "Invite anyway and never warn me again": "Invite anyway and never warn me again", + "Invite anyway": "Invite anyway", + "Close dialog": "Close dialog", + "%(featureName)s Beta feedback": "%(featureName)s Beta feedback", + "To leave the beta, visit your settings.": "To leave the beta, visit your settings.", + "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.", + "Preparing to send logs": "Preparing to send logs", + "Logs sent": "Logs sent", + "Thank you!": "Thank you!", + "Failed to send logs: ": "Failed to send logs: ", + "Preparing to download logs": "Preparing to download logs", + "Reminder: Your browser is unsupported, so your experience may be unpredictable.": "Reminder: Your browser is unsupported, so your experience may be unpredictable.", + "Before submitting logs, you must create a GitHub issue to describe your problem.": "Before submitting logs, you must create a GitHub issue to describe your problem.", + "Download logs": "Download logs", + "GitHub issue": "GitHub issue", + "Notes": "Notes", + "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.", + "Send logs": "Send logs", + "No recent messages by %(user)s found": "No recent messages by %(user)s found", + "Try scrolling up in the timeline to see if there are any earlier ones.": "Try scrolling up in the timeline to see if there are any earlier ones.", + "Remove recent messages by %(user)s": "Remove recent messages by %(user)s", + "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|other": "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?", + "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|one": "You are about to remove %(count)s message by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?", + "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.": "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.", + "Preserve system messages": "Preserve system messages", + "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)": "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)", + "Remove %(count)s messages|other": "Remove %(count)s messages", + "Remove %(count)s messages|one": "Remove 1 message", + "Unable to load commit detail: %(msg)s": "Unable to load commit detail: %(msg)s", + "Unavailable": "Unavailable", + "Changelog": "Changelog", + "You cannot delete this message. (%(code)s)": "You cannot delete this message. (%(code)s)", + "Removing…": "Removing…", + "Confirm Removal": "Confirm Removal", + "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.", + "Reason (optional)": "Reason (optional)", + "Clear all data in this session?": "Clear all data in this session?", + "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.", + "Clear all data": "Clear all data", + "Please enter a name for the room": "Please enter a name for the room", + "Everyone in will be able to find and join this room.": "Everyone in will be able to find and join this room.", + "You can change this at any time from room settings.": "You can change this at any time from room settings.", + "Anyone will be able to find and join this room, not just members of .": "Anyone will be able to find and join this room, not just members of .", + "Anyone will be able to find and join this room.": "Anyone will be able to find and join this room.", + "Only people invited will be able to find and join this room.": "Only people invited will be able to find and join this room.", + "You can't disable this later. Bridges & most bots won't work yet.": "You can't disable this later. Bridges & most bots won't work yet.", + "Your server requires encryption to be enabled in private rooms.": "Your server requires encryption to be enabled in private rooms.", + "Enable end-to-end encryption": "Enable end-to-end encryption", + "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.", + "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.", + "Create a video room": "Create a video room", + "Create a room": "Create a room", + "Create a public room": "Create a public room", + "Create a private room": "Create a private room", + "Topic (optional)": "Topic (optional)", + "Room visibility": "Room visibility", + "Private room (invite only)": "Private room (invite only)", + "Visible to space members": "Visible to space members", + "Block anyone not part of %(serverName)s from ever joining this room.": "Block anyone not part of %(serverName)s from ever joining this room.", + "Create video room": "Create video room", + "Create room": "Create room", + "Anyone in will be able to find and join.": "Anyone in will be able to find and join.", + "Anyone will be able to find and join this space, not just members of .": "Anyone will be able to find and join this space, not just members of .", + "Only people invited will be able to find and join this space.": "Only people invited will be able to find and join this space.", + "Add a space to a space you manage.": "Add a space to a space you manage.", + "Space visibility": "Space visibility", + "Private space (invite only)": "Private space (invite only)", + "Want to add an existing space instead?": "Want to add an existing space instead?", + "Adding...": "Adding...", + "Sign out": "Sign out", + "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this", + "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.", + "Incompatible Database": "Incompatible Database", + "Continue With Encryption Disabled": "Continue With Encryption Disabled", + "Confirm your account deactivation by using Single Sign On to prove your identity.": "Confirm your account deactivation by using Single Sign On to prove your identity.", + "Are you sure you want to deactivate your account? This is irreversible.": "Are you sure you want to deactivate your account? This is irreversible.", + "Confirm account deactivation": "Confirm account deactivation", + "To continue, please enter your account password:": "To continue, please enter your account password:", + "There was a problem communicating with the server. Please try again.": "There was a problem communicating with the server. Please try again.", + "Server did not require any authentication": "Server did not require any authentication", + "Server did not return valid authentication information.": "Server did not return valid authentication information.", + "Confirm that you would like to deactivate your account. If you proceed:": "Confirm that you would like to deactivate your account. If you proceed:", + "You will not be able to reactivate your account": "You will not be able to reactivate your account", + "You will no longer be able to log in": "You will no longer be able to log in", + "No one will be able to reuse your username (MXID), including you: this username will remain unavailable": "No one will be able to reuse your username (MXID), including you: this username will remain unavailable", + "You will leave all rooms and DMs that you are in": "You will leave all rooms and DMs that you are in", + "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number": "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number", + "Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?": "Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?", + "Hide my messages from new joiners": "Hide my messages from new joiners", + "Room": "Room", + "Send custom timeline event": "Send custom timeline event", + "Explore room state": "Explore room state", + "Explore room account data": "Explore room account data", + "View servers in room": "View servers in room", + "Verification explorer": "Verification explorer", + "Active Widgets": "Active Widgets", + "Explore account data": "Explore account data", + "Settings explorer": "Settings explorer", + "Server info": "Server info", + "Toolbox": "Toolbox", + "Developer Tools": "Developer Tools", + "Room ID: %(roomId)s": "Room ID: %(roomId)s", + "The poll has ended. No votes were cast.": "The poll has ended. No votes were cast.", + "The poll has ended. Top answer: %(topAnswer)s": "The poll has ended. Top answer: %(topAnswer)s", + "Failed to end poll": "Failed to end poll", + "Sorry, the poll did not end. Please try again.": "Sorry, the poll did not end. Please try again.", + "End Poll": "End Poll", + "Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.": "Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.", + "An error has occurred.": "An error has occurred.", + "Processing...": "Processing...", + "Enter a number between %(min)s and %(max)s": "Enter a number between %(min)s and %(max)s", + "Size can only be a number between %(min)s MB and %(max)s MB": "Size can only be a number between %(min)s MB and %(max)s MB", + "Number of messages can only be a number between %(min)s and %(max)s": "Number of messages can only be a number between %(min)s and %(max)s", + "Number of messages": "Number of messages", + "MB": "MB", + "Export Cancelled": "Export Cancelled", + "The export was cancelled successfully": "The export was cancelled successfully", + "Export Successful": "Export Successful", + "Your export was successful. Find it in your Downloads folder.": "Your export was successful. Find it in your Downloads folder.", + "Are you sure you want to stop exporting your data? If you do, you'll need to start over.": "Are you sure you want to stop exporting your data? If you do, you'll need to start over.", + "Exporting your data": "Exporting your data", + "Export Chat": "Export Chat", + "Select from the options below to export chats from your timeline": "Select from the options below to export chats from your timeline", + "Format": "Format", + "Size Limit": "Size Limit", + "Include Attachments": "Include Attachments", + "Export": "Export", + "Feedback sent": "Feedback sent", + "Comment": "Comment", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Your platform and username will be noted to help us use your feedback as much as we can.", + "Feedback": "Feedback", + "You may contact me if you want to follow up or to let me test out upcoming ideas": "You may contact me if you want to follow up or to let me test out upcoming ideas", + "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.", + "Report a bug": "Report a bug", + "Please view existing bugs on Github first. No match? Start a new one.": "Please view existing bugs on Github first. No match? Start a new one.", + "Send feedback": "Send feedback", + "You don't have permission to do this": "You don't have permission to do this", + "Sending": "Sending", + "Sent": "Sent", + "Open room": "Open room", + "Send": "Send", + "Forward message": "Forward message", + "Message preview": "Message preview", + "Search for rooms or people": "Search for rooms or people", + "Feedback sent! Thanks, we appreciate it!": "Feedback sent! Thanks, we appreciate it!", + "You may contact me if you have any follow up questions": "You may contact me if you have any follow up questions", + "Confirm abort of host creation": "Confirm abort of host creation", + "Are you sure you wish to abort creation of the host? The process cannot be continued.": "Are you sure you wish to abort creation of the host? The process cannot be continued.", + "Abort": "Abort", + "Failed to connect to your homeserver. Please close this dialog and try again.": "Failed to connect to your homeserver. Please close this dialog and try again.", + "Continuing temporarily allows the %(hostSignupBrand)s setup process to access your account to fetch verified email addresses. This data is not stored.": "Continuing temporarily allows the %(hostSignupBrand)s setup process to access your account to fetch verified email addresses. This data is not stored.", + "Learn more in our , and .": "Learn more in our , and .", + "Cookie Policy": "Cookie Policy", + "Privacy Policy": "Privacy Policy", + "Terms of Service": "Terms of Service", + "You should know": "You should know", + "%(hostSignupBrand)s Setup": "%(hostSignupBrand)s Setup", + "Maximise dialog": "Maximise dialog", + "Minimise dialog": "Minimise dialog", + "Upgrade to %(hostSignupBrand)s": "Upgrade to %(hostSignupBrand)s", + "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.", + "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.", + "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.", + "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.", + "Waiting for partner to confirm...": "Waiting for partner to confirm...", + "Incoming Verification Request": "Incoming Verification Request", + "Integrations are disabled": "Integrations are disabled", + "Enable 'Manage Integrations' in Settings to do this.": "Enable 'Manage Integrations' in Settings to do this.", + "Integrations not allowed": "Integrations not allowed", + "Your %(brand)s doesn't allow you to use an integration manager to do this. Please contact an admin.": "Your %(brand)s doesn't allow you to use an integration manager to do this. Please contact an admin.", + "To continue, use Single Sign On to prove your identity.": "To continue, use Single Sign On to prove your identity.", + "Confirm to continue": "Confirm to continue", + "Click the button below to confirm your identity.": "Click the button below to confirm your identity.", + "Invite by email": "Invite by email", + "We couldn't create your DM.": "We couldn't create your DM.", + "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 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", + "Suggestions": "Suggestions", + "Recently Direct Messaged": "Recently Direct Messaged", + "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.", + "Use an identity server to invite by email. Manage in Settings.": "Use an identity server to invite by email. Manage in Settings.", + "Start a conversation with someone using their name, email address or username (like ).": "Start a conversation with someone using their name, email address or username (like ).", + "Start a conversation with someone using their name or username (like ).": "Start a conversation with someone using their name or username (like ).", + "Some suggestions may be hidden for privacy.": "Some suggestions may be hidden for privacy.", + "If you can't see who you're looking for, send them your invite link below.": "If you can't see who you're looking for, send them your invite link below.", + "Or send invite link": "Or send invite link", + "Unnamed Space": "Unnamed Space", + "Invite to %(roomName)s": "Invite to %(roomName)s", + "Unnamed Room": "Unnamed Room", + "Invite someone using their name, email address, username (like ) or share this space.": "Invite someone using their name, email address, username (like ) or share this space.", + "Invite someone using their name, username (like ) or share this space.": "Invite someone using their name, username (like ) or share this space.", + "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.", + "Invited people will be able to read old messages.": "Invited people will be able to read old messages.", + "Transfer": "Transfer", + "Consult first": "Consult first", + "User Directory": "User Directory", + "Dial pad": "Dial pad", + "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", + "a key signature": "a key signature", + "%(brand)s encountered an error during upload of:": "%(brand)s encountered an error during upload of:", + "Upload completed": "Upload completed", + "Cancelled signature upload": "Cancelled signature upload", + "Unable to upload": "Unable to upload", + "Signature upload success": "Signature upload success", + "Signature upload failed": "Signature upload failed", + "You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.": "You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.", + "If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.", + "Incompatible local cache": "Incompatible local cache", + "Clear cache and resync": "Clear cache and resync", + "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!", + "Updating %(brand)s": "Updating %(brand)s", + "You won't be able to rejoin unless you are re-invited.": "You won't be able to rejoin unless you are re-invited.", + "You're the only admin of this space. Leaving it will mean no one has control over it.": "You're the only admin of this space. Leaving it will mean no one has control over it.", + "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.", + "Leave %(spaceName)s": "Leave %(spaceName)s", + "You are about to leave .": "You are about to leave .", + "Would you like to leave the rooms in this space?": "Would you like to leave the rooms in this space?", + "Don't leave any rooms": "Don't leave any rooms", + "Leave all rooms": "Leave all rooms", + "Leave some rooms": "Leave some rooms", + "Leave space": "Leave space", + "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.", + "Start using Key Backup": "Start using Key Backup", + "I don't want my encrypted messages": "I don't want my encrypted messages", + "Manually export keys": "Manually export keys", + "You'll lose access to your encrypted messages": "You'll lose access to your encrypted messages", + "Are you sure you want to sign out?": "Are you sure you want to sign out?", + "%(count)s rooms|other": "%(count)s rooms", + "%(count)s rooms|one": "%(count)s room", + "You're removing all spaces. Access will default to invite only": "You're removing all spaces. Access will default to invite only", + "Select spaces": "Select spaces", + "Decide which spaces can access this room. If a space is selected, its members can find and join .": "Decide which spaces can access this room. If a space is selected, its members can find and join .", + "Search spaces": "Search spaces", + "Spaces you know that contain this space": "Spaces you know that contain this space", + "Spaces you know that contain this room": "Spaces you know that contain this room", + "Other spaces or rooms you might not know": "Other spaces or rooms you might not know", + "These are likely ones other room admins are a part of.": "These are likely ones other room admins are a part of.", + "Confirm by comparing the following with the User Settings in your other session:": "Confirm by comparing the following with the User Settings in your other session:", + "Confirm this user's session by comparing the following with their User Settings:": "Confirm this user's session by comparing the following with their User Settings:", + "Session name": "Session name", + "Session ID": "Session ID", + "Session key": "Session key", + "If they don't match, the security of your communication may be compromised.": "If they don't match, the security of your communication may be compromised.", + "Verify session": "Verify session", + "Your homeserver doesn't seem to support this feature.": "Your homeserver doesn't seem to support this feature.", + "Message edits": "Message edits", + "Modal Widget": "Modal Widget", + "Data on this screen is shared with %(widgetDomain)s": "Data on this screen is shared with %(widgetDomain)s", + "Continuing without email": "Continuing without email", + "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.": "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.", + "Email (optional)": "Email (optional)", + "Please fill why you're reporting.": "Please fill why you're reporting.", + "Ignore user": "Ignore user", + "Check if you want to hide all current and future messages from this user.": "Check if you want to hide all current and future messages from this user.", + "What this user is writing is wrong.\nThis will be reported to the room moderators.": "What this user is writing is wrong.\nThis will be reported to the room moderators.", + "This user is displaying toxic behaviour, for instance by insulting other users or sharing adult-only content in a family-friendly room or otherwise violating the rules of this room.\nThis will be reported to the room moderators.": "This user is displaying toxic behaviour, for instance by insulting other users or sharing adult-only content in a family-friendly room or otherwise violating the rules of this room.\nThis will be reported to the room moderators.", + "This user is displaying illegal behaviour, for instance by doxing people or threatening violence.\nThis will be reported to the room moderators who may escalate this to legal authorities.": "This user is displaying illegal behaviour, for instance by doxing people or threatening violence.\nThis will be reported to the room moderators who may escalate this to legal authorities.", + "This user is spamming the room with ads, links to ads or to propaganda.\nThis will be reported to the room moderators.": "This user is spamming the room with ads, links to ads or to propaganda.\nThis will be reported to the room moderators.", + "This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\nThis will be reported to the administrators of %(homeserver)s. The administrators will NOT be able to read the encrypted content of this room.": "This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\nThis will be reported to the administrators of %(homeserver)s. The administrators will NOT be able to read the encrypted content of this room.", + "This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\n This will be reported to the administrators of %(homeserver)s.": "This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\n This will be reported to the administrators of %(homeserver)s.", + "Any other reason. Please describe the problem.\nThis will be reported to the room moderators.": "Any other reason. Please describe the problem.\nThis will be reported to the room moderators.", + "Please pick a nature and describe what makes this message abusive.": "Please pick a nature and describe what makes this message abusive.", + "Report Content": "Report Content", + "Disagree": "Disagree", + "Toxic Behaviour": "Toxic Behaviour", + "Illegal Content": "Illegal Content", + "Spam or propaganda": "Spam or propaganda", + "Report the entire room": "Report the entire room", + "Send report": "Send report", + "Report Content to Your Homeserver Administrator": "Report Content to Your Homeserver Administrator", + "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.": "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.", + "Room Settings - %(roomName)s": "Room Settings - %(roomName)s", + "Failed to upgrade room": "Failed to upgrade room", + "The room upgrade could not be completed": "The room upgrade could not be completed", + "Upgrade this room to version %(version)s": "Upgrade this room to version %(version)s", + "Upgrade Room Version": "Upgrade Room Version", + "Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:": "Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:", + "Create a new room with the same name, description and avatar": "Create a new room with the same name, description and avatar", + "Update any local room aliases to point to the new room": "Update any local room aliases to point to the new room", + "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room", + "Put a link back to the old room at the start of the new room so people can see old messages": "Put a link back to the old room at the start of the new room so people can see old messages", + "Automatically invite members from this room to the new one": "Automatically invite members from this room to the new one", + "Upgrade private room": "Upgrade private room", + "Upgrade public room": "Upgrade public room", + "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.", + "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.", + "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.", + "Please note upgrading will make a new version of the room. All current messages will stay in this archived room.": "Please note upgrading will make a new version of the room. All current messages will stay in this archived room.", + "You'll upgrade this room from to .": "You'll upgrade this room from to .", + "Resend": "Resend", + "You're all caught up.": "You're all caught up.", + "Server isn't responding": "Server isn't responding", + "Your server isn't responding to some of your requests. Below are some of the most likely reasons.": "Your server isn't responding to some of your requests. Below are some of the most likely reasons.", + "The server (%(serverName)s) took too long to respond.": "The server (%(serverName)s) took too long to respond.", + "Your firewall or anti-virus is blocking the request.": "Your firewall or anti-virus is blocking the request.", + "A browser extension is preventing the request.": "A browser extension is preventing the request.", + "The server is offline.": "The server is offline.", + "The server has denied your request.": "The server has denied your request.", + "Your area is experiencing difficulties connecting to the internet.": "Your area is experiencing difficulties connecting to the internet.", + "A connection error occurred while trying to contact the server.": "A connection error occurred while trying to contact the server.", + "The server is not configured to indicate what the problem is (CORS).": "The server is not configured to indicate what the problem is (CORS).", + "Recent changes that have not yet been received": "Recent changes that have not yet been received", + "Unable to validate homeserver": "Unable to validate homeserver", + "Invalid URL": "Invalid URL", + "Specify a homeserver": "Specify a homeserver", + "Matrix.org is the biggest public homeserver in the world, so it's a good place for many.": "Matrix.org is the biggest public homeserver in the world, so it's a good place for many.", + "Sign into your homeserver": "Sign into your homeserver", + "We call the places where you can host your account 'homeservers'.": "We call the places where you can host your account 'homeservers'.", + "Other homeserver": "Other homeserver", + "Use your preferred Matrix homeserver if you have one, or host your own.": "Use your preferred Matrix homeserver if you have one, or host your own.", + "About homeservers": "About homeservers", + "Reset event store?": "Reset event store?", + "You most likely do not want to reset your event index store": "You most likely do not want to reset your event index store", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated", + "Reset event store": "Reset event store", + "Sign out and remove encryption keys?": "Sign out and remove encryption keys?", + "Clear Storage and Sign Out": "Clear Storage and Sign Out", + "Send Logs": "Send Logs", + "Refresh": "Refresh", + "Unable to restore session": "Unable to restore session", + "We encountered an error trying to restore your previous session.": "We encountered an error trying to restore your previous session.", + "If you have previously used a more recent version of %(brand)s, your session may be incompatible with this version. Close this window and return to the more recent version.": "If you have previously used a more recent version of %(brand)s, your session may be incompatible with this version. Close this window and return to the more recent version.", + "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.", + "Verification Pending": "Verification Pending", + "Please check your email and click on the link it contains. Once this is done, click continue.": "Please check your email and click on the link it contains. Once this is done, click continue.", + "Email address": "Email address", + "This will allow you to reset your password and receive notifications.": "This will allow you to reset your password and receive notifications.", + "Skip": "Skip", + "Share Room": "Share Room", + "Link to most recent message": "Link to most recent message", + "Share User": "Share User", + "Share Room Message": "Share Room Message", + "Link to selected message": "Link to selected message", + "Link to room": "Link to room", + "Command Help": "Command Help", + "Sections to show": "Sections to show", + "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.": "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.", + "Space settings": "Space settings", + "Settings - %(spaceName)s": "Settings - %(spaceName)s", + "Spaces you're in": "Spaces you're in", + "Other rooms in %(spaceName)s": "Other rooms in %(spaceName)s", + "Join %(roomAddress)s": "Join %(roomAddress)s", + "Use \"%(query)s\" to search": "Use \"%(query)s\" to search", + "Public rooms": "Public rooms", + "Other searches": "Other searches", + "To search messages, look for this icon at the top of a room ": "To search messages, look for this icon at the top of a room ", + "Recent searches": "Recent searches", + "Clear": "Clear", + "Use to scroll": "Use to scroll", + "Search Dialog": "Search Dialog", + "Results not as expected? Please give feedback.": "Results not as expected? Please give feedback.", + "To help us prevent this in future, please send us logs.": "To help us prevent this in future, please send us logs.", + "Missing session data": "Missing session data", + "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.", + "Your browser likely removed this data when running low on disk space.": "Your browser likely removed this data when running low on disk space.", + "Find others by phone or email": "Find others by phone or email", + "Be found by phone or email": "Be found by phone or email", + "Use bots, bridges, widgets and sticker packs": "Use bots, bridges, widgets and sticker packs", + "To continue you need to accept the terms of this service.": "To continue you need to accept the terms of this service.", + "Service": "Service", + "Summary": "Summary", + "Document": "Document", + "Next": "Next", + "You signed in to a new session without verifying it:": "You signed in to a new session without verifying it:", + "Verify your other session using one of the options below.": "Verify your other session using one of the options below.", + "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) signed in to a new session without verifying it:", + "Ask this user to verify their session, or manually verify it below.": "Ask this user to verify their session, or manually verify it below.", + "Not Trusted": "Not Trusted", + "Manually Verify by Text": "Manually Verify by Text", + "Interactively verify by Emoji": "Interactively verify by Emoji", + "Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)", + "Upload files": "Upload files", + "Upload all": "Upload all", + "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.", + "These files are too large to upload. The file size limit is %(limit)s.": "These files are too large to upload. The file size limit is %(limit)s.", + "Some files are too large to be uploaded. The file size limit is %(limit)s.": "Some files are too large to be uploaded. The file size limit is %(limit)s.", + "Upload %(count)s other files|other": "Upload %(count)s other files", + "Upload %(count)s other files|one": "Upload %(count)s other file", + "Cancel All": "Cancel All", + "Upload Error": "Upload Error", + "Verify other device": "Verify other device", + "Verification Request": "Verification Request", + "Approve widget permissions": "Approve widget permissions", + "This widget would like to:": "This widget would like to:", + "Approve": "Approve", + "Decline All": "Decline All", + "Remember my selection for this widget": "Remember my selection for this widget", + "Allow this widget to verify your identity": "Allow this widget to verify your identity", + "The widget will verify your user ID, but won't be able to perform actions for you:": "The widget will verify your user ID, but won't be able to perform actions for you:", + "Remember this": "Remember this", + "Wrong file type": "Wrong file type", + "Looks good!": "Looks good!", + "Wrong Security Key": "Wrong Security Key", + "Invalid Security Key": "Invalid Security Key", + "Forgotten or lost all recovery methods? Reset all": "Forgotten or lost all recovery methods? Reset all", + "Reset everything": "Reset everything", + "Only do this if you have no other device to complete verification with.": "Only do this if you have no other device to complete verification with.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.", + "Security Phrase": "Security Phrase", + "Unable to access secret storage. Please verify that you entered the correct Security Phrase.": "Unable to access secret storage. Please verify that you entered the correct Security Phrase.", + "Enter your Security Phrase or to continue.": "Enter your Security Phrase or to continue.", + "Security Key": "Security Key", + "Use your Security Key to continue.": "Use your Security Key to continue.", + "Destroy cross-signing keys?": "Destroy cross-signing keys?", + "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.": "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.", + "Clear cross-signing keys": "Clear cross-signing keys", + "Confirm encryption setup": "Confirm encryption setup", + "Click the button below to confirm setting up encryption.": "Click the button below to confirm setting up encryption.", + "Unable to set up keys": "Unable to set up keys", + "Restoring keys from backup": "Restoring keys from backup", + "Fetching keys from server...": "Fetching keys from server...", + "%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored", + "Unable to load backup status": "Unable to load backup status", + "Security Key mismatch": "Security Key mismatch", + "Backup could not be decrypted with this Security Key: please verify that you entered the correct Security Key.": "Backup could not be decrypted with this Security Key: please verify that you entered the correct Security Key.", + "Incorrect Security Phrase": "Incorrect Security Phrase", + "Backup could not be decrypted with this Security Phrase: please verify that you entered the correct Security Phrase.": "Backup could not be decrypted with this Security Phrase: please verify that you entered the correct Security Phrase.", + "Unable to restore backup": "Unable to restore backup", + "No backup found!": "No backup found!", + "Keys restored": "Keys restored", + "Failed to decrypt %(failedCount)s sessions!": "Failed to decrypt %(failedCount)s sessions!", + "Successfully restored %(sessionCount)s keys": "Successfully restored %(sessionCount)s keys", + "Enter Security Phrase": "Enter Security Phrase", + "Warning: you should only set up key backup from a trusted computer.": "Warning: you should only set up key backup from a trusted computer.", + "Access your secure message history and set up secure messaging by entering your Security Phrase.": "Access your secure message history and set up secure messaging by entering your Security Phrase.", + "If you've forgotten your Security Phrase you can use your Security Key or set up new recovery options": "If you've forgotten your Security Phrase you can use your Security Key or set up new recovery options", + "Enter Security Key": "Enter Security Key", + "This looks like a valid Security Key!": "This looks like a valid Security Key!", + "Not a valid Security Key": "Not a valid Security Key", + "Warning: You should only set up key backup from a trusted computer.": "Warning: You should only set up key backup from a trusted computer.", + "Access your secure message history and set up secure messaging by entering your Security Key.": "Access your secure message history and set up secure messaging by entering your Security Key.", + "If you've forgotten your Security Key you can ": "If you've forgotten your Security Key you can ", + "Send custom account data event": "Send custom account data event", + "Send custom room account data event": "Send custom room account data event", + "Event Type": "Event Type", + "State Key": "State Key", + "Doesn't look like valid JSON.": "Doesn't look like valid JSON.", + "Failed to send event!": "Failed to send event!", + "Event sent!": "Event sent!", + "Event Content": "Event Content", + "Filter results": "Filter results", + "No results found": "No results found", + "<%(count)s spaces>|other": "<%(count)s spaces>", + "<%(count)s spaces>|one": "", + "<%(count)s spaces>|zero": "", + "Send custom state event": "Send custom state event", + "Capabilities": "Capabilities", + "Failed to load.": "Failed to load.", + "Client Versions": "Client Versions", + "Server Versions": "Server Versions", + "Server": "Server", + "Number of users": "Number of users", + "Failed to save settings.": "Failed to save settings.", + "Save setting values": "Save setting values", + "Setting:": "Setting:", + "Caution:": "Caution:", + "This UI does NOT check the types of the values. Use at your own risk.": "This UI does NOT check the types of the values. Use at your own risk.", + "Setting definition:": "Setting definition:", + "Level": "Level", + "Settable at global": "Settable at global", + "Settable at room": "Settable at room", + "Values at explicit levels": "Values at explicit levels", + "Values at explicit levels in this room": "Values at explicit levels in this room", + "Edit values": "Edit values", + "Value:": "Value:", + "Value in this room:": "Value in this room:", + "Values at explicit levels:": "Values at explicit levels:", + "Values at explicit levels in this room:": "Values at explicit levels in this room:", + "Setting ID": "Setting ID", + "Value": "Value", + "Value in this room": "Value in this room", + "Edit setting": "Edit setting", + "Unsent": "Unsent", + "Requested": "Requested", + "Ready": "Ready", + "Started": "Started", + "Cancelled": "Cancelled", + "Transaction": "Transaction", + "Phase": "Phase", + "Timeout": "Timeout", + "Methods": "Methods", + "Requester": "Requester", + "Observe only": "Observe only", + "No verification requests found": "No verification requests found", + "There was an error finding this widget.": "There was an error finding this widget.", + "Resume": "Resume", + "Hold": "Hold", + "Input devices": "Input devices", + "Output devices": "Output devices", + "Cameras": "Cameras", + "Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)", + "Open in OpenStreetMap": "Open in OpenStreetMap", + "Forward": "Forward", + "View source": "View source", + "Show preview": "Show preview", + "Source URL": "Source URL", + "Collapse reply thread": "Collapse reply thread", + "View related event": "View related event", + "Report": "Report", + "Copy link": "Copy link", + "Forget": "Forget", + "Mentions only": "Mentions only", + "See room timeline (devtools)": "See room timeline (devtools)", + "Space": "Space", + "Space home": "Space home", + "Manage & explore rooms": "Manage & explore rooms", + "Thread options": "Thread options", + "Unable to start audio streaming.": "Unable to start audio streaming.", + "Failed to start livestream": "Failed to start livestream", + "Start audio stream": "Start audio stream", + "Take a picture": "Take a picture", + "Delete Widget": "Delete Widget", + "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?", + "Delete widget": "Delete widget", + "Remove for everyone": "Remove for everyone", + "Revoke permissions": "Revoke permissions", + "Move left": "Move left", + "Move right": "Move right", + "This is a beta feature": "This is a beta feature", + "Click for more info": "Click for more info", + "Beta": "Beta", + "Join the beta": "Join the beta", + "Updated %(humanizedUpdateTime)s": "Updated %(humanizedUpdateTime)s", + "Live until %(expiryTime)s": "Live until %(expiryTime)s", + "Loading live location...": "Loading live location...", + "Live location ended": "Live location ended", + "Live location error": "Live location error", + "No live locations": "No live locations", + "View list": "View list", + "View List": "View List", + "Close sidebar": "Close sidebar", + "An error occurred while stopping your live location": "An error occurred while stopping your live location", + "An error occurred whilst sharing your live location": "An error occurred whilst sharing your live location", + "You are sharing your live location": "You are sharing your live location", + "%(timeRemaining)s left": "%(timeRemaining)s left", + "Live location enabled": "Live location enabled", + "An error occurred whilst sharing your live location, please try again": "An error occurred whilst sharing your live location, please try again", + "An error occurred while stopping your live location, please try again": "An error occurred while stopping your live location, please try again", + "Stop sharing": "Stop sharing", + "Stop sharing and close": "Stop sharing and close", + "Avatar": "Avatar", + "This room is public": "This room is public", + "Away": "Away", + "powered by Matrix": "powered by Matrix", + "This homeserver would like to make sure you are not a robot.": "This homeserver would like to make sure you are not a robot.", + "Country Dropdown": "Country Dropdown", + "Email": "Email", + "Enter email address": "Enter email address", + "Doesn't look like a valid email address": "Doesn't look like a valid email address", + "Confirm your identity by entering your account password below.": "Confirm your identity by entering your account password below.", + "Password": "Password", + "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.", + "Please review and accept all of the homeserver's policies": "Please review and accept all of the homeserver's policies", + "Please review and accept the policies of this homeserver:": "Please review and accept the policies of this homeserver:", + "Check your email to continue": "Check your email to continue", + "Unread email icon": "Unread email icon", + "To create your account, open the link in the email we just sent to %(emailAddress)s.": "To create your account, open the link in the email we just sent to %(emailAddress)s.", + "Did not receive it? Resend it": "Did not receive it? Resend it", + "Resent!": "Resent!", + "Token incorrect": "Token incorrect", + "A text message has been sent to %(msisdn)s": "A text message has been sent to %(msisdn)s", + "Please enter the code it contains:": "Please enter the code it contains:", + "Code": "Code", + "Submit": "Submit", + "Something went wrong in confirming your identity. Cancel and try again.": "Something went wrong in confirming your identity. Cancel and try again.", + "Start authentication": "Start authentication", + "Enter password": "Enter password", + "Nice, strong password!": "Nice, strong password!", + "Password is allowed, but unsafe": "Password is allowed, but unsafe", + "Keep going...": "Keep going...", + "Enter username": "Enter username", + "Enter phone number": "Enter phone number", + "That phone number doesn't look quite right, please check and try again": "That phone number doesn't look quite right, please check and try again", + "Username": "Username", + "Phone": "Phone", + "Forgot password?": "Forgot password?", + "Sign in with": "Sign in with", + "Sign in": "Sign in", + "Use an email address to recover your account": "Use an email address to recover your account", + "Enter email address (required on this homeserver)": "Enter email address (required on this homeserver)", + "Other users can invite you to rooms using your contact details": "Other users can invite you to rooms using your contact details", + "Enter phone number (required on this homeserver)": "Enter phone number (required on this homeserver)", + "Use lowercase letters, numbers, dashes and underscores only": "Use lowercase letters, numbers, dashes and underscores only", + "Unable to check if username has been taken. Try again later.": "Unable to check if username has been taken. Try again later.", + "Someone already has that username. Try another or if it is you, sign in below.": "Someone already has that username. Try another or if it is you, sign in below.", + "Phone (optional)": "Phone (optional)", + "Register": "Register", + "Add an email to be able to reset your password.": "Add an email to be able to reset your password.", + "Use email or phone to optionally be discoverable by existing contacts.": "Use email or phone to optionally be discoverable by existing contacts.", + "Use email to optionally be discoverable by existing contacts.": "Use email to optionally be discoverable by existing contacts.", + "Sign in with SSO": "Sign in with SSO", + "Unnamed audio": "Unnamed audio", + "Error downloading audio": "Error downloading audio", + "Pause": "Pause", + "Play": "Play", + "Couldn't load page": "Couldn't load page", + "Drop file here to upload": "Drop file here to upload", + "You must register to use this functionality": "You must register to use this functionality", + "You must join the room to see its files": "You must join the room to see its files", + "No files visible in this room": "No files visible in this room", + "Attach files from chat or just drag and drop them anywhere in a room.": "Attach files from chat or just drag and drop them anywhere in a room.", + "Great, that'll help people know it's you": "Great, that'll help people know it's you", + "Add a photo so people know it's you.": "Add a photo so people know it's you.", + "Welcome %(name)s": "Welcome %(name)s", + "Now, let's help you get started": "Now, let's help you get started", + "Welcome to %(appName)s": "Welcome to %(appName)s", + "Own your conversations.": "Own your conversations.", + "Send a Direct Message": "Send a Direct Message", + "Explore Public Rooms": "Explore Public Rooms", + "Create a Group Chat": "Create a Group Chat", + "Open dial pad": "Open dial pad", + "Wait!": "Wait!", + "If someone told you to copy/paste something here, there is a high likelihood you're being scammed!": "If someone told you to copy/paste something here, there is a high likelihood you're being scammed!", + "If you know what you're doing, Element is open-source, be sure to check out our GitHub (https://github.com/vector-im/element-web/) and contribute!": "If you know what you're doing, Element is open-source, be sure to check out our GitHub (https://github.com/vector-im/element-web/) and contribute!", + "Reject invitation": "Reject invitation", + "Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?", + "Failed to reject invitation": "Failed to reject invitation", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "You are the only person here. If you leave, no one will be able to join in the future, including you.", + "This space is not public. You will not be able to rejoin without an invite.": "This space is not public. You will not be able to rejoin without an invite.", + "This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.", + "Are you sure you want to leave the space '%(spaceName)s'?": "Are you sure you want to leave the space '%(spaceName)s'?", + "Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?", + "Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s", + "Unable to copy room link": "Unable to copy room link", + "Unable to copy a link to the room to the clipboard.": "Unable to copy a link to the room to the clipboard.", + "New search beta available": "New search beta available", + "We're testing a new search to make finding what you want quicker.\n": "We're testing a new search to make finding what you want quicker.\n", + "Signed Out": "Signed Out", + "For security, this session has been signed out. Please sign in again.": "For security, this session has been signed out. Please sign in again.", + "Terms and Conditions": "Terms and Conditions", + "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.", + "Review terms and conditions": "Review terms and conditions", + "Old cryptography data detected": "Old cryptography data detected", + "Data from an older version of %(brand)s has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "Data from an older version of %(brand)s has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.", + "Verification requested": "Verification requested", + "Logout": "Logout", + "%(creator)s created this DM.": "%(creator)s created this DM.", + "%(creator)s created and configured the room.": "%(creator)s created and configured the room.", + "You're all caught up": "You're all caught up", + "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.", + "Delete the room address %(alias)s and remove %(name)s from the directory?": "Delete the room address %(alias)s and remove %(name)s from the directory?", + "Remove %(name)s from the directory?": "Remove %(name)s from the directory?", + "Remove from Directory": "Remove from Directory", + "remove %(name)s from the directory.": "remove %(name)s from the directory.", + "delete the address.": "delete the address.", + "The server may be unavailable or overloaded": "The server may be unavailable or overloaded", + "Create new room": "Create new room", + "No results for \"%(query)s\"": "No results for \"%(query)s\"", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.", + "Find a room…": "Find a room…", + "Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "If you can't find the room you're looking for, ask for an invite or create a new room.", + "Filter": "Filter", + "Filter rooms and people": "Filter rooms and people", + "Clear filter": "Clear filter", + "You can't send any messages until you review and agree to our terms and conditions.": "You can't send any messages until you review and agree to our terms and conditions.", + "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.", + "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.", + "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.", + "Some of your messages have not been sent": "Some of your messages have not been sent", + "Delete all": "Delete all", + "Retry all": "Retry all", + "You can select all or individual messages to retry or delete": "You can select all or individual messages to retry or delete", + "Connectivity to the server has been lost.": "Connectivity to the server has been lost.", + "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", + "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", + "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", + "Search failed": "Search failed", + "Server may be unavailable, overloaded, or search timed out :(": "Server may be unavailable, overloaded, or search timed out :(", + "No more results": "No more results", + "Failed to reject invite": "Failed to reject invite", + "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.", + "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.", + "Joining": "Joining", + "You don't have permission": "You don't have permission", + "This room is suggested as a good one to join": "This room is suggested as a good one to join", + "Suggested": "Suggested", + "Select a room below first": "Select a room below first", + "Failed to remove some rooms. Try again later": "Failed to remove some rooms. Try again later", + "Removing...": "Removing...", + "Mark as not suggested": "Mark as not suggested", + "Mark as suggested": "Mark as suggested", + "Failed to load list of rooms.": "Failed to load list of rooms.", + "Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.", + "You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.", + "Results": "Results", + "Rooms and spaces": "Rooms and spaces", + "Search names and descriptions": "Search names and descriptions", + "Welcome to ": "Welcome to ", + "Random": "Random", + "Support": "Support", + "Room name": "Room name", + "Failed to create initial space rooms": "Failed to create initial space rooms", + "Skip for now": "Skip for now", + "Creating rooms...": "Creating rooms...", + "What do you want to organise?": "What do you want to organise?", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.", + "Search for rooms or spaces": "Search for rooms or spaces", + "Share %(name)s": "Share %(name)s", + "It's just you at the moment, it will be even better with others.": "It's just you at the moment, it will be even better with others.", + "Go to my first room": "Go to my first room", + "Go to my space": "Go to my space", + "Who are you working with?": "Who are you working with?", + "Make sure the right people have access to %(name)s": "Make sure the right people have access to %(name)s", + "Just me": "Just me", + "A private space to organise your rooms": "A private space to organise your rooms", + "Me and my teammates": "Me and my teammates", + "A private space for you and your teammates": "A private space for you and your teammates", + "Failed to invite the following users to your space: %(csvUsers)s": "Failed to invite the following users to your space: %(csvUsers)s", + "Inviting...": "Inviting...", + "Invite your teammates": "Invite your teammates", + "Make sure the right people have access. You can invite more later.": "Make sure the right people have access. You can invite more later.", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.", + "Invite by username": "Invite by username", + "What are some things you want to discuss in %(spaceName)s?": "What are some things you want to discuss in %(spaceName)s?", + "Let's create a room for each of them.": "Let's create a room for each of them.", + "You can add more later too, including already existing ones.": "You can add more later too, including already existing ones.", + "What projects are your team working on?": "What projects are your team working on?", + "We'll create rooms for each of them.": "We'll create rooms for each of them.", + "All threads": "All threads", + "Shows all threads from current room": "Shows all threads from current room", + "My threads": "My threads", + "Shows all threads you've participated in": "Shows all threads you've participated in", + "Show:": "Show:", + "Reply to an ongoing thread or use “%(replyInThread)s” when hovering over a message to start a new one.": "Reply to an ongoing thread or use “%(replyInThread)s” when hovering over a message to start a new one.", + "Show all threads": "Show all threads", + "Threads help keep your conversations on-topic and easy to track.": "Threads help keep your conversations on-topic and easy to track.", + "Tip: Use “%(replyInThread)s” when hovering over a message.": "Tip: Use “%(replyInThread)s” when hovering over a message.", + "Keep discussions organised with threads": "Keep discussions organised with threads", + "Threads are a beta feature": "Threads are a beta feature", + "Give feedback": "Give feedback", + "Thread": "Thread", + "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", + "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", + "Failed to load timeline position": "Failed to load timeline position", + "Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others", + "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", + "Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other", + "Got an account? Sign in": "Got an account? Sign in", + "New here? Create an account": "New here? Create an account", + "Switch to light mode": "Switch to light mode", + "Switch to dark mode": "Switch to dark mode", + "Switch theme": "Switch theme", + "User menu": "User menu", + "Could not load user profile": "Could not load user profile", + "Decrypted event source": "Decrypted event source", + "Original event source": "Original event source", + "Event ID: %(eventId)s": "Event ID: %(eventId)s", + "Unable to verify this device": "Unable to verify this device", + "Verify this device": "Verify this device", + "Device verified": "Device verified", + "Really reset verification keys?": "Really reset verification keys?", + "Skip verification for now": "Skip verification for now", + "Failed to send email": "Failed to send email", + "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.", + "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.", + "If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.": "If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.", + "The email address linked to your account must be entered.": "The email address linked to your account must be entered.", + "The email address doesn't appear to be valid.": "The email address doesn't appear to be valid.", + "A new password must be entered.": "A new password must be entered.", + "New passwords must match each other.": "New passwords must match each other.", + "Sign out all devices": "Sign out all devices", + "A verification email will be sent to your inbox to confirm setting your new password.": "A verification email will be sent to your inbox to confirm setting your new password.", + "Send Reset Email": "Send Reset Email", + "Sign in instead": "Sign in instead", + "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.", + "I have verified my email address": "I have verified my email address", + "Your password has been reset.": "Your password has been reset.", + "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.", + "Return to login screen": "Return to login screen", + "Set a new password": "Set a new password", + "Invalid homeserver discovery response": "Invalid homeserver discovery response", + "Failed to get autodiscovery configuration from server": "Failed to get autodiscovery configuration from server", + "Invalid base_url for m.homeserver": "Invalid base_url for m.homeserver", + "Homeserver URL does not appear to be a valid Matrix homeserver": "Homeserver URL does not appear to be a valid Matrix homeserver", + "Invalid identity server discovery response": "Invalid identity server discovery response", + "Invalid base_url for m.identity_server": "Invalid base_url for m.identity_server", + "Identity server URL does not appear to be a valid identity server": "Identity server URL does not appear to be a valid identity server", + "General failure": "General failure", + "This homeserver does not support login using email address.": "This homeserver does not support login using email address.", + "Please contact your service administrator to continue using this service.": "Please contact your service administrator to continue using this service.", + "This account has been deactivated.": "This account has been deactivated.", + "Incorrect username and/or password.": "Incorrect username and/or password.", + "Please note you are logging into the %(hs)s server, not matrix.org.": "Please note you are logging into the %(hs)s server, not matrix.org.", + "Failed to perform homeserver discovery": "Failed to perform homeserver discovery", + "This homeserver doesn't offer any login flows which are supported by this client.": "This homeserver doesn't offer any login flows which are supported by this client.", + "There was a problem communicating with the homeserver, please try again later.": "There was a problem communicating with the homeserver, please try again later.", + "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.", + "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.": "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.", + "Syncing...": "Syncing...", + "Signing In...": "Signing In...", + "If you've joined lots of rooms, this might take a while": "If you've joined lots of rooms, this might take a while", + "New? Create account": "New? Create account", + "Registration has been disabled on this homeserver.": "Registration has been disabled on this homeserver.", + "Unable to query for supported registration methods.": "Unable to query for supported registration methods.", + "This server does not support authentication with a phone number.": "This server does not support authentication with a phone number.", + "Someone already has that username, please try another.": "Someone already has that username, please try another.", + "That e-mail address is already in use.": "That e-mail address is already in use.", + "Continue with %(ssoButtons)s": "Continue with %(ssoButtons)s", + "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s Or %(usernamePassword)s", + "Already have an account? Sign in here": "Already have an account? Sign in here", + "Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).": "Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).", + "Continue with previous account": "Continue with previous account", + "Log in to your new account.": "Log in to your new account.", + "Registration Successful": "Registration Successful", + "Create account": "Create account", + "Host account on": "Host account on", + "Decide where your account is hosted": "Decide where your account is hosted", + "It looks like you don't have a Security Key or any other devices you can verify against. This device will not be able to access old encrypted messages. In order to verify your identity on this device, you'll need to reset your verification keys.": "It looks like you don't have a Security Key or any other devices you can verify against. This device will not be able to access old encrypted messages. In order to verify your identity on this device, you'll need to reset your verification keys.", + "Proceed with reset": "Proceed with reset", + "Verify with Security Key or Phrase": "Verify with Security Key or Phrase", + "Verify with Security Key": "Verify with Security Key", + "Verify with another device": "Verify with another device", + "Verify your identity to access encrypted messages and prove your identity to others.": "Verify your identity to access encrypted messages and prove your identity to others.", + "Your new device is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Your new device is now verified. It has access to your encrypted messages, and other users will see it as trusted.", + "Your new device is now verified. Other users will see it as trusted.": "Your new device is now verified. Other users will see it as trusted.", + "Without verifying, you won't have access to all your messages and may appear as untrusted to others.": "Without verifying, you won't have access to all your messages and may appear as untrusted to others.", + "I'll verify later": "I'll verify later", + "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.": "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.", + "Please only proceed if you're sure you've lost all of your other devices and your security key.": "Please only proceed if you're sure you've lost all of your other devices and your security key.", + "Failed to re-authenticate due to a homeserver problem": "Failed to re-authenticate due to a homeserver problem", + "Incorrect password": "Incorrect password", + "Failed to re-authenticate": "Failed to re-authenticate", + "Forgotten your password?": "Forgotten your password?", + "Regain access to your account and recover encryption keys stored in this session. Without them, you won't be able to read all of your secure messages in any session.": "Regain access to your account and recover encryption keys stored in this session. Without them, you won't be able to read all of your secure messages in any session.", + "Enter your password to sign in and regain access to your account.": "Enter your password to sign in and regain access to your account.", + "Sign in and regain access to your account.": "Sign in and regain access to your account.", + "You cannot sign in to your account. Please contact your homeserver admin for more information.": "You cannot sign in to your account. Please contact your homeserver admin for more information.", + "You're signed out": "You're signed out", + "Clear personal data": "Clear personal data", + "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.", + "Commands": "Commands", + "Command Autocomplete": "Command Autocomplete", + "Emoji Autocomplete": "Emoji Autocomplete", + "Notify the whole room": "Notify the whole room", + "Room Notification": "Room Notification", + "Notification Autocomplete": "Notification Autocomplete", + "Room Autocomplete": "Room Autocomplete", + "Space Autocomplete": "Space Autocomplete", + "Users": "Users", + "User Autocomplete": "User Autocomplete", + "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.", + "For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.", + "Enter a Security Phrase": "Enter a Security Phrase", + "Great! This Security Phrase looks strong enough.": "Great! This Security Phrase looks strong enough.", + "Set up with a Security Key": "Set up with a Security Key", + "That matches!": "That matches!", + "Use a different passphrase?": "Use a different passphrase?", + "That doesn't match.": "That doesn't match.", + "Go back to set it again.": "Go back to set it again.", + "Enter your Security Phrase a second time to confirm it.": "Enter your Security Phrase a second time to confirm it.", + "Repeat your Security Phrase...": "Repeat your Security Phrase...", + "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.": "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.", + "Keep a copy of it somewhere secure, like a password manager or even a safe.": "Keep a copy of it somewhere secure, like a password manager or even a safe.", + "Your Security Key": "Your Security Key", + "Your Security Key has been copied to your clipboard, paste it to:": "Your Security Key has been copied to your clipboard, paste it to:", + "Your Security Key is in your Downloads folder.": "Your Security Key is in your Downloads folder.", + "Print it and store it somewhere safe": "Print it and store it somewhere safe", + "Save it on a USB key or backup drive": "Save it on a USB key or backup drive", + "Copy it to your personal cloud storage": "Copy it to your personal cloud storage", + "Your keys are being backed up (the first backup could take a few minutes).": "Your keys are being backed up (the first backup could take a few minutes).", + "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.": "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.", + "Set up Secure Message Recovery": "Set up Secure Message Recovery", + "Secure your backup with a Security Phrase": "Secure your backup with a Security Phrase", + "Confirm your Security Phrase": "Confirm your Security Phrase", + "Make a copy of your Security Key": "Make a copy of your Security Key", + "Starting backup...": "Starting backup...", + "Success!": "Success!", + "Create key backup": "Create key backup", + "Unable to create key backup": "Unable to create key backup", + "Generate a Security Key": "Generate a Security Key", + "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.", + "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Use a secret phrase only you know, and optionally save a Security Key to use for backup.", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.", + "Enter your account password to confirm the upgrade:": "Enter your account password to confirm the upgrade:", + "Restore your key backup to upgrade your encryption": "Restore your key backup to upgrade your encryption", + "Restore": "Restore", + "You'll need to authenticate with the server to confirm the upgrade.": "You'll need to authenticate with the server to confirm the upgrade.", + "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.", + "Enter a security phrase only you know, as it's used to safeguard your data. To be secure, you shouldn't re-use your account password.": "Enter a security phrase only you know, as it's used to safeguard your data. To be secure, you shouldn't re-use your account password.", + "Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.": "Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.", + "Unable to query secret storage status": "Unable to query secret storage status", + "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.", + "You can also set up Secure Backup & manage your keys in Settings.": "You can also set up Secure Backup & manage your keys in Settings.", + "Upgrade your encryption": "Upgrade your encryption", + "Set a Security Phrase": "Set a Security Phrase", + "Confirm Security Phrase": "Confirm Security Phrase", + "Save your Security Key": "Save your Security Key", + "Unable to set up secret storage": "Unable to set up secret storage", + "Passphrases must match": "Passphrases must match", + "Passphrase must not be empty": "Passphrase must not be empty", + "Unknown error": "Unknown error", + "Export room keys": "Export room keys", + "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.": "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.", + "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.", + "Enter passphrase": "Enter passphrase", + "Confirm passphrase": "Confirm passphrase", + "Import room keys": "Import room keys", + "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.", + "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.": "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.", + "File to import": "File to import", + "Import": "Import", + "New Recovery Method": "New Recovery Method", + "A new Security Phrase and key for Secure Messages have been detected.": "A new Security Phrase and key for Secure Messages have been detected.", + "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.", + "This session is encrypting history using the new recovery method.": "This session is encrypting history using the new recovery method.", + "Go to Settings": "Go to Settings", + "Set up Secure Messages": "Set up Secure Messages", + "Recovery Method Removed": "Recovery Method Removed", + "This session has detected that your Security Phrase and key for Secure Messages have been removed.": "This session has detected that your Security Phrase and key for Secure Messages have been removed.", + "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.": "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.", + "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.", + "If disabled, messages from encrypted rooms won't appear in search results.": "If disabled, messages from encrypted rooms won't appear in search results.", + "Disable": "Disable", + "Not currently indexing messages for any room.": "Not currently indexing messages for any room.", + "Currently indexing: %(currentRoom)s": "Currently indexing: %(currentRoom)s", + "%(brand)s is securely caching encrypted messages locally for them to appear in search results:": "%(brand)s is securely caching encrypted messages locally for them to appear in search results:", + "Space used:": "Space used:", + "Indexed messages:": "Indexed messages:", + "Indexed rooms:": "Indexed rooms:", + "%(doneRooms)s out of %(totalRooms)s": "%(doneRooms)s out of %(totalRooms)s", + "Message downloading sleep time(ms)": "Message downloading sleep time(ms)", + "Failed to set direct chat tag": "Failed to set direct chat tag", + "Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room", + "Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room", + "Page Up": "Page Up", + "Page Down": "Page Down", + "Esc": "Esc", + "Enter": "Enter", + "End": "End", + "Alt": "Alt", + "Ctrl": "Ctrl", + "Shift": "Shift", + "[number]": "[number]", + "Calls": "Calls", + "Room List": "Room List", + "Accessibility": "Accessibility", + "Navigation": "Navigation", + "Autocomplete": "Autocomplete", + "Toggle Bold": "Toggle Bold", + "Toggle Italics": "Toggle Italics", + "Toggle Quote": "Toggle Quote", + "Toggle Code Block": "Toggle Code Block", + "Toggle Link": "Toggle Link", + "Cancel replying to a message": "Cancel replying to a message", + "Navigate to next message to edit": "Navigate to next message to edit", + "Navigate to previous message to edit": "Navigate to previous message to edit", + "Jump to start of the composer": "Jump to start of the composer", + "Jump to end of the composer": "Jump to end of the composer", + "Navigate to next message in composer history": "Navigate to next message in composer history", + "Navigate to previous message in composer history": "Navigate to previous message in composer history", + "Send a sticker": "Send a sticker", + "Toggle microphone mute": "Toggle microphone mute", + "Toggle webcam on/off": "Toggle webcam on/off", + "Dismiss read marker and jump to bottom": "Dismiss read marker and jump to bottom", + "Jump to oldest unread message": "Jump to oldest unread message", + "Upload a file": "Upload a file", + "Scroll up in the timeline": "Scroll up in the timeline", + "Scroll down in the timeline": "Scroll down in the timeline", + "Jump to room search": "Jump to room search", + "Select room from the room list": "Select room from the room list", + "Collapse room list section": "Collapse room list section", + "Expand room list section": "Expand room list section", + "Clear room list filter field": "Clear room list filter field", + "Navigate down in the room list": "Navigate down in the room list", + "Navigate up in the room list": "Navigate up in the room list", + "Toggle the top left menu": "Toggle the top left menu", + "Toggle right panel": "Toggle right panel", + "Open this settings tab": "Open this settings tab", + "Go to Home View": "Go to Home View", + "Next unread room or DM": "Next unread room or DM", + "Previous unread room or DM": "Previous unread room or DM", + "Next room or DM": "Next room or DM", + "Previous room or DM": "Previous room or DM", + "Cancel autocomplete": "Cancel autocomplete", + "Next autocomplete suggestion": "Next autocomplete suggestion", + "Previous autocomplete suggestion": "Previous autocomplete suggestion", + "Toggle space panel": "Toggle space panel", + "Toggle hidden event visibility": "Toggle hidden event visibility", + "Jump to first message": "Jump to first message", + "Jump to last message": "Jump to last message", + "Undo edit": "Undo edit", + "Redo edit": "Redo edit", + "Previous recently visited room or space": "Previous recently visited room or space", + "Next recently visited room or space": "Next recently visited room or space", + "Switch to space by number": "Switch to space by number", + "Open user settings": "Open user settings", + "Close dialog or context menu": "Close dialog or context menu", + "Activate selected button": "Activate selected button", + "New line": "New line", + "Force complete": "Force complete", + "Search (must be enabled)": "Search (must be enabled)" +} From f1b5ea46a5d5b88e59314839e5afd3afc5015c19 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 25 May 2022 16:21:56 +0200 Subject: [PATCH 23/73] Remove en_EN_orig.json --- src/i18n/strings/en_EN_orig.json | 3425 ------------------------------ 1 file changed, 3425 deletions(-) delete mode 100644 src/i18n/strings/en_EN_orig.json diff --git a/src/i18n/strings/en_EN_orig.json b/src/i18n/strings/en_EN_orig.json deleted file mode 100644 index 86917dbb866..00000000000 --- a/src/i18n/strings/en_EN_orig.json +++ /dev/null @@ -1,3425 +0,0 @@ -{ - "This email address is already in use": "This email address is already in use", - "This phone number is already in use": "This phone number is already in use", - "Use Single Sign On to continue": "Use Single Sign On to continue", - "Confirm adding this email address by using Single Sign On to prove your identity.": "Confirm adding this email address by using Single Sign On to prove your identity.", - "Single Sign On": "Single Sign On", - "Confirm adding email": "Confirm adding email", - "Click the button below to confirm adding this email address.": "Click the button below to confirm adding this email address.", - "Confirm": "Confirm", - "Add Email Address": "Add Email Address", - "Failed to verify email address: make sure you clicked the link in the email": "Failed to verify email address: make sure you clicked the link in the email", - "Confirm adding this phone number by using Single Sign On to prove your identity.": "Confirm adding this phone number by using Single Sign On to prove your identity.", - "Confirm adding phone number": "Confirm adding phone number", - "Click the button below to confirm adding this phone number.": "Click the button below to confirm adding this phone number.", - "Add Phone Number": "Add Phone Number", - "The platform you're on": "The platform you're on", - "The version of %(brand)s": "The version of %(brand)s", - "Whether or not you're logged in (we don't record your username)": "Whether or not you're logged in (we don't record your username)", - "Your language of choice": "Your language of choice", - "Which officially provided instance you are using, if any": "Which officially provided instance you are using, if any", - "Whether or not you're using the Richtext mode of the Rich Text Editor": "Whether or not you're using the Richtext mode of the Rich Text Editor", - "Your homeserver's URL": "Your homeserver's URL", - "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Whether you're using %(brand)s on a device where touch is the primary input mechanism", - "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)", - "Whether you're using %(brand)s as an installed Progressive Web App": "Whether you're using %(brand)s as an installed Progressive Web App", - "e.g. %(exampleValue)s": "e.g. %(exampleValue)s", - "Every page you use in the app": "Every page you use in the app", - "e.g. ": "e.g. ", - "Your user agent": "Your user agent", - "Your device resolution": "Your device resolution", - "Our complete cookie policy can be found here.": "Our complete cookie policy can be found here.", - "Analytics": "Analytics", - "Some examples of the information being sent to us to help make %(brand)s better includes:": "Some examples of the information being sent to us to help make %(brand)s better includes:", - "Where this page includes identifiable information, such as a room, user ID, that data is removed before being sent to the server.": "Where this page includes identifiable information, such as a room, user ID, that data is removed before being sent to the server.", - "Error": "Error", - "Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", - "Dismiss": "Dismiss", - "Call Failed": "Call Failed", - "User Busy": "User Busy", - "The user you called is busy.": "The user you called is busy.", - "The call could not be established": "The call could not be established", - "Answered Elsewhere": "Answered Elsewhere", - "The call was answered on another device.": "The call was answered on another device.", - "Call failed due to misconfigured server": "Call failed due to misconfigured server", - "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.", - "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.", - "Try using turn.matrix.org": "Try using turn.matrix.org", - "OK": "OK", - "Unable to access microphone": "Unable to access microphone", - "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.", - "Unable to access webcam / microphone": "Unable to access webcam / microphone", - "Call failed because webcam or microphone could not be accessed. Check that:": "Call failed because webcam or microphone could not be accessed. Check that:", - "A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly", - "Permission is granted to use the webcam": "Permission is granted to use the webcam", - "No other application is using the webcam": "No other application is using the webcam", - "Already in call": "Already in call", - "You're already in a call with this person.": "You're already in a call with this person.", - "Calls are unsupported": "Calls are unsupported", - "You cannot place calls in this browser.": "You cannot place calls in this browser.", - "Connectivity to the server has been lost": "Connectivity to the server has been lost", - "You cannot place calls without a connection to the server.": "You cannot place calls without a connection to the server.", - "Too Many Calls": "Too Many Calls", - "You've reached the maximum number of simultaneous calls.": "You've reached the maximum number of simultaneous calls.", - "You cannot place a call with yourself.": "You cannot place a call with yourself.", - "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", - "Unable to transfer call": "Unable to transfer call", - "Transfer Failed": "Transfer Failed", - "Failed to transfer call": "Failed to transfer call", - "Permission Required": "Permission Required", - "You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room", - "End conference": "End conference", - "This will end the conference for everyone. Continue?": "This will end the conference for everyone. Continue?", - "The file '%(fileName)s' failed to upload.": "The file '%(fileName)s' failed to upload.", - "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", - "Upload Failed": "Upload Failed", - "Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.", - "The server does not support the room version specified.": "The server does not support the room version specified.", - "Failure to create room": "Failure to create room", - "Sun": "Sun", - "Mon": "Mon", - "Tue": "Tue", - "Wed": "Wed", - "Thu": "Thu", - "Fri": "Fri", - "Sat": "Sat", - "Jan": "Jan", - "Feb": "Feb", - "Mar": "Mar", - "Apr": "Apr", - "May": "May", - "Jun": "Jun", - "Jul": "Jul", - "Aug": "Aug", - "Sep": "Sep", - "Oct": "Oct", - "Nov": "Nov", - "Dec": "Dec", - "PM": "PM", - "AM": "AM", - "%(weekDayName)s %(time)s": "%(weekDayName)s %(time)s", - "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(time)s", - "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s", - "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s", - "%(date)s at %(time)s": "%(date)s at %(time)s", - "%(value)sd": "%(value)sd", - "%(value)sh": "%(value)sh", - "%(value)sm": "%(value)sm", - "%(value)ss": "%(value)ss", - "That link is no longer supported": "That link is no longer supported", - "You're trying to access a community link (%(groupId)s).
Communities are no longer supported and have been replaced by spaces.Learn more about spaces here.": "You're trying to access a community link (%(groupId)s).
Communities are no longer supported and have been replaced by spaces.Learn more about spaces here.", - "Identity server has no terms of service": "Identity server has no terms of service", - "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.", - "Only continue if you trust the owner of the server.": "Only continue if you trust the owner of the server.", - "Trust": "Trust", - "We couldn't log you in": "We couldn't log you in", - "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.", - "Try again": "Try again", - "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.", - "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.", - "%(name)s is requesting verification": "%(name)s is requesting verification", - "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s does not have permission to send you notifications - please check your browser settings", - "%(brand)s was not given permission to send notifications - please try again": "%(brand)s was not given permission to send notifications - please try again", - "Unable to enable Notifications": "Unable to enable Notifications", - "This email address was not found": "This email address was not found", - "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "Your email address does not appear to be associated with a Matrix ID on this Homeserver.", - "United Kingdom": "United Kingdom", - "United States": "United States", - "Afghanistan": "Afghanistan", - "Åland Islands": "Åland Islands", - "Albania": "Albania", - "Algeria": "Algeria", - "American Samoa": "American Samoa", - "Andorra": "Andorra", - "Angola": "Angola", - "Anguilla": "Anguilla", - "Antarctica": "Antarctica", - "Antigua & Barbuda": "Antigua & Barbuda", - "Argentina": "Argentina", - "Armenia": "Armenia", - "Aruba": "Aruba", - "Australia": "Australia", - "Austria": "Austria", - "Azerbaijan": "Azerbaijan", - "Bahamas": "Bahamas", - "Bahrain": "Bahrain", - "Bangladesh": "Bangladesh", - "Barbados": "Barbados", - "Belarus": "Belarus", - "Belgium": "Belgium", - "Belize": "Belize", - "Benin": "Benin", - "Bermuda": "Bermuda", - "Bhutan": "Bhutan", - "Bolivia": "Bolivia", - "Bosnia": "Bosnia", - "Botswana": "Botswana", - "Bouvet Island": "Bouvet Island", - "Brazil": "Brazil", - "British Indian Ocean Territory": "British Indian Ocean Territory", - "British Virgin Islands": "British Virgin Islands", - "Brunei": "Brunei", - "Bulgaria": "Bulgaria", - "Burkina Faso": "Burkina Faso", - "Burundi": "Burundi", - "Cambodia": "Cambodia", - "Cameroon": "Cameroon", - "Canada": "Canada", - "Cape Verde": "Cape Verde", - "Caribbean Netherlands": "Caribbean Netherlands", - "Cayman Islands": "Cayman Islands", - "Central African Republic": "Central African Republic", - "Chad": "Chad", - "Chile": "Chile", - "China": "China", - "Christmas Island": "Christmas Island", - "Cocos (Keeling) Islands": "Cocos (Keeling) Islands", - "Colombia": "Colombia", - "Comoros": "Comoros", - "Congo - Brazzaville": "Congo - Brazzaville", - "Congo - Kinshasa": "Congo - Kinshasa", - "Cook Islands": "Cook Islands", - "Costa Rica": "Costa Rica", - "Croatia": "Croatia", - "Cuba": "Cuba", - "Curaçao": "Curaçao", - "Cyprus": "Cyprus", - "Czech Republic": "Czech Republic", - "Côte d’Ivoire": "Côte d’Ivoire", - "Denmark": "Denmark", - "Djibouti": "Djibouti", - "Dominica": "Dominica", - "Dominican Republic": "Dominican Republic", - "Ecuador": "Ecuador", - "Egypt": "Egypt", - "El Salvador": "El Salvador", - "Equatorial Guinea": "Equatorial Guinea", - "Eritrea": "Eritrea", - "Estonia": "Estonia", - "Ethiopia": "Ethiopia", - "Falkland Islands": "Falkland Islands", - "Faroe Islands": "Faroe Islands", - "Fiji": "Fiji", - "Finland": "Finland", - "France": "France", - "French Guiana": "French Guiana", - "French Polynesia": "French Polynesia", - "French Southern Territories": "French Southern Territories", - "Gabon": "Gabon", - "Gambia": "Gambia", - "Georgia": "Georgia", - "Germany": "Germany", - "Ghana": "Ghana", - "Gibraltar": "Gibraltar", - "Greece": "Greece", - "Greenland": "Greenland", - "Grenada": "Grenada", - "Guadeloupe": "Guadeloupe", - "Guam": "Guam", - "Guatemala": "Guatemala", - "Guernsey": "Guernsey", - "Guinea": "Guinea", - "Guinea-Bissau": "Guinea-Bissau", - "Guyana": "Guyana", - "Haiti": "Haiti", - "Heard & McDonald Islands": "Heard & McDonald Islands", - "Honduras": "Honduras", - "Hong Kong": "Hong Kong", - "Hungary": "Hungary", - "Iceland": "Iceland", - "India": "India", - "Indonesia": "Indonesia", - "Iran": "Iran", - "Iraq": "Iraq", - "Ireland": "Ireland", - "Isle of Man": "Isle of Man", - "Israel": "Israel", - "Italy": "Italy", - "Jamaica": "Jamaica", - "Japan": "Japan", - "Jersey": "Jersey", - "Jordan": "Jordan", - "Kazakhstan": "Kazakhstan", - "Kenya": "Kenya", - "Kiribati": "Kiribati", - "Kosovo": "Kosovo", - "Kuwait": "Kuwait", - "Kyrgyzstan": "Kyrgyzstan", - "Laos": "Laos", - "Latvia": "Latvia", - "Lebanon": "Lebanon", - "Lesotho": "Lesotho", - "Liberia": "Liberia", - "Libya": "Libya", - "Liechtenstein": "Liechtenstein", - "Lithuania": "Lithuania", - "Luxembourg": "Luxembourg", - "Macau": "Macau", - "Macedonia": "Macedonia", - "Madagascar": "Madagascar", - "Malawi": "Malawi", - "Malaysia": "Malaysia", - "Maldives": "Maldives", - "Mali": "Mali", - "Malta": "Malta", - "Marshall Islands": "Marshall Islands", - "Martinique": "Martinique", - "Mauritania": "Mauritania", - "Mauritius": "Mauritius", - "Mayotte": "Mayotte", - "Mexico": "Mexico", - "Micronesia": "Micronesia", - "Moldova": "Moldova", - "Monaco": "Monaco", - "Mongolia": "Mongolia", - "Montenegro": "Montenegro", - "Montserrat": "Montserrat", - "Morocco": "Morocco", - "Mozambique": "Mozambique", - "Myanmar": "Myanmar", - "Namibia": "Namibia", - "Nauru": "Nauru", - "Nepal": "Nepal", - "Netherlands": "Netherlands", - "New Caledonia": "New Caledonia", - "New Zealand": "New Zealand", - "Nicaragua": "Nicaragua", - "Niger": "Niger", - "Nigeria": "Nigeria", - "Niue": "Niue", - "Norfolk Island": "Norfolk Island", - "North Korea": "North Korea", - "Northern Mariana Islands": "Northern Mariana Islands", - "Norway": "Norway", - "Oman": "Oman", - "Pakistan": "Pakistan", - "Palau": "Palau", - "Palestine": "Palestine", - "Panama": "Panama", - "Papua New Guinea": "Papua New Guinea", - "Paraguay": "Paraguay", - "Peru": "Peru", - "Philippines": "Philippines", - "Pitcairn Islands": "Pitcairn Islands", - "Poland": "Poland", - "Portugal": "Portugal", - "Puerto Rico": "Puerto Rico", - "Qatar": "Qatar", - "Romania": "Romania", - "Russia": "Russia", - "Rwanda": "Rwanda", - "Réunion": "Réunion", - "Samoa": "Samoa", - "San Marino": "San Marino", - "Saudi Arabia": "Saudi Arabia", - "Senegal": "Senegal", - "Serbia": "Serbia", - "Seychelles": "Seychelles", - "Sierra Leone": "Sierra Leone", - "Singapore": "Singapore", - "Sint Maarten": "Sint Maarten", - "Slovakia": "Slovakia", - "Slovenia": "Slovenia", - "Solomon Islands": "Solomon Islands", - "Somalia": "Somalia", - "South Africa": "South Africa", - "South Georgia & South Sandwich Islands": "South Georgia & South Sandwich Islands", - "South Korea": "South Korea", - "South Sudan": "South Sudan", - "Spain": "Spain", - "Sri Lanka": "Sri Lanka", - "St. Barthélemy": "St. Barthélemy", - "St. Helena": "St. Helena", - "St. Kitts & Nevis": "St. Kitts & Nevis", - "St. Lucia": "St. Lucia", - "St. Martin": "St. Martin", - "St. Pierre & Miquelon": "St. Pierre & Miquelon", - "St. Vincent & Grenadines": "St. Vincent & Grenadines", - "Sudan": "Sudan", - "Suriname": "Suriname", - "Svalbard & Jan Mayen": "Svalbard & Jan Mayen", - "Swaziland": "Swaziland", - "Sweden": "Sweden", - "Switzerland": "Switzerland", - "Syria": "Syria", - "São Tomé & Príncipe": "São Tomé & Príncipe", - "Taiwan": "Taiwan", - "Tajikistan": "Tajikistan", - "Tanzania": "Tanzania", - "Thailand": "Thailand", - "Timor-Leste": "Timor-Leste", - "Togo": "Togo", - "Tokelau": "Tokelau", - "Tonga": "Tonga", - "Trinidad & Tobago": "Trinidad & Tobago", - "Tunisia": "Tunisia", - "Turkey": "Turkey", - "Turkmenistan": "Turkmenistan", - "Turks & Caicos Islands": "Turks & Caicos Islands", - "Tuvalu": "Tuvalu", - "U.S. Virgin Islands": "U.S. Virgin Islands", - "Uganda": "Uganda", - "Ukraine": "Ukraine", - "United Arab Emirates": "United Arab Emirates", - "Uruguay": "Uruguay", - "Uzbekistan": "Uzbekistan", - "Vanuatu": "Vanuatu", - "Vatican City": "Vatican City", - "Venezuela": "Venezuela", - "Vietnam": "Vietnam", - "Wallis & Futuna": "Wallis & Futuna", - "Western Sahara": "Western Sahara", - "Yemen": "Yemen", - "Zambia": "Zambia", - "Zimbabwe": "Zimbabwe", - "Sign In or Create Account": "Sign In or Create Account", - "Use your account or create a new one to continue.": "Use your account or create a new one to continue.", - "Create Account": "Create Account", - "Sign In": "Sign In", - "Default": "Default", - "Restricted": "Restricted", - "Moderator": "Moderator", - "Admin": "Admin", - "Custom (%(level)s)": "Custom (%(level)s)", - "Failed to invite": "Failed to invite", - "Operation failed": "Operation failed", - "Failed to invite users to %(roomName)s": "Failed to invite users to %(roomName)s", - "We sent the others, but the below people couldn't be invited to ": "We sent the others, but the below people couldn't be invited to ", - "Some invites couldn't be sent": "Some invites couldn't be sent", - "You need to be logged in.": "You need to be logged in.", - "You need to be able to invite users to do that.": "You need to be able to invite users to do that.", - "Unable to create widget.": "Unable to create widget.", - "Missing roomId.": "Missing roomId.", - "Failed to send request.": "Failed to send request.", - "This room is not recognised.": "This room is not recognised.", - "Power level must be positive integer.": "Power level must be positive integer.", - "You are not in this room.": "You are not in this room.", - "You do not have permission to do that in this room.": "You do not have permission to do that in this room.", - "Missing room_id in request": "Missing room_id in request", - "Room %(roomId)s not visible": "Room %(roomId)s not visible", - "Missing user_id in request": "Missing user_id in request", - "Cancel entering passphrase?": "Cancel entering passphrase?", - "Are you sure you want to cancel entering passphrase?": "Are you sure you want to cancel entering passphrase?", - "Go Back": "Go Back", - "Cancel": "Cancel", - "Setting up keys": "Setting up keys", - "Messages": "Messages", - "Actions": "Actions", - "Advanced": "Advanced", - "Effects": "Effects", - "Other": "Other", - "Command error: Unable to handle slash command.": "Command error: Unable to handle slash command.", - "Command error: Unable to find rendering type (%(renderingType)s)": "Command error: Unable to find rendering type (%(renderingType)s)", - "Usage": "Usage", - "Sends the given message as a spoiler": "Sends the given message as a spoiler", - "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Prepends ¯\\_(ツ)_/¯ to a plain-text message", - "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message", - "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message", - "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message", - "Sends a message as plain text, without interpreting it as markdown": "Sends a message as plain text, without interpreting it as markdown", - "Sends a message as html, without interpreting it as markdown": "Sends a message as html, without interpreting it as markdown", - "Upgrades a room to a new version": "Upgrades a room to a new version", - "You do not have the required permissions to use this command.": "You do not have the required permissions to use this command.", - "Jump to the given date in the timeline": "Jump to the given date in the timeline", - "We were unable to understand the given date (%(inputDate)s). Try using the format YYYY-MM-DD.": "We were unable to understand the given date (%(inputDate)s). Try using the format YYYY-MM-DD.", - "Changes your display nickname": "Changes your display nickname", - "Changes your display nickname in the current room only": "Changes your display nickname in the current room only", - "Changes the avatar of the current room": "Changes the avatar of the current room", - "Changes your avatar in this current room only": "Changes your avatar in this current room only", - "Changes your avatar in all rooms": "Changes your avatar in all rooms", - "Gets or sets the room topic": "Gets or sets the room topic", - "Failed to get room topic: Unable to find room (%(roomId)s": "Failed to get room topic: Unable to find room (%(roomId)s", - "This room has no topic.": "This room has no topic.", - "Sets the room name": "Sets the room name", - "Invites user with given id to current room": "Invites user with given id to current room", - "Use an identity server": "Use an identity server", - "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.", - "Continue": "Continue", - "Use an identity server to invite by email. Manage in Settings.": "Use an identity server to invite by email. Manage in Settings.", - "Joins room with given address": "Joins room with given address", - "Leave room": "Leave room", - "Unrecognised room address: %(roomAlias)s": "Unrecognised room address: %(roomAlias)s", - "Removes user with given id from this room": "Removes user with given id from this room", - "Bans user with given id": "Bans user with given id", - "Unbans user with given ID": "Unbans user with given ID", - "Ignores a user, hiding their messages from you": "Ignores a user, hiding their messages from you", - "Ignored user": "Ignored user", - "You are now ignoring %(userId)s": "You are now ignoring %(userId)s", - "Stops ignoring a user, showing their messages going forward": "Stops ignoring a user, showing their messages going forward", - "Unignored user": "Unignored user", - "You are no longer ignoring %(userId)s": "You are no longer ignoring %(userId)s", - "Define the power level of a user": "Define the power level of a user", - "Command failed: Unable to find room (%(roomId)s": "Command failed: Unable to find room (%(roomId)s", - "Could not find user in room": "Could not find user in room", - "Deops user with given id": "Deops user with given id", - "Opens the Developer Tools dialog": "Opens the Developer Tools dialog", - "Adds a custom widget by URL to the room": "Adds a custom widget by URL to the room", - "Please supply a widget URL or embed code": "Please supply a widget URL or embed code", - "Please supply a https:// or http:// widget URL": "Please supply a https:// or http:// widget URL", - "You cannot modify widgets in this room.": "You cannot modify widgets in this room.", - "Verifies a user, session, and pubkey tuple": "Verifies a user, session, and pubkey tuple", - "Unknown (user, session) pair: (%(userId)s, %(deviceId)s)": "Unknown (user, session) pair: (%(userId)s, %(deviceId)s)", - "Session already verified!": "Session already verified!", - "WARNING: Session already verified, but keys do NOT MATCH!": "WARNING: Session already verified, but keys do NOT MATCH!", - "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!", - "Verified key": "Verified key", - "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.": "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.", - "Forces the current outbound group session in an encrypted room to be discarded": "Forces the current outbound group session in an encrypted room to be discarded", - "Sends the given message coloured as a rainbow": "Sends the given message coloured as a rainbow", - "Sends the given emote coloured as a rainbow": "Sends the given emote coloured as a rainbow", - "Displays list of commands with usages and descriptions": "Displays list of commands with usages and descriptions", - "Displays information about a user": "Displays information about a user", - "Send a bug report with logs": "Send a bug report with logs", - "Switches to this room's virtual room, if it has one": "Switches to this room's virtual room, if it has one", - "No virtual room for this room": "No virtual room for this room", - "Opens chat with the given user": "Opens chat with the given user", - "Unable to find Matrix ID for phone number": "Unable to find Matrix ID for phone number", - "Sends a message to the given user": "Sends a message to the given user", - "Places the call in the current room on hold": "Places the call in the current room on hold", - "No active call in this room": "No active call in this room", - "Takes the call in the current room off hold": "Takes the call in the current room off hold", - "Converts the room to a DM": "Converts the room to a DM", - "Converts the DM to a room": "Converts the DM to a room", - "Displays action": "Displays action", - "Someone": "Someone", - "%(senderName)s placed a voice call.": "%(senderName)s placed a voice call.", - "%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s placed a voice call. (not supported by this browser)", - "%(senderName)s placed a video call.": "%(senderName)s placed a video call.", - "%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s placed a video call. (not supported by this browser)", - "%(targetName)s accepted the invitation for %(displayName)s": "%(targetName)s accepted the invitation for %(displayName)s", - "%(targetName)s accepted an invitation": "%(targetName)s accepted an invitation", - "%(senderName)s invited %(targetName)s": "%(senderName)s invited %(targetName)s", - "%(senderName)s banned %(targetName)s: %(reason)s": "%(senderName)s banned %(targetName)s: %(reason)s", - "%(senderName)s banned %(targetName)s": "%(senderName)s banned %(targetName)s", - "%(oldDisplayName)s changed their display name to %(displayName)s": "%(oldDisplayName)s changed their display name to %(displayName)s", - "%(senderName)s set their display name to %(displayName)s": "%(senderName)s set their display name to %(displayName)s", - "%(senderName)s removed their display name (%(oldDisplayName)s)": "%(senderName)s removed their display name (%(oldDisplayName)s)", - "%(senderName)s removed their profile picture": "%(senderName)s removed their profile picture", - "%(senderName)s changed their profile picture": "%(senderName)s changed their profile picture", - "%(senderName)s set a profile picture": "%(senderName)s set a profile picture", - "%(senderName)s made no change": "%(senderName)s made no change", - "%(targetName)s joined the room": "%(targetName)s joined the room", - "%(targetName)s rejected the invitation": "%(targetName)s rejected the invitation", - "%(targetName)s left the room: %(reason)s": "%(targetName)s left the room: %(reason)s", - "%(targetName)s left the room": "%(targetName)s left the room", - "%(senderName)s unbanned %(targetName)s": "%(senderName)s unbanned %(targetName)s", - "%(senderName)s withdrew %(targetName)s's invitation: %(reason)s": "%(senderName)s withdrew %(targetName)s's invitation: %(reason)s", - "%(senderName)s withdrew %(targetName)s's invitation": "%(senderName)s withdrew %(targetName)s's invitation", - "%(senderName)s removed %(targetName)s: %(reason)s": "%(senderName)s removed %(targetName)s: %(reason)s", - "%(senderName)s removed %(targetName)s": "%(senderName)s removed %(targetName)s", - "%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s changed the topic to \"%(topic)s\".", - "%(senderDisplayName)s changed the room avatar.": "%(senderDisplayName)s changed the room avatar.", - "%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s removed the room name.", - "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.", - "%(senderDisplayName)s changed the room name to %(roomName)s.": "%(senderDisplayName)s changed the room name to %(roomName)s.", - "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s upgraded this room.", - "%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s made the room public to whoever knows the link.", - "%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s made the room invite only.", - "%(senderDisplayName)s changed who can join this room. View settings.": "%(senderDisplayName)s changed who can join this room. View settings.", - "%(senderDisplayName)s changed who can join this room.": "%(senderDisplayName)s changed who can join this room.", - "%(senderDisplayName)s changed the join rule to %(rule)s": "%(senderDisplayName)s changed the join rule to %(rule)s", - "%(senderDisplayName)s has allowed guests to join the room.": "%(senderDisplayName)s has allowed guests to join the room.", - "%(senderDisplayName)s has prevented guests from joining the room.": "%(senderDisplayName)s has prevented guests from joining the room.", - "%(senderDisplayName)s changed guest access to %(rule)s": "%(senderDisplayName)s changed guest access to %(rule)s", - "%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s set the server ACLs for this room.", - "%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s changed the server ACLs for this room.", - "🎉 All servers are banned from participating! This room can no longer be used.": "🎉 All servers are banned from participating! This room can no longer be used.", - "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.", - "%(senderDisplayName)s sent a sticker.": "%(senderDisplayName)s sent a sticker.", - "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.", - "%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.", - "%(senderName)s added the alternative addresses %(addresses)s for this room.|other": "%(senderName)s added the alternative addresses %(addresses)s for this room.", - "%(senderName)s added the alternative addresses %(addresses)s for this room.|one": "%(senderName)s added alternative address %(addresses)s for this room.", - "%(senderName)s removed the alternative addresses %(addresses)s for this room.|other": "%(senderName)s removed the alternative addresses %(addresses)s for this room.", - "%(senderName)s removed the alternative addresses %(addresses)s for this room.|one": "%(senderName)s removed alternative address %(addresses)s for this room.", - "%(senderName)s changed the alternative addresses for this room.": "%(senderName)s changed the alternative addresses for this room.", - "%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s changed the main and alternative addresses for this room.", - "%(senderName)s changed the addresses for this room.": "%(senderName)s changed the addresses for this room.", - "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.", - "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.", - "%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s made future room history visible to all room members, from the point they are invited.", - "%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s made future room history visible to all room members, from the point they joined.", - "%(senderName)s made future room history visible to all room members.": "%(senderName)s made future room history visible to all room members.", - "%(senderName)s made future room history visible to anyone.": "%(senderName)s made future room history visible to anyone.", - "%(senderName)s made future room history visible to unknown (%(visibility)s).": "%(senderName)s made future room history visible to unknown (%(visibility)s).", - "%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s changed the power level of %(powerLevelDiffText)s.", - "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s": "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s", - "%(senderName)s pinned a message to this room. See all pinned messages.": "%(senderName)s pinned a message to this room. See all pinned messages.", - "%(senderName)s pinned a message to this room. See all pinned messages.": "%(senderName)s pinned a message to this room. See all pinned messages.", - "%(senderName)s unpinned a message from this room. See all pinned messages.": "%(senderName)s unpinned a message from this room. See all pinned messages.", - "%(senderName)s unpinned a message from this room. See all pinned messages.": "%(senderName)s unpinned a message from this room. See all pinned messages.", - "%(senderName)s changed the pinned messages for the room.": "%(senderName)s changed the pinned messages for the room.", - "%(senderName)s changed the pinned messages for the room.": "%(senderName)s changed the pinned messages for the room.", - "%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s widget modified by %(senderName)s", - "%(widgetName)s widget added by %(senderName)s": "%(widgetName)s widget added by %(senderName)s", - "%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s", - "%(senderName)s has updated the room layout": "%(senderName)s has updated the room layout", - "%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s removed the rule banning users matching %(glob)s", - "%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s removed the rule banning rooms matching %(glob)s", - "%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s removed the rule banning servers matching %(glob)s", - "%(senderName)s removed a ban rule matching %(glob)s": "%(senderName)s removed a ban rule matching %(glob)s", - "%(senderName)s updated an invalid ban rule": "%(senderName)s updated an invalid ban rule", - "%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s": "%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s", - "%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s", - "%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s", - "%(senderName)s updated a ban rule matching %(glob)s for %(reason)s": "%(senderName)s updated a ban rule matching %(glob)s for %(reason)s", - "%(senderName)s created a rule banning users matching %(glob)s for %(reason)s": "%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", - "%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", - "%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", - "%(senderName)s created a ban rule matching %(glob)s for %(reason)s": "%(senderName)s created a ban rule matching %(glob)s for %(reason)s", - "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", - "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", - "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", - "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", - "%(senderName)s has shared their location": "%(senderName)s has shared their location", - "Message deleted": "Message deleted", - "Message deleted by %(name)s": "Message deleted by %(name)s", - "%(senderName)s has started a poll - %(pollQuestion)s": "%(senderName)s has started a poll - %(pollQuestion)s", - "%(senderName)s has ended a poll": "%(senderName)s has ended a poll", - "Light": "Light", - "Light high contrast": "Light high contrast", - "Dark": "Dark", - "%(displayName)s is typing …": "%(displayName)s is typing …", - "%(names)s and %(count)s others are typing …|other": "%(names)s and %(count)s others are typing …", - "%(names)s and %(count)s others are typing …|one": "%(names)s and one other is typing …", - "%(names)s and %(lastPerson)s are typing …": "%(names)s and %(lastPerson)s are typing …", - "Remain on your screen when viewing another room, when running": "Remain on your screen when viewing another room, when running", - "Remain on your screen while running": "Remain on your screen while running", - "Send stickers into this room": "Send stickers into this room", - "Send stickers into your active room": "Send stickers into your active room", - "Change which room you're viewing": "Change which room you're viewing", - "Change which room, message, or user you're viewing": "Change which room, message, or user you're viewing", - "Change the topic of this room": "Change the topic of this room", - "See when the topic changes in this room": "See when the topic changes in this room", - "Change the topic of your active room": "Change the topic of your active room", - "See when the topic changes in your active room": "See when the topic changes in your active room", - "Change the name of this room": "Change the name of this room", - "See when the name changes in this room": "See when the name changes in this room", - "Change the name of your active room": "Change the name of your active room", - "See when the name changes in your active room": "See when the name changes in your active room", - "Change the avatar of this room": "Change the avatar of this room", - "See when the avatar changes in this room": "See when the avatar changes in this room", - "Change the avatar of your active room": "Change the avatar of your active room", - "See when the avatar changes in your active room": "See when the avatar changes in your active room", - "Remove, ban, or invite people to this room, and make you leave": "Remove, ban, or invite people to this room, and make you leave", - "See when people join, leave, or are invited to this room": "See when people join, leave, or are invited to this room", - "Remove, ban, or invite people to your active room, and make you leave": "Remove, ban, or invite people to your active room, and make you leave", - "See when people join, leave, or are invited to your active room": "See when people join, leave, or are invited to your active room", - "Send stickers to this room as you": "Send stickers to this room as you", - "See when a sticker is posted in this room": "See when a sticker is posted in this room", - "Send stickers to your active room as you": "Send stickers to your active room as you", - "See when anyone posts a sticker to your active room": "See when anyone posts a sticker to your active room", - "with an empty state key": "with an empty state key", - "with state key %(stateKey)s": "with state key %(stateKey)s", - "The above, but in any room you are joined or invited to as well": "The above, but in any room you are joined or invited to as well", - "The above, but in as well": "The above, but in as well", - "Send %(eventType)s events as you in this room": "Send %(eventType)s events as you in this room", - "See %(eventType)s events posted to this room": "See %(eventType)s events posted to this room", - "Send %(eventType)s events as you in your active room": "Send %(eventType)s events as you in your active room", - "See %(eventType)s events posted to your active room": "See %(eventType)s events posted to your active room", - "The %(capability)s capability": "The %(capability)s capability", - "Send messages as you in this room": "Send messages as you in this room", - "Send messages as you in your active room": "Send messages as you in your active room", - "See messages posted to this room": "See messages posted to this room", - "See messages posted to your active room": "See messages posted to your active room", - "Send text messages as you in this room": "Send text messages as you in this room", - "Send text messages as you in your active room": "Send text messages as you in your active room", - "See text messages posted to this room": "See text messages posted to this room", - "See text messages posted to your active room": "See text messages posted to your active room", - "Send emotes as you in this room": "Send emotes as you in this room", - "Send emotes as you in your active room": "Send emotes as you in your active room", - "See emotes posted to this room": "See emotes posted to this room", - "See emotes posted to your active room": "See emotes posted to your active room", - "Send images as you in this room": "Send images as you in this room", - "Send images as you in your active room": "Send images as you in your active room", - "See images posted to this room": "See images posted to this room", - "See images posted to your active room": "See images posted to your active room", - "Send videos as you in this room": "Send videos as you in this room", - "Send videos as you in your active room": "Send videos as you in your active room", - "See videos posted to this room": "See videos posted to this room", - "See videos posted to your active room": "See videos posted to your active room", - "Send general files as you in this room": "Send general files as you in this room", - "Send general files as you in your active room": "Send general files as you in your active room", - "See general files posted to this room": "See general files posted to this room", - "See general files posted to your active room": "See general files posted to your active room", - "Send %(msgtype)s messages as you in this room": "Send %(msgtype)s messages as you in this room", - "Send %(msgtype)s messages as you in your active room": "Send %(msgtype)s messages as you in your active room", - "See %(msgtype)s messages posted to this room": "See %(msgtype)s messages posted to this room", - "See %(msgtype)s messages posted to your active room": "See %(msgtype)s messages posted to your active room", - "Cannot reach homeserver": "Cannot reach homeserver", - "Ensure you have a stable internet connection, or get in touch with the server admin": "Ensure you have a stable internet connection, or get in touch with the server admin", - "Your %(brand)s is misconfigured": "Your %(brand)s is misconfigured", - "Ask your %(brand)s admin to check your config for incorrect or duplicate entries.": "Ask your %(brand)s admin to check your config for incorrect or duplicate entries.", - "Cannot reach identity server": "Cannot reach identity server", - "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.", - "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.", - "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.", - "No homeserver URL provided": "No homeserver URL provided", - "Unexpected error resolving homeserver configuration": "Unexpected error resolving homeserver configuration", - "Unexpected error resolving identity server configuration": "Unexpected error resolving identity server configuration", - "This homeserver has hit its Monthly Active User limit.": "This homeserver has hit its Monthly Active User limit.", - "This homeserver has been blocked by its administrator.": "This homeserver has been blocked by its administrator.", - "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", - "Please contact your service administrator to continue using the service.": "Please contact your service administrator to continue using the service.", - "Unable to connect to Homeserver. Retrying...": "Unable to connect to Homeserver. Retrying...", - "Attachment": "Attachment", - "%(items)s and %(count)s others|other": "%(items)s and %(count)s others", - "%(items)s and %(count)s others|one": "%(items)s and one other", - "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", - "a few seconds ago": "a few seconds ago", - "about a minute ago": "about a minute ago", - "%(num)s minutes ago": "%(num)s minutes ago", - "about an hour ago": "about an hour ago", - "%(num)s hours ago": "%(num)s hours ago", - "about a day ago": "about a day ago", - "%(num)s days ago": "%(num)s days ago", - "a few seconds from now": "a few seconds from now", - "about a minute from now": "about a minute from now", - "%(num)s minutes from now": "%(num)s minutes from now", - "about an hour from now": "about an hour from now", - "%(num)s hours from now": "%(num)s hours from now", - "about a day from now": "about a day from now", - "%(num)s days from now": "%(num)s days from now", - "%(space1Name)s and %(space2Name)s": "%(space1Name)s and %(space2Name)s", - "%(spaceName)s and %(count)s others|other": "%(spaceName)s and %(count)s others", - "%(spaceName)s and %(count)s others|zero": "%(spaceName)s", - "%(spaceName)s and %(count)s others|one": "%(spaceName)s and %(count)s other", - "%(name)s (%(userId)s)": "%(name)s (%(userId)s)", - "Unexpected server error trying to leave the room": "Unexpected server error trying to leave the room", - "Can't leave Server Notices room": "Can't leave Server Notices room", - "This room is used for important messages from the Homeserver, so you cannot leave it.": "This room is used for important messages from the Homeserver, so you cannot leave it.", - "Error leaving room": "Error leaving room", - "Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions", - "Not a valid %(brand)s keyfile": "Not a valid %(brand)s keyfile", - "Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?", - "Unrecognised address": "Unrecognised address", - "You do not have permission to invite people to this space.": "You do not have permission to invite people to this space.", - "You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.", - "User is already invited to the space": "User is already invited to the space", - "User is already invited to the room": "User is already invited to the room", - "User is already in the space": "User is already in the space", - "User is already in the room": "User is already in the room", - "User does not exist": "User does not exist", - "User may or may not exist": "User may or may not exist", - "The user must be unbanned before they can be invited.": "The user must be unbanned before they can be invited.", - "The user's homeserver does not support the version of the space.": "The user's homeserver does not support the version of the space.", - "The user's homeserver does not support the version of the room.": "The user's homeserver does not support the version of the room.", - "Unknown server error": "Unknown server error", - "Use a few words, avoid common phrases": "Use a few words, avoid common phrases", - "No need for symbols, digits, or uppercase letters": "No need for symbols, digits, or uppercase letters", - "Use a longer keyboard pattern with more turns": "Use a longer keyboard pattern with more turns", - "Avoid repeated words and characters": "Avoid repeated words and characters", - "Avoid sequences": "Avoid sequences", - "Avoid recent years": "Avoid recent years", - "Avoid years that are associated with you": "Avoid years that are associated with you", - "Avoid dates and years that are associated with you": "Avoid dates and years that are associated with you", - "Capitalization doesn't help very much": "Capitalization doesn't help very much", - "All-uppercase is almost as easy to guess as all-lowercase": "All-uppercase is almost as easy to guess as all-lowercase", - "Reversed words aren't much harder to guess": "Reversed words aren't much harder to guess", - "Predictable substitutions like '@' instead of 'a' don't help very much": "Predictable substitutions like '@' instead of 'a' don't help very much", - "Add another word or two. Uncommon words are better.": "Add another word or two. Uncommon words are better.", - "Repeats like \"aaa\" are easy to guess": "Repeats like \"aaa\" are easy to guess", - "Repeats like \"abcabcabc\" are only slightly harder to guess than \"abc\"": "Repeats like \"abcabcabc\" are only slightly harder to guess than \"abc\"", - "Sequences like abc or 6543 are easy to guess": "Sequences like abc or 6543 are easy to guess", - "Recent years are easy to guess": "Recent years are easy to guess", - "Dates are often easy to guess": "Dates are often easy to guess", - "This is a top-10 common password": "This is a top-10 common password", - "This is a top-100 common password": "This is a top-100 common password", - "This is a very common password": "This is a very common password", - "This is similar to a commonly used password": "This is similar to a commonly used password", - "A word by itself is easy to guess": "A word by itself is easy to guess", - "Names and surnames by themselves are easy to guess": "Names and surnames by themselves are easy to guess", - "Common names and surnames are easy to guess": "Common names and surnames are easy to guess", - "Straight rows of keys are easy to guess": "Straight rows of keys are easy to guess", - "Short keyboard patterns are easy to guess": "Short keyboard patterns are easy to guess", - "Unnamed room": "Unnamed room", - "Unable to join network": "Unable to join network", - "%(brand)s does not know how to join a room on this network": "%(brand)s does not know how to join a room on this network", - "Room not found": "Room not found", - "Couldn't find a matching Matrix room": "Couldn't find a matching Matrix room", - "Fetching third party location failed": "Fetching third party location failed", - "Unable to look up room ID from server": "Unable to look up room ID from server", - "Error upgrading room": "Error upgrading room", - "Double check that your server supports the room version chosen and try again.": "Double check that your server supports the room version chosen and try again.", - "Invite to %(spaceName)s": "Invite to %(spaceName)s", - "Share your public space": "Share your public space", - "Unknown App": "Unknown App", - "This homeserver is not configured to display maps.": "This homeserver is not configured to display maps.", - "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.": "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.", - "Are you sure you want to exit during this export?": "Are you sure you want to exit during this export?", - "Generating a ZIP": "Generating a ZIP", - "Fetched %(count)s events out of %(total)s|other": "Fetched %(count)s events out of %(total)s", - "Fetched %(count)s events out of %(total)s|one": "Fetched %(count)s event out of %(total)s", - "Fetched %(count)s events so far|other": "Fetched %(count)s events so far", - "Fetched %(count)s events so far|one": "Fetched %(count)s event so far", - "HTML": "HTML", - "JSON": "JSON", - "Plain Text": "Plain Text", - "From the beginning": "From the beginning", - "Specify a number of messages": "Specify a number of messages", - "Current Timeline": "Current Timeline", - "Media omitted": "Media omitted", - "Media omitted - file size limit exceeded": "Media omitted - file size limit exceeded", - "%(creatorName)s created this room.": "%(creatorName)s created this room.", - "This is the start of export of . Exported by at %(exportDate)s.": "This is the start of export of . Exported by at %(exportDate)s.", - "Topic: %(topic)s": "Topic: %(topic)s", - "Error fetching file": "Error fetching file", - "Processing event %(number)s out of %(total)s": "Processing event %(number)s out of %(total)s", - "Starting export...": "Starting export...", - "Fetched %(count)s events in %(seconds)ss|other": "Fetched %(count)s events in %(seconds)ss", - "Fetched %(count)s events in %(seconds)ss|one": "Fetched %(count)s event in %(seconds)ss", - "Creating HTML...": "Creating HTML...", - "Export successful!": "Export successful!", - "Exported %(count)s events in %(seconds)s seconds|other": "Exported %(count)s events in %(seconds)s seconds", - "Exported %(count)s events in %(seconds)s seconds|one": "Exported %(count)s event in %(seconds)s seconds", - "File Attached": "File Attached", - "Starting export process...": "Starting export process...", - "Fetching events...": "Fetching events...", - "Creating output...": "Creating output...", - "Enable": "Enable", - "That's fine": "That's fine", - "Stop": "Stop", - "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.", - "Help improve %(analyticsOwner)s": "Help improve %(analyticsOwner)s", - "You previously consented to share anonymous usage data with us. We're updating how that works.": "You previously consented to share anonymous usage data with us. We're updating how that works.", - "Learn more": "Learn more", - "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More": "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More", - "Yes": "Yes", - "No": "No", - "You have unverified logins": "You have unverified logins", - "Review to ensure your account is safe": "Review to ensure your account is safe", - "Review": "Review", - "Later": "Later", - "Don't miss a reply": "Don't miss a reply", - "Notifications": "Notifications", - "Enable desktop notifications": "Enable desktop notifications", - "Unknown caller": "Unknown caller", - "Voice call": "Voice call", - "Video call": "Video call", - "Decline": "Decline", - "Accept": "Accept", - "Sound on": "Sound on", - "Silence call": "Silence call", - "Use app for a better experience": "Use app for a better experience", - "%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.": "%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.", - "Use app": "Use app", - "Your homeserver has exceeded its user limit.": "Your homeserver has exceeded its user limit.", - "Your homeserver has exceeded one of its resource limits.": "Your homeserver has exceeded one of its resource limits.", - "Contact your server admin.": "Contact your server admin.", - "Warning": "Warning", - "Ok": "Ok", - "Set up Secure Backup": "Set up Secure Backup", - "Encryption upgrade available": "Encryption upgrade available", - "Verify this session": "Verify this session", - "Upgrade": "Upgrade", - "Verify": "Verify", - "Safeguard against losing access to encrypted messages & data": "Safeguard against losing access to encrypted messages & data", - "Other users may not trust it": "Other users may not trust it", - "New login. Was this you?": "New login. Was this you?", - "%(deviceId)s from %(ip)s": "%(deviceId)s from %(ip)s", - "Check your devices": "Check your devices", - "What's new?": "What's new?", - "What's New": "What's New", - "Update": "Update", - "Update %(brand)s": "Update %(brand)s", - "New version of %(brand)s is available": "New version of %(brand)s is available", - "Guest": "Guest", - "There was an error joining.": "There was an error joining.", - "Sorry, your homeserver is too old to participate here.": "Sorry, your homeserver is too old to participate here.", - "Please contact your homeserver administrator.": "Please contact your homeserver administrator.", - "The person who invited you has already left.": "The person who invited you has already left.", - "The person who invited you has already left, or their server is offline.": "The person who invited you has already left, or their server is offline.", - "Failed to join": "Failed to join", - "All rooms": "All rooms", - "Home": "Home", - "Favourites": "Favourites", - "People": "People", - "Other rooms": "Other rooms", - "You joined the call": "You joined the call", - "%(senderName)s joined the call": "%(senderName)s joined the call", - "Call in progress": "Call in progress", - "You ended the call": "You ended the call", - "%(senderName)s ended the call": "%(senderName)s ended the call", - "Call ended": "Call ended", - "You started a call": "You started a call", - "%(senderName)s started a call": "%(senderName)s started a call", - "Waiting for answer": "Waiting for answer", - "%(senderName)s is calling": "%(senderName)s is calling", - "* %(senderName)s %(emote)s": "* %(senderName)s %(emote)s", - "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", - "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", - "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", - "Threads": "Threads", - "Back to chat": "Back to chat", - "Room information": "Room information", - "Room members": "Room members", - "Back to thread": "Back to thread", - "Change notification settings": "Change notification settings", - "Messaging": "Messaging", - "Profile": "Profile", - "Spaces": "Spaces", - "Widgets": "Widgets", - "Rooms": "Rooms", - "Moderation": "Moderation", - "Message Previews": "Message Previews", - "Themes": "Themes", - "Encryption": "Encryption", - "Experimental": "Experimental", - "Developer": "Developer", - "Let moderators hide messages pending moderation.": "Let moderators hide messages pending moderation.", - "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators": "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators", - "Render LaTeX maths in messages": "Render LaTeX maths in messages", - "Message Pinning": "Message Pinning", - "Threaded messaging": "Threaded messaging", - "Keep discussions organised with threads.": "Keep discussions organised with threads.", - "Threads help keep conversations on-topic and easy to track. Learn more.": "Threads help keep conversations on-topic and easy to track. Learn more.", - "How can I start a thread?": "How can I start a thread?", - "Use “%(replyInThread)s” when hovering over a message.": "Use “%(replyInThread)s” when hovering over a message.", - "Reply in thread": "Reply in thread", - "How can I leave the beta?": "How can I leave the beta?", - "To leave, return to this page and use the “%(leaveTheBeta)s” button.": "To leave, return to this page and use the “%(leaveTheBeta)s” button.", - "Leave the beta": "Leave the beta", - "Thank you for trying the beta, please go into as much detail as you can so we can improve it.": "Thank you for trying the beta, please go into as much detail as you can so we can improve it.", - "Video rooms (under active development)": "Video rooms (under active development)", - "Render simple counters in room header": "Render simple counters in room header", - "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", - "Support adding custom themes": "Support adding custom themes", - "Show message previews for reactions in DMs": "Show message previews for reactions in DMs", - "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", - "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", - "Show extensible event representation of events": "Show extensible event representation of events", - "Show current avatar and name for users in message history": "Show current avatar and name for users in message history", - "Show info about bridges in room settings": "Show info about bridges in room settings", - "Use new room breadcrumbs": "Use new room breadcrumbs", - "New search experience": "New search experience", - "The new search": "The new search", - "A new, quick way to search spaces and rooms you're in.": "A new, quick way to search spaces and rooms you're in.", - "This feature is a work in progress, we'd love to hear your feedback.": "This feature is a work in progress, we'd love to hear your feedback.", - "How can I give feedback?": "How can I give feedback?", - "To feedback, join the beta, start a search and click on feedback.": "To feedback, join the beta, start a search and click on feedback.", - "To leave, just return to this page or click on the beta badge when you search.": "To leave, just return to this page or click on the beta badge when you search.", - "Right panel stays open (defaults to room member list)": "Right panel stays open (defaults to room member list)", - "Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)", - "Don't send read receipts": "Don't send read receipts", - "Right-click message context menu": "Right-click message context menu", - "Location sharing - pin drop": "Location sharing - pin drop", - "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", - "Font size": "Font size", - "Use custom size": "Use custom size", - "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", - "Show stickers button": "Show stickers button", - "Show polls button": "Show polls button", - "Insert a trailing colon after user mentions at the start of a message": "Insert a trailing colon after user mentions at the start of a message", - "Use a more compact 'Modern' layout": "Use a more compact 'Modern' layout", - "Show a placeholder for removed messages": "Show a placeholder for removed messages", - "Show join/leave messages (invites/removes/bans unaffected)": "Show join/leave messages (invites/removes/bans unaffected)", - "Show avatar changes": "Show avatar changes", - "Show display name changes": "Show display name changes", - "Show read receipts sent by other users": "Show read receipts sent by other users", - "Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)", - "Always show message timestamps": "Always show message timestamps", - "Autoplay GIFs": "Autoplay GIFs", - "Autoplay videos": "Autoplay videos", - "Enable automatic language detection for syntax highlighting": "Enable automatic language detection for syntax highlighting", - "Expand code blocks by default": "Expand code blocks by default", - "Show line numbers in code blocks": "Show line numbers in code blocks", - "Jump to the bottom of the timeline when you send a message": "Jump to the bottom of the timeline when you send a message", - "Show avatars in user and room mentions": "Show avatars in user and room mentions", - "Enable big emoji in chat": "Enable big emoji in chat", - "Send typing notifications": "Send typing notifications", - "Show typing notifications": "Show typing notifications", - "Use Command + F to search timeline": "Use Command + F to search timeline", - "Use Ctrl + F to search timeline": "Use Ctrl + F to search timeline", - "Use Command + Enter to send a message": "Use Command + Enter to send a message", - "Use Ctrl + Enter to send a message": "Use Ctrl + Enter to send a message", - "Surround selected text when typing special characters": "Surround selected text when typing special characters", - "Automatically replace plain text Emoji": "Automatically replace plain text Emoji", - "Enable Markdown": "Enable Markdown", - "Start messages with /plain to send without markdown and /md to send with.": "Start messages with /plain to send without markdown and /md to send with.", - "Mirror local video feed": "Mirror local video feed", - "Match system theme": "Match system theme", - "Use a system font": "Use a system font", - "System font name": "System font name", - "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)", - "Send analytics data": "Send analytics data", - "Never send encrypted messages to unverified sessions from this session": "Never send encrypted messages to unverified sessions from this session", - "Never send encrypted messages to unverified sessions in this room from this session": "Never send encrypted messages to unverified sessions in this room from this session", - "Enable inline URL previews by default": "Enable inline URL previews by default", - "Enable URL previews for this room (only affects you)": "Enable URL previews for this room (only affects you)", - "Enable URL previews by default for participants in this room": "Enable URL previews by default for participants in this room", - "Enable widget screenshots on supported widgets": "Enable widget screenshots on supported widgets", - "Prompt before sending invites to potentially invalid matrix IDs": "Prompt before sending invites to potentially invalid matrix IDs", - "Order rooms by name": "Order rooms by name", - "Show rooms with unread notifications first": "Show rooms with unread notifications first", - "Show shortcuts to recently viewed rooms above the room list": "Show shortcuts to recently viewed rooms above the room list", - "Show hidden events in timeline": "Show hidden events in timeline", - "Low bandwidth mode (requires compatible homeserver)": "Low bandwidth mode (requires compatible homeserver)", - "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)", - "Show previews/thumbnails for images": "Show previews/thumbnails for images", - "Enable message search in encrypted rooms": "Enable message search in encrypted rooms", - "How fast should messages be downloaded.": "How fast should messages be downloaded.", - "Manually verify all remote sessions": "Manually verify all remote sessions", - "IRC display name width": "IRC display name width", - "Show chat effects (animations when receiving e.g. confetti)": "Show chat effects (animations when receiving e.g. confetti)", - "Show all rooms in Home": "Show all rooms in Home", - "All rooms you're in will appear in Home.": "All rooms you're in will appear in Home.", - "Developer mode": "Developer mode", - "Automatically send debug logs on any error": "Automatically send debug logs on any error", - "Automatically send debug logs on decryption errors": "Automatically send debug logs on decryption errors", - "Automatically send debug logs when key backup is not functioning": "Automatically send debug logs when key backup is not functioning", - "Partial Support for Threads": "Partial Support for Threads", - "Your homeserver does not currently support threads, so this feature may be unreliable. Some threaded messages may not be reliably available. Learn more.": "Your homeserver does not currently support threads, so this feature may be unreliable. Some threaded messages may not be reliably available. Learn more.", - "Do you want to enable threads anyway?": "Do you want to enable threads anyway?", - "Yes, enable": "Yes, enable", - "Collecting app version information": "Collecting app version information", - "Collecting logs": "Collecting logs", - "Uploading logs": "Uploading logs", - "Downloading logs": "Downloading logs", - "Waiting for response from server": "Waiting for response from server", - "Messages containing my display name": "Messages containing my display name", - "Messages containing my username": "Messages containing my username", - "Messages containing @room": "Messages containing @room", - "Messages in one-to-one chats": "Messages in one-to-one chats", - "Encrypted messages in one-to-one chats": "Encrypted messages in one-to-one chats", - "Messages in group chats": "Messages in group chats", - "Encrypted messages in group chats": "Encrypted messages in group chats", - "When I'm invited to a room": "When I'm invited to a room", - "Call invitation": "Call invitation", - "Messages sent by bot": "Messages sent by bot", - "When rooms are upgraded": "When rooms are upgraded", - "My Ban List": "My Ban List", - "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", - "Sends the given message with confetti": "Sends the given message with confetti", - "sends confetti": "sends confetti", - "Sends the given message with fireworks": "Sends the given message with fireworks", - "sends fireworks": "sends fireworks", - "Sends the given message with rainfall": "Sends the given message with rainfall", - "sends rainfall": "sends rainfall", - "Sends the given message with snowfall": "Sends the given message with snowfall", - "sends snowfall": "sends snowfall", - "Sends the given message with a space themed effect": "Sends the given message with a space themed effect", - "sends space invaders": "sends space invaders", - "Sends the given message with hearts": "Sends the given message with hearts", - "sends hearts": "sends hearts", - "Server error": "Server error", - "Command error": "Command error", - "Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.", - "Unknown Command": "Unknown Command", - "Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s", - "You can use /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", - "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", - "Send as message": "Send as message", - "You are presenting": "You are presenting", - "%(sharerName)s is presenting": "%(sharerName)s is presenting", - "Your camera is turned off": "Your camera is turned off", - "Your camera is still enabled": "Your camera is still enabled", - "unknown person": "unknown person", - "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Consulting with %(transferTarget)s. Transfer to %(transferee)s", - "You held the call Switch": "You held the call Switch", - "You held the call Resume": "You held the call Resume", - "%(peerName)s held the call": "%(peerName)s held the call", - "Connecting": "Connecting", - "Dial": "Dial", - "%(count)s people joined|other": "%(count)s people joined", - "%(count)s people joined|one": "%(count)s person joined", - "Audio devices": "Audio devices", - "Mute microphone": "Mute microphone", - "Unmute microphone": "Unmute microphone", - "Video devices": "Video devices", - "Turn off camera": "Turn off camera", - "Turn on camera": "Turn on camera", - "Join": "Join", - "Dialpad": "Dialpad", - "Mute the microphone": "Mute the microphone", - "Unmute the microphone": "Unmute the microphone", - "Stop the camera": "Stop the camera", - "Start the camera": "Start the camera", - "Stop sharing your screen": "Stop sharing your screen", - "Start sharing your screen": "Start sharing your screen", - "Hide sidebar": "Hide sidebar", - "Show sidebar": "Show sidebar", - "More": "More", - "Hangup": "Hangup", - "Fill Screen": "Fill Screen", - "Pin": "Pin", - "Return to call": "Return to call", - "%(name)s on hold": "%(name)s on hold", - "Call": "Call", - "The other party cancelled the verification.": "The other party cancelled the verification.", - "Verified!": "Verified!", - "You've successfully verified this user.": "You've successfully verified this user.", - "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.", - "Got It": "Got It", - "Confirm the emoji below are displayed on both devices, in the same order:": "Confirm the emoji below are displayed on both devices, in the same order:", - "Verify this user by confirming the following emoji appear on their screen.": "Verify this user by confirming the following emoji appear on their screen.", - "Verify this device by confirming the following number appears on its screen.": "Verify this device by confirming the following number appears on its screen.", - "Verify this user by confirming the following number appears on their screen.": "Verify this user by confirming the following number appears on their screen.", - "Unable to find a supported verification method.": "Unable to find a supported verification method.", - "Waiting for you to verify on your other device, %(deviceName)s (%(deviceId)s)…": "Waiting for you to verify on your other device, %(deviceName)s (%(deviceId)s)…", - "Waiting for you to verify on your other device…": "Waiting for you to verify on your other device…", - "Waiting for %(displayName)s to verify…": "Waiting for %(displayName)s to verify…", - "Cancelling…": "Cancelling…", - "They don't match": "They don't match", - "They match": "They match", - "To be secure, do this in person or use a trusted way to communicate.": "To be secure, do this in person or use a trusted way to communicate.", - "Dog": "Dog", - "Cat": "Cat", - "Lion": "Lion", - "Horse": "Horse", - "Unicorn": "Unicorn", - "Pig": "Pig", - "Elephant": "Elephant", - "Rabbit": "Rabbit", - "Panda": "Panda", - "Rooster": "Rooster", - "Penguin": "Penguin", - "Turtle": "Turtle", - "Fish": "Fish", - "Octopus": "Octopus", - "Butterfly": "Butterfly", - "Flower": "Flower", - "Tree": "Tree", - "Cactus": "Cactus", - "Mushroom": "Mushroom", - "Globe": "Globe", - "Moon": "Moon", - "Cloud": "Cloud", - "Fire": "Fire", - "Banana": "Banana", - "Apple": "Apple", - "Strawberry": "Strawberry", - "Corn": "Corn", - "Pizza": "Pizza", - "Cake": "Cake", - "Heart": "Heart", - "Smiley": "Smiley", - "Robot": "Robot", - "Hat": "Hat", - "Glasses": "Glasses", - "Spanner": "Spanner", - "Santa": "Santa", - "Thumbs up": "Thumbs up", - "Umbrella": "Umbrella", - "Hourglass": "Hourglass", - "Clock": "Clock", - "Gift": "Gift", - "Light bulb": "Light bulb", - "Book": "Book", - "Pencil": "Pencil", - "Paperclip": "Paperclip", - "Scissors": "Scissors", - "Lock": "Lock", - "Key": "Key", - "Hammer": "Hammer", - "Telephone": "Telephone", - "Flag": "Flag", - "Train": "Train", - "Bicycle": "Bicycle", - "Aeroplane": "Aeroplane", - "Rocket": "Rocket", - "Trophy": "Trophy", - "Ball": "Ball", - "Guitar": "Guitar", - "Trumpet": "Trumpet", - "Bell": "Bell", - "Anchor": "Anchor", - "Headphones": "Headphones", - "Folder": "Folder", - "Your server isn't responding to some requests.": "Your server isn't responding to some requests.", - "Decline (%(counter)s)": "Decline (%(counter)s)", - "Accept to continue:": "Accept to continue:", - "Quick settings": "Quick settings", - "All settings": "All settings", - "Developer tools": "Developer tools", - "Pin to sidebar": "Pin to sidebar", - "More options": "More options", - "Settings": "Settings", - "Match system": "Match system", - "Theme": "Theme", - "Space selection": "Space selection", - "Delete avatar": "Delete avatar", - "Delete": "Delete", - "Upload avatar": "Upload avatar", - "Upload": "Upload", - "Name": "Name", - "Description": "Description", - "No results": "No results", - "Search %(spaceName)s": "Search %(spaceName)s", - "Please enter a name for the space": "Please enter a name for the space", - "Spaces are a new feature.": "Spaces are a new feature.", - "Spaces feedback": "Spaces feedback", - "Thank you for trying Spaces. Your feedback will help inform the next versions.": "Thank you for trying Spaces. Your feedback will help inform the next versions.", - "Give feedback.": "Give feedback.", - "e.g. my-space": "e.g. my-space", - "Address": "Address", - "Create a space": "Create a space", - "Spaces are a new way to group rooms and people. What kind of Space do you want to create? You can change this later.": "Spaces are a new way to group rooms and people. What kind of Space do you want to create? You can change this later.", - "Public": "Public", - "Open space for anyone, best for communities": "Open space for anyone, best for communities", - "Private": "Private", - "Invite only, best for yourself or teams": "Invite only, best for yourself or teams", - "To join a space you'll need an invite.": "To join a space you'll need an invite.", - "Go back": "Go back", - "Your public space": "Your public space", - "Your private space": "Your private space", - "Add some details to help people recognise it.": "Add some details to help people recognise it.", - "You can change these anytime.": "You can change these anytime.", - "Creating...": "Creating...", - "Create": "Create", - "Show all rooms": "Show all rooms", - "Options": "Options", - "Expand": "Expand", - "Collapse": "Collapse", - "Click to copy": "Click to copy", - "Copied!": "Copied!", - "Failed to copy": "Failed to copy", - "Share invite link": "Share invite link", - "Invite people": "Invite people", - "Invite with email or username": "Invite with email or username", - "Failed to save space settings.": "Failed to save space settings.", - "General": "General", - "Edit settings relating to your space.": "Edit settings relating to your space.", - "Saving...": "Saving...", - "Save Changes": "Save Changes", - "Leave Space": "Leave Space", - "Failed to update the guest access of this space": "Failed to update the guest access of this space", - "Failed to update the history visibility of this space": "Failed to update the history visibility of this space", - "Hide advanced": "Hide advanced", - "Show advanced": "Show advanced", - "Enable guest access": "Enable guest access", - "Guests can join a space without having an account.": "Guests can join a space without having an account.", - "This may be useful for public spaces.": "This may be useful for public spaces.", - "Visibility": "Visibility", - "Access": "Access", - "Decide who can view and join %(spaceName)s.": "Decide who can view and join %(spaceName)s.", - "Failed to update the visibility of this space": "Failed to update the visibility of this space", - "Preview Space": "Preview Space", - "Allow people to preview your space before they join.": "Allow people to preview your space before they join.", - "Recommended for public spaces.": "Recommended for public spaces.", - "Jump to first unread room.": "Jump to first unread room.", - "Jump to first invite.": "Jump to first invite.", - "Space options": "Space options", - "Remove": "Remove", - "This bridge was provisioned by .": "This bridge was provisioned by .", - "This bridge is managed by .": "This bridge is managed by .", - "Workspace: ": "Workspace: ", - "Channel: ": "Channel: ", - "Failed to upload profile picture!": "Failed to upload profile picture!", - "Upload new:": "Upload new:", - "No display name": "No display name", - "Warning!": "Warning!", - "Changing your password on this homeserver will cause all of your other devices to be signed out. This will delete the message encryption keys stored on them, and may make encrypted chat history unreadable.": "Changing your password on this homeserver will cause all of your other devices to be signed out. This will delete the message encryption keys stored on them, and may make encrypted chat history unreadable.", - "If you want to retain access to your chat history in encrypted rooms you should first export your room keys and re-import them afterwards.": "If you want to retain access to your chat history in encrypted rooms you should first export your room keys and re-import them afterwards.", - "You can also ask your homeserver admin to upgrade the server to change this behaviour.": "You can also ask your homeserver admin to upgrade the server to change this behaviour.", - "Export E2E room keys": "Export E2E room keys", - "New passwords don't match": "New passwords don't match", - "Passwords can't be empty": "Passwords can't be empty", - "Do you want to set an email address?": "Do you want to set an email address?", - "Confirm password": "Confirm password", - "Passwords don't match": "Passwords don't match", - "Current password": "Current password", - "New Password": "New Password", - "Change Password": "Change Password", - "Your homeserver does not support cross-signing.": "Your homeserver does not support cross-signing.", - "Cross-signing is ready for use.": "Cross-signing is ready for use.", - "Cross-signing is ready but keys are not backed up.": "Cross-signing is ready but keys are not backed up.", - "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.": "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.", - "Cross-signing is not set up.": "Cross-signing is not set up.", - "Reset": "Reset", - "Cross-signing public keys:": "Cross-signing public keys:", - "in memory": "in memory", - "not found": "not found", - "Cross-signing private keys:": "Cross-signing private keys:", - "in secret storage": "in secret storage", - "not found in storage": "not found in storage", - "Master private key:": "Master private key:", - "cached locally": "cached locally", - "not found locally": "not found locally", - "Self signing private key:": "Self signing private key:", - "User signing private key:": "User signing private key:", - "Homeserver feature support:": "Homeserver feature support:", - "exists": "exists", - "": "", - "Import E2E room keys": "Import E2E room keys", - "Cryptography": "Cryptography", - "Session ID:": "Session ID:", - "Session key:": "Session key:", - "Your homeserver does not support device management.": "Your homeserver does not support device management.", - "Unable to load device list": "Unable to load device list", - "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.", - "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.", - "Confirm signing out these devices|other": "Confirm signing out these devices", - "Confirm signing out these devices|one": "Confirm signing out this device", - "Click the button below to confirm signing out these devices.|other": "Click the button below to confirm signing out these devices.", - "Click the button below to confirm signing out these devices.|one": "Click the button below to confirm signing out this device.", - "Sign out devices|other": "Sign out devices", - "Sign out devices|one": "Sign out device", - "Authentication": "Authentication", - "Deselect all": "Deselect all", - "Select all": "Select all", - "Verified devices": "Verified devices", - "Unverified devices": "Unverified devices", - "Devices without encryption support": "Devices without encryption support", - "Sign out %(count)s selected devices|other": "Sign out %(count)s selected devices", - "Sign out %(count)s selected devices|one": "Sign out %(count)s selected device", - "You aren't signed into any other devices.": "You aren't signed into any other devices.", - "This device": "This device", - "Failed to set display name": "Failed to set display name", - "Last seen %(date)s at %(ip)s": "Last seen %(date)s at %(ip)s", - "Sign Out": "Sign Out", - "Display Name": "Display Name", - "Rename": "Rename", - "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.", - "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|other": "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.", - "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|one": "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s room.", - "Manage": "Manage", - "Securely cache encrypted messages locally for them to appear in search results.": "Securely cache encrypted messages locally for them to appear in search results.", - "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.", - "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.", - "Message search initialisation failed": "Message search initialisation failed", - "Hey you. You're the best!": "Hey you. You're the best!", - "Size must be a number": "Size must be a number", - "Custom font size can only be between %(min)s pt and %(max)s pt": "Custom font size can only be between %(min)s pt and %(max)s pt", - "Use between %(min)s pt and %(max)s pt": "Use between %(min)s pt and %(max)s pt", - "Image size in the timeline": "Image size in the timeline", - "Large": "Large", - "Connecting to integration manager...": "Connecting to integration manager...", - "Cannot connect to integration manager": "Cannot connect to integration manager", - "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", - "Integration manager": "Integration manager", - "Private (invite only)": "Private (invite only)", - "Only invited people can join.": "Only invited people can join.", - "Anyone can find and join.": "Anyone can find and join.", - "Upgrade required": "Upgrade required", - "& %(count)s more|other": "& %(count)s more", - "& %(count)s more|one": "& %(count)s more", - "Currently, %(count)s spaces have access|other": "Currently, %(count)s spaces have access", - "Currently, %(count)s spaces have access|one": "Currently, a space has access", - "Anyone in a space can find and join. Edit which spaces can access here.": "Anyone in a space can find and join. Edit which spaces can access here.", - "Spaces with access": "Spaces with access", - "Anyone in can find and join. You can select other spaces too.": "Anyone in can find and join. You can select other spaces too.", - "Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.", - "Space members": "Space members", - "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.": "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.", - "This upgrade will allow members of selected spaces access to this room without an invite.": "This upgrade will allow members of selected spaces access to this room without an invite.", - "Upgrading room": "Upgrading room", - "Loading new room": "Loading new room", - "Sending invites... (%(progress)s out of %(count)s)|other": "Sending invites... (%(progress)s out of %(count)s)", - "Sending invites... (%(progress)s out of %(count)s)|one": "Sending invite...", - "Updating spaces... (%(progress)s out of %(count)s)|other": "Updating spaces... (%(progress)s out of %(count)s)", - "Updating spaces... (%(progress)s out of %(count)s)|one": "Updating space...", - "Message layout": "Message layout", - "IRC (Experimental)": "IRC (Experimental)", - "Modern": "Modern", - "Message bubbles": "Message bubbles", - "Messages containing keywords": "Messages containing keywords", - "Error saving notification preferences": "Error saving notification preferences", - "An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.", - "Enable for this account": "Enable for this account", - "Enable email notifications for %(email)s": "Enable email notifications for %(email)s", - "Enable desktop notifications for this session": "Enable desktop notifications for this session", - "Show message in desktop notification": "Show message in desktop notification", - "Enable audible notifications for this session": "Enable audible notifications for this session", - "Clear notifications": "Clear notifications", - "Keyword": "Keyword", - "New keyword": "New keyword", - "On": "On", - "Off": "Off", - "Noisy": "Noisy", - "Global": "Global", - "Mentions & keywords": "Mentions & keywords", - "Notification targets": "Notification targets", - "There was an error loading your notification settings.": "There was an error loading your notification settings.", - "Failed to save your profile": "Failed to save your profile", - "The operation could not be completed": "The operation could not be completed", - "Upgrade to your own domain": "Upgrade to your own domain", - "Profile picture": "Profile picture", - "Save": "Save", - "Delete Backup": "Delete Backup", - "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", - "Unable to load key backup status": "Unable to load key backup status", - "Restore from Backup": "Restore from Backup", - "This session is backing up your keys. ": "This session is backing up your keys. ", - "This session is not backing up your keys, but you do have an existing backup you can restore from and add to going forward.": "This session is not backing up your keys, but you do have an existing backup you can restore from and add to going forward.", - "Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.": "Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.", - "Connect this session to Key Backup": "Connect this session to Key Backup", - "Backing up %(sessionsRemaining)s keys...": "Backing up %(sessionsRemaining)s keys...", - "All keys backed up": "All keys backed up", - "Backup has a valid signature from this user": "Backup has a valid signature from this user", - "Backup has a invalid signature from this user": "Backup has a invalid signature from this user", - "Backup has a signature from unknown user with ID %(deviceId)s": "Backup has a signature from unknown user with ID %(deviceId)s", - "Backup has a signature from unknown session with ID %(deviceId)s": "Backup has a signature from unknown session with ID %(deviceId)s", - "Backup has a valid signature from this session": "Backup has a valid signature from this session", - "Backup has an invalid signature from this session": "Backup has an invalid signature from this session", - "Backup has a valid signature from verified session ": "Backup has a valid signature from verified session ", - "Backup has a valid signature from unverified session ": "Backup has a valid signature from unverified session ", - "Backup has an invalid signature from verified session ": "Backup has an invalid signature from verified session ", - "Backup has an invalid signature from unverified session ": "Backup has an invalid signature from unverified session ", - "Backup is not signed by any of your sessions": "Backup is not signed by any of your sessions", - "This backup is trusted because it has been restored on this session": "This backup is trusted because it has been restored on this session", - "Backup version:": "Backup version:", - "Algorithm:": "Algorithm:", - "Your keys are not being backed up from this session.": "Your keys are not being backed up from this session.", - "Back up your keys before signing out to avoid losing them.": "Back up your keys before signing out to avoid losing them.", - "Set up": "Set up", - "well formed": "well formed", - "unexpected type": "unexpected type", - "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.": "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.", - "Backup key stored:": "Backup key stored:", - "not stored": "not stored", - "Backup key cached:": "Backup key cached:", - "Secret storage public key:": "Secret storage public key:", - "in account data": "in account data", - "Secret storage:": "Secret storage:", - "ready": "ready", - "not ready": "not ready", - "Identity server URL must be HTTPS": "Identity server URL must be HTTPS", - "Not a valid identity server (status code %(code)s)": "Not a valid identity server (status code %(code)s)", - "Could not connect to identity server": "Could not connect to identity server", - "Checking server": "Checking server", - "Change identity server": "Change identity server", - "Disconnect from the identity server and connect to instead?": "Disconnect from the identity server and connect to instead?", - "Terms of service not accepted or the identity server is invalid.": "Terms of service not accepted or the identity server is invalid.", - "The identity server you have chosen does not have any terms of service.": "The identity server you have chosen does not have any terms of service.", - "Disconnect identity server": "Disconnect identity server", - "Disconnect from the identity server ?": "Disconnect from the identity server ?", - "Disconnect": "Disconnect", - "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.", - "You should:": "You should:", - "check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "check your browser plugins for anything that might block the identity server (such as Privacy Badger)", - "contact the administrators of identity server ": "contact the administrators of identity server ", - "wait and try again later": "wait and try again later", - "Disconnect anyway": "Disconnect anyway", - "You are still sharing your personal data on the identity server .": "You are still sharing your personal data on the identity server .", - "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.", - "Identity server (%(server)s)": "Identity server (%(server)s)", - "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.": "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.", - "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.": "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.", - "Identity server": "Identity server", - "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.": "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.", - "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.", - "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.", - "Do not use an identity server": "Do not use an identity server", - "Enter a new identity server": "Enter a new identity server", - "Change": "Change", - "Use an integration manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Use an integration manager (%(serverName)s) to manage bots, widgets, and sticker packs.", - "Use an integration manager to manage bots, widgets, and sticker packs.": "Use an integration manager to manage bots, widgets, and sticker packs.", - "Manage integrations": "Manage integrations", - "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.", - "Add": "Add", - "Invalid theme schema.": "Invalid theme schema.", - "Error downloading theme information.": "Error downloading theme information.", - "Theme added!": "Theme added!", - "Use high contrast": "Use high contrast", - "Custom theme URL": "Custom theme URL", - "Add theme": "Add theme", - "Error encountered (%(errorDetail)s).": "Error encountered (%(errorDetail)s).", - "Checking for an update...": "Checking for an update...", - "No update available.": "No update available.", - "Downloading update...": "Downloading update...", - "New version available. Update now.": "New version available. Update now.", - "Check for update": "Check for update", - "Set the name of a font installed on your system & %(brand)s will attempt to use it.": "Set the name of a font installed on your system & %(brand)s will attempt to use it.", - "Customise your appearance": "Customise your appearance", - "Appearance Settings only affect this %(brand)s session.": "Appearance Settings only affect this %(brand)s session.", - "Failed to change password. Is your password correct?": "Failed to change password. Is your password correct?", - "Your password was successfully changed.": "Your password was successfully changed.", - "You will not receive push notifications on other devices until you sign back in to them.": "You will not receive push notifications on other devices until you sign back in to them.", - "Success": "Success", - "Email addresses": "Email addresses", - "Phone numbers": "Phone numbers", - "Set a new account password...": "Set a new account password...", - "Account": "Account", - "Language and region": "Language and region", - "Spell check dictionaries": "Spell check dictionaries", - "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.", - "Account management": "Account management", - "Deactivating your account is a permanent action - be careful!": "Deactivating your account is a permanent action - be careful!", - "Deactivate Account": "Deactivate Account", - "Deactivate account": "Deactivate account", - "Discovery": "Discovery", - "%(brand)s version:": "%(brand)s version:", - "Olm version:": "Olm version:", - "Legal": "Legal", - "Credits": "Credits", - "For help with using %(brand)s, click here.": "For help with using %(brand)s, click here.", - "For help with using %(brand)s, click here or start a chat with our bot using the button below.": "For help with using %(brand)s, click here or start a chat with our bot using the button below.", - "Chat with %(brand)s Bot": "Chat with %(brand)s Bot", - "Bug reporting": "Bug reporting", - "If you've submitted a bug via GitHub, debug logs can help us track down the problem. ": "If you've submitted a bug via GitHub, debug logs can help us track down the problem. ", - "Debug logs contain application usage data including your username, the IDs or aliases of the rooms you have visited, which UI elements you last interacted with, and the usernames of other users. They do not contain messages.": "Debug logs contain application usage data including your username, the IDs or aliases of the rooms you have visited, which UI elements you last interacted with, and the usernames of other users. They do not contain messages.", - "Submit debug logs": "Submit debug logs", - "To report a Matrix-related security issue, please read the Matrix.org Security Disclosure Policy.": "To report a Matrix-related security issue, please read the Matrix.org Security Disclosure Policy.", - "Help & About": "Help & About", - "FAQ": "FAQ", - "Keyboard Shortcuts": "Keyboard Shortcuts", - "Versions": "Versions", - "Homeserver is": "Homeserver is", - "Identity server is": "Identity server is", - "Access Token": "Access Token", - "Your access token gives full access to your account. Do not share it with anyone.": "Your access token gives full access to your account. Do not share it with anyone.", - "Clear cache and reload": "Clear cache and reload", - "Keyboard": "Keyboard", - "Labs": "Labs", - "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.", - "Ignored/Blocked": "Ignored/Blocked", - "Error adding ignored user/server": "Error adding ignored user/server", - "Something went wrong. Please try again or view your console for hints.": "Something went wrong. Please try again or view your console for hints.", - "Error subscribing to list": "Error subscribing to list", - "Please verify the room ID or address and try again.": "Please verify the room ID or address and try again.", - "Error removing ignored user/server": "Error removing ignored user/server", - "Error unsubscribing from list": "Error unsubscribing from list", - "Please try again or view your console for hints.": "Please try again or view your console for hints.", - "None": "None", - "Ban list rules - %(roomName)s": "Ban list rules - %(roomName)s", - "Server rules": "Server rules", - "User rules": "User rules", - "Close": "Close", - "You have not ignored anyone.": "You have not ignored anyone.", - "You are currently ignoring:": "You are currently ignoring:", - "You are not subscribed to any lists": "You are not subscribed to any lists", - "Unsubscribe": "Unsubscribe", - "View rules": "View rules", - "You are currently subscribed to:": "You are currently subscribed to:", - "Ignored users": "Ignored users", - "⚠ These settings are meant for advanced users.": "⚠ These settings are meant for advanced users.", - "Add users and servers you want to ignore here. Use asterisks to have %(brand)s match any characters. For example, @bot:* would ignore all users that have the name 'bot' on any server.": "Add users and servers you want to ignore here. Use asterisks to have %(brand)s match any characters. For example, @bot:* would ignore all users that have the name 'bot' on any server.", - "Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.": "Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.", - "Personal ban list": "Personal ban list", - "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.": "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.", - "Server or user ID to ignore": "Server or user ID to ignore", - "eg: @bot:* or example.org": "eg: @bot:* or example.org", - "Ignore": "Ignore", - "Subscribed lists": "Subscribed lists", - "Subscribing to a ban list will cause you to join it!": "Subscribing to a ban list will cause you to join it!", - "If this isn't what you want, please use a different tool to ignore users.": "If this isn't what you want, please use a different tool to ignore users.", - "Room ID or address of ban list": "Room ID or address of ban list", - "Subscribe": "Subscribe", - "Start automatically after system login": "Start automatically after system login", - "Warn before quitting": "Warn before quitting", - "Always show the window menu bar": "Always show the window menu bar", - "Show tray icon and minimise window to it on close": "Show tray icon and minimise window to it on close", - "Enable hardware acceleration (restart %(appName)s to take effect)": "Enable hardware acceleration (restart %(appName)s to take effect)", - "Preferences": "Preferences", - "Room list": "Room list", - "Keyboard shortcuts": "Keyboard shortcuts", - "To view all keyboard shortcuts, click here.": "To view all keyboard shortcuts, click here.", - "Displaying time": "Displaying time", - "Composer": "Composer", - "Code blocks": "Code blocks", - "Images, GIFs and videos": "Images, GIFs and videos", - "Timeline": "Timeline", - "Autocomplete delay (ms)": "Autocomplete delay (ms)", - "Read Marker lifetime (ms)": "Read Marker lifetime (ms)", - "Read Marker off-screen lifetime (ms)": "Read Marker off-screen lifetime (ms)", - "Unignore": "Unignore", - "You have no ignored users.": "You have no ignored users.", - "Bulk options": "Bulk options", - "Accept all %(invitedRooms)s invites": "Accept all %(invitedRooms)s invites", - "Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites", - "Secure Backup": "Secure Backup", - "Message search": "Message search", - "Cross-signing": "Cross-signing", - "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.", - "Okay": "Okay", - "Privacy": "Privacy", - "Share anonymous data to help us identify issues. Nothing personal. No third parties.": "Share anonymous data to help us identify issues. Nothing personal. No third parties.", - "Where you're signed in": "Where you're signed in", - "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Manage your signed-in devices below. A device's name is visible to people you communicate with.", - "Sidebar": "Sidebar", - "Spaces to show": "Spaces to show", - "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.", - "Home is useful for getting an overview of everything.": "Home is useful for getting an overview of everything.", - "Show all your rooms in Home, even if they're in a space.": "Show all your rooms in Home, even if they're in a space.", - "Group all your favourite rooms and people in one place.": "Group all your favourite rooms and people in one place.", - "Group all your people in one place.": "Group all your people in one place.", - "Rooms outside of a space": "Rooms outside of a space", - "Group all your rooms that aren't part of a space in one place.": "Group all your rooms that aren't part of a space in one place.", - "Default Device": "Default Device", - "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", - "Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.", - "Request media permissions": "Request media permissions", - "Audio Output": "Audio Output", - "No Audio Outputs detected": "No Audio Outputs detected", - "Microphone": "Microphone", - "No Microphones detected": "No Microphones detected", - "Camera": "Camera", - "No Webcams detected": "No Webcams detected", - "Voice & Video": "Voice & Video", - "This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers", - "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. 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.": "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. 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.", - "Upgrade this space to the recommended room version": "Upgrade this space to the recommended room version", - "Upgrade this room to the recommended room version": "Upgrade this room to the recommended room version", - "View older version of %(spaceName)s.": "View older version of %(spaceName)s.", - "View older messages in %(roomName)s.": "View older messages in %(roomName)s.", - "Space information": "Space information", - "Internal room ID": "Internal room ID", - "Room version": "Room version", - "Room version:": "Room version:", - "This room is bridging messages to the following platforms. Learn more.": "This room is bridging messages to the following platforms. Learn more.", - "This room isn't bridging messages to any platforms. Learn more.": "This room isn't bridging messages to any platforms. Learn more.", - "Bridges": "Bridges", - "Room Addresses": "Room Addresses", - "Uploaded sound": "Uploaded sound", - "Get notifications as set up in your settings": "Get notifications as set up in your settings", - "All messages": "All messages", - "Get notified for every message": "Get notified for every message", - "@mentions & keywords": "@mentions & keywords", - "Get notified only with mentions and keywords as set up in your settings": "Get notified only with mentions and keywords as set up in your settings", - "You won't get any notifications": "You won't get any notifications", - "Sounds": "Sounds", - "Notification sound": "Notification sound", - "Set a new custom sound": "Set a new custom sound", - "Browse": "Browse", - "Failed to unban": "Failed to unban", - "Unban": "Unban", - "Banned by %(displayName)s": "Banned by %(displayName)s", - "Reason": "Reason", - "Error changing power level requirement": "Error changing power level requirement", - "An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.": "An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.", - "Error changing power level": "Error changing power level", - "An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.": "An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.", - "Change space avatar": "Change space avatar", - "Change room avatar": "Change room avatar", - "Change space name": "Change space name", - "Change room name": "Change room name", - "Change main address for the space": "Change main address for the space", - "Change main address for the room": "Change main address for the room", - "Manage rooms in this space": "Manage rooms in this space", - "Change history visibility": "Change history visibility", - "Change permissions": "Change permissions", - "Change description": "Change description", - "Change topic": "Change topic", - "Upgrade the room": "Upgrade the room", - "Enable room encryption": "Enable room encryption", - "Change server ACLs": "Change server ACLs", - "Send reactions": "Send reactions", - "Remove messages sent by me": "Remove messages sent by me", - "Modify widgets": "Modify widgets", - "Manage pinned events": "Manage pinned events", - "Default role": "Default role", - "Send messages": "Send messages", - "Invite users": "Invite users", - "Change settings": "Change settings", - "Remove users": "Remove users", - "Ban users": "Ban users", - "Remove messages sent by others": "Remove messages sent by others", - "Notify everyone": "Notify everyone", - "No users have specific privileges in this room": "No users have specific privileges in this room", - "Privileged Users": "Privileged Users", - "Muted Users": "Muted Users", - "Banned users": "Banned users", - "Send %(eventType)s events": "Send %(eventType)s events", - "Roles & Permissions": "Roles & Permissions", - "Permissions": "Permissions", - "Select the roles required to change various parts of the space": "Select the roles required to change various parts of the space", - "Select the roles required to change various parts of the room": "Select the roles required to change various parts of the room", - "Are you sure you want to add encryption to this public room?": "Are you sure you want to add encryption to this public room?", - "It's not recommended to add encryption to public rooms.Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "It's not recommended to add encryption to public rooms.Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.", - "To avoid these issues, create a new encrypted room for the conversation you plan to have.": "To avoid these issues, create a new encrypted room for the conversation you plan to have.", - "Enable encryption?": "Enable encryption?", - "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.": "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.", - "To link to this room, please add an address.": "To link to this room, please add an address.", - "Decide who can join %(roomName)s.": "Decide who can join %(roomName)s.", - "Failed to update the join rules": "Failed to update the join rules", - "Unknown failure": "Unknown failure", - "Are you sure you want to make this encrypted room public?": "Are you sure you want to make this encrypted room public?", - "It's not recommended to make encrypted rooms public. It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.": "It's not recommended to make encrypted rooms public. It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.", - "To avoid these issues, create a new public room for the conversation you plan to have.": "To avoid these issues, create a new public room for the conversation you plan to have.", - "Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)", - "Members only (since they were invited)": "Members only (since they were invited)", - "Members only (since they joined)": "Members only (since they joined)", - "Anyone": "Anyone", - "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.", - "Who can read history?": "Who can read history?", - "People with supported clients will be able to join the room without having a registered account.": "People with supported clients will be able to join the room without having a registered account.", - "Security & Privacy": "Security & Privacy", - "Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.", - "Encrypted": "Encrypted", - "Unable to revoke sharing for email address": "Unable to revoke sharing for email address", - "Unable to share email address": "Unable to share email address", - "Your email address hasn't been verified yet": "Your email address hasn't been verified yet", - "Click the link in the email you received to verify and then click continue again.": "Click the link in the email you received to verify and then click continue again.", - "Unable to verify email address.": "Unable to verify email address.", - "Verify the link in your inbox": "Verify the link in your inbox", - "Complete": "Complete", - "Revoke": "Revoke", - "Share": "Share", - "Discovery options will appear once you have added an email above.": "Discovery options will appear once you have added an email above.", - "Unable to revoke sharing for phone number": "Unable to revoke sharing for phone number", - "Unable to share phone number": "Unable to share phone number", - "Unable to verify phone number.": "Unable to verify phone number.", - "Incorrect verification code": "Incorrect verification code", - "Please enter verification code sent via text.": "Please enter verification code sent via text.", - "Verification code": "Verification code", - "Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.", - "Unable to remove contact information": "Unable to remove contact information", - "Remove %(email)s?": "Remove %(email)s?", - "Invalid Email Address": "Invalid Email Address", - "This doesn't appear to be a valid email address": "This doesn't appear to be a valid email address", - "Unable to add email address": "Unable to add email address", - "We've sent you an email to verify your address. Please follow the instructions there and then click the button below.": "We've sent you an email to verify your address. Please follow the instructions there and then click the button below.", - "Email Address": "Email Address", - "Remove %(phone)s?": "Remove %(phone)s?", - "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.", - "Phone Number": "Phone Number", - "This user has not verified all of their sessions.": "This user has not verified all of their sessions.", - "You have not verified this user.": "You have not verified this user.", - "You have verified this user. This user has verified all of their sessions.": "You have verified this user. This user has verified all of their sessions.", - "Someone is using an unknown session": "Someone is using an unknown session", - "This room is end-to-end encrypted": "This room is end-to-end encrypted", - "Everyone in this room is verified": "Everyone in this room is verified", - "Edit message": "Edit message", - "Mod": "Mod", - "From a thread": "From a thread", - "This event could not be displayed": "This event could not be displayed", - "Your key share request has been sent - please check your other sessions for key share requests.": "Your key share request has been sent - please check your other sessions for key share requests.", - "Key share requests are sent to your other sessions automatically. If you rejected or dismissed the key share request on your other sessions, click here to request the keys for this session again.": "Key share requests are sent to your other sessions automatically. If you rejected or dismissed the key share request on your other sessions, click here to request the keys for this session again.", - "If your other sessions do not have the key for this message you will not be able to decrypt them.": "If your other sessions do not have the key for this message you will not be able to decrypt them.", - "Key request sent.": "Key request sent.", - "Re-request encryption keys from your other sessions.": "Re-request encryption keys from your other sessions.", - "Message Actions": "Message Actions", - "View in room": "View in room", - "Copy link to thread": "Copy link to thread", - "This message cannot be decrypted": "This message cannot be decrypted", - "Encrypted by an unverified session": "Encrypted by an unverified session", - "Unencrypted": "Unencrypted", - "Encrypted by a deleted session": "Encrypted by a deleted session", - "The authenticity of this encrypted message can't be guaranteed on this device.": "The authenticity of this encrypted message can't be guaranteed on this device.", - "Sending your message...": "Sending your message...", - "Encrypting your message...": "Encrypting your message...", - "Your message was sent": "Your message was sent", - "Failed to send": "Failed to send", - "You don't have permission to view messages from before you were invited.": "You don't have permission to view messages from before you were invited.", - "You don't have permission to view messages from before you joined.": "You don't have permission to view messages from before you joined.", - "Encrypted messages before this point are unavailable.": "Encrypted messages before this point are unavailable.", - "You can't see earlier messages": "You can't see earlier messages", - "Scroll to most recent messages": "Scroll to most recent messages", - "Show %(count)s other previews|other": "Show %(count)s other previews", - "Show %(count)s other previews|one": "Show %(count)s other preview", - "Close preview": "Close preview", - "and %(count)s others...|other": "and %(count)s others...", - "and %(count)s others...|one": "and one other...", - "Invite to this room": "Invite to this room", - "Invite to this space": "Invite to this space", - "Invited": "Invited", - "Filter room members": "Filter room members", - "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", - "Send message": "Send message", - "Reply to encrypted thread…": "Reply to encrypted thread…", - "Reply to thread…": "Reply to thread…", - "Send an encrypted reply…": "Send an encrypted reply…", - "Send a reply…": "Send a reply…", - "Send an encrypted message…": "Send an encrypted message…", - "Send a message…": "Send a message…", - "The conversation continues here.": "The conversation continues here.", - "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.", - "You do not have permission to post to this room": "You do not have permission to post to this room", - "%(seconds)ss left": "%(seconds)ss left", - "Send voice message": "Send voice message", - "Emoji": "Emoji", - "Hide stickers": "Hide stickers", - "Sticker": "Sticker", - "Voice Message": "Voice Message", - "You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.", - "Poll": "Poll", - "Bold": "Bold", - "Italics": "Italics", - "Strikethrough": "Strikethrough", - "Code block": "Code block", - "Quote": "Quote", - "Insert link": "Insert link", - "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Only the two of you are in this conversation, unless either of you invites anyone to join.", - "This is the beginning of your direct message history with .": "This is the beginning of your direct message history with .", - "Topic: %(topic)s (edit)": "Topic: %(topic)s (edit)", - "Topic: %(topic)s ": "Topic: %(topic)s ", - "Add a topic to help people know what it is about.": "Add a topic to help people know what it is about.", - "You created this room.": "You created this room.", - "%(displayName)s created this room.": "%(displayName)s created this room.", - "Invite to just this room": "Invite to just this room", - "Add a photo, so people can easily spot your room.": "Add a photo, so people can easily spot your room.", - "This is the start of .": "This is the start of .", - "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.": "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.", - "Enable encryption in settings.": "Enable encryption in settings.", - "End-to-end encryption isn't enabled": "End-to-end encryption isn't enabled", - "Message didn't send. Click for info.": "Message didn't send. Click for info.", - "Unpin": "Unpin", - "View message": "View message", - "%(duration)ss": "%(duration)ss", - "%(duration)sm": "%(duration)sm", - "%(duration)sh": "%(duration)sh", - "%(duration)sd": "%(duration)sd", - "Busy": "Busy", - "Online for %(duration)s": "Online for %(duration)s", - "Idle for %(duration)s": "Idle for %(duration)s", - "Offline for %(duration)s": "Offline for %(duration)s", - "Unknown for %(duration)s": "Unknown for %(duration)s", - "Online": "Online", - "Idle": "Idle", - "Offline": "Offline", - "Unknown": "Unknown", - "Preview": "Preview", - "View": "View", - "%(members)s and more": "%(members)s and more", - "%(members)s and %(last)s": "%(members)s and %(last)s", - "Seen by %(count)s people|other": "Seen by %(count)s people", - "Seen by %(count)s people|one": "Seen by %(count)s person", - "Read receipts": "Read receipts", - "Recently viewed": "Recently viewed", - "Replying": "Replying", - "Room %(name)s": "Room %(name)s", - "Recently visited rooms": "Recently visited rooms", - "No recently visited rooms": "No recently visited rooms", - "(~%(count)s results)|other": "(~%(count)s results)", - "(~%(count)s results)|one": "(~%(count)s result)", - "Join Room": "Join Room", - "Room options": "Room options", - "Forget room": "Forget room", - "Hide Widgets": "Hide Widgets", - "Show Widgets": "Show Widgets", - "Search": "Search", - "Invite": "Invite", - "Video room": "Video room", - "Public space": "Public space", - "Public room": "Public room", - "Private space": "Private space", - "Private room": "Private room", - "%(count)s members|other": "%(count)s members", - "%(count)s members|one": "%(count)s member", - "Start new chat": "Start new chat", - "Invite to space": "Invite to space", - "You do not have permissions to invite people to this space": "You do not have permissions to invite people to this space", - "Add people": "Add people", - "Start chat": "Start chat", - "Explore rooms": "Explore rooms", - "New room": "New room", - "You do not have permissions to create new rooms in this space": "You do not have permissions to create new rooms in this space", - "New video room": "New video room", - "Add existing room": "Add existing room", - "You do not have permissions to add rooms to this space": "You do not have permissions to add rooms to this space", - "Explore public rooms": "Explore public rooms", - "Add room": "Add room", - "Invites": "Invites", - "Low priority": "Low priority", - "System Alerts": "System Alerts", - "Historical": "Historical", - "Suggested Rooms": "Suggested Rooms", - "Empty room": "Empty room", - "Can't see what you're looking for?": "Can't see what you're looking for?", - "Start a new chat": "Start a new chat", - "Explore all public rooms": "Explore all public rooms", - "%(count)s results|other": "%(count)s results", - "%(count)s results|one": "%(count)s result", - "Add space": "Add space", - "You do not have permissions to add spaces to this space": "You do not have permissions to add spaces to this space", - "Join public room": "Join public room", - "Currently joining %(count)s rooms|other": "Currently joining %(count)s rooms", - "Currently joining %(count)s rooms|one": "Currently joining %(count)s room", - "Currently removing messages in %(count)s rooms|other": "Currently removing messages in %(count)s rooms", - "Currently removing messages in %(count)s rooms|one": "Currently removing messages in %(count)s room", - "%(spaceName)s menu": "%(spaceName)s menu", - "Home options": "Home options", - "Joining space …": "Joining space …", - "Joining room …": "Joining room …", - "Joining …": "Joining …", - "Loading …": "Loading …", - "Rejecting invite …": "Rejecting invite …", - "Join the conversation with an account": "Join the conversation with an account", - "Sign Up": "Sign Up", - "Loading preview": "Loading preview", - "You were removed from %(roomName)s by %(memberName)s": "You were removed from %(roomName)s by %(memberName)s", - "You were removed by %(memberName)s": "You were removed by %(memberName)s", - "Reason: %(reason)s": "Reason: %(reason)s", - "Forget this space": "Forget this space", - "Forget this room": "Forget this room", - "Re-join": "Re-join", - "You were banned from %(roomName)s by %(memberName)s": "You were banned from %(roomName)s by %(memberName)s", - "You were banned by %(memberName)s": "You were banned by %(memberName)s", - "Something went wrong with your invite to %(roomName)s": "Something went wrong with your invite to %(roomName)s", - "Something went wrong with your invite.": "Something went wrong with your invite.", - "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to the person who invited you.": "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to the person who invited you.", - "unknown error code": "unknown error code", - "You can only join it with a working invite.": "You can only join it with a working invite.", - "Try to join anyway": "Try to join anyway", - "You can still join here.": "You can still join here.", - "Join the discussion": "Join the discussion", - "This invite to %(roomName)s was sent to %(email)s which is not associated with your account": "This invite to %(roomName)s was sent to %(email)s which is not associated with your account", - "This invite was sent to %(email)s which is not associated with your account": "This invite was sent to %(email)s which is not associated with your account", - "Link this email with your account in Settings to receive invites directly in %(brand)s.": "Link this email with your account in Settings to receive invites directly in %(brand)s.", - "This invite to %(roomName)s was sent to %(email)s": "This invite to %(roomName)s was sent to %(email)s", - "This invite was sent to %(email)s": "This invite was sent to %(email)s", - "Use an identity server in Settings to receive invites directly in %(brand)s.": "Use an identity server in Settings to receive invites directly in %(brand)s.", - "Share this email in Settings to receive invites directly in %(brand)s.": "Share this email in Settings to receive invites directly in %(brand)s.", - "Do you want to chat with %(user)s?": "Do you want to chat with %(user)s?", - " wants to chat": " wants to chat", - "Start chatting": "Start chatting", - "Do you want to join %(roomName)s?": "Do you want to join %(roomName)s?", - " invited you": " invited you", - "Reject": "Reject", - "Reject & Ignore user": "Reject & Ignore user", - "You're previewing %(roomName)s. Want to join it?": "You're previewing %(roomName)s. Want to join it?", - "%(roomName)s can't be previewed. Do you want to join it?": "%(roomName)s can't be previewed. Do you want to join it?", - "There's no preview, would you like to join?": "There's no preview, would you like to join?", - "%(roomName)s does not exist.": "%(roomName)s does not exist.", - "This room or space does not exist.": "This room or space does not exist.", - "Are you sure you're at the right place?": "Are you sure you're at the right place?", - "%(roomName)s is not accessible at this time.": "%(roomName)s is not accessible at this time.", - "This room or space is not accessible at this time.": "This room or space is not accessible at this time.", - "Try again later, or ask a room or space admin to check if you have access.": "Try again later, or ask a room or space admin to check if you have access.", - "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.": "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.", - "Leave": "Leave", - " invites you": " invites you", - "To view %(roomName)s, you need an invite": "To view %(roomName)s, you need an invite", - "To view, please enable video rooms in Labs first": "To view, please enable video rooms in Labs first", - "To join, please enable video rooms in Labs first": "To join, please enable video rooms in Labs first", - "Show Labs settings": "Show Labs settings", - "Appearance": "Appearance", - "Show rooms with unread messages first": "Show rooms with unread messages first", - "Show previews of messages": "Show previews of messages", - "Sort by": "Sort by", - "Activity": "Activity", - "A-Z": "A-Z", - "List options": "List options", - "Show %(count)s more|other": "Show %(count)s more", - "Show %(count)s more|one": "Show %(count)s more", - "Show less": "Show less", - "Use default": "Use default", - "Mentions & Keywords": "Mentions & Keywords", - "Notification options": "Notification options", - "Forget Room": "Forget Room", - "Favourited": "Favourited", - "Favourite": "Favourite", - "Low Priority": "Low Priority", - "Copy room link": "Copy room link", - "Video": "Video", - "Joining…": "Joining…", - "Joined": "Joined", - "%(count)s participants|other": "%(count)s participants", - "%(count)s participants|one": "1 participant", - "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", - "%(count)s unread messages including mentions.|one": "1 unread mention.", - "%(count)s unread messages.|other": "%(count)s unread messages.", - "%(count)s unread messages.|one": "1 unread message.", - "Unread messages.": "Unread messages.", - "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.", - "This room has already been upgraded.": "This room has already been upgraded.", - "This room is running room version , which this homeserver has marked as unstable.": "This room is running room version , which this homeserver has marked as unstable.", - "Only room administrators will see this warning": "Only room administrators will see this warning", - "This Room": "This Room", - "All Rooms": "All Rooms", - "Search…": "Search…", - "Failed to connect to integration manager": "Failed to connect to integration manager", - "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", - "Add some now": "Add some now", - "Stickerpack": "Stickerpack", - "Failed to revoke invite": "Failed to revoke invite", - "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.": "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.", - "Admin Tools": "Admin Tools", - "Revoke invite": "Revoke invite", - "Invited by %(sender)s": "Invited by %(sender)s", - "%(count)s reply|other": "%(count)s replies", - "%(count)s reply|one": "%(count)s reply", - "Open thread": "Open thread", - "Jump to first unread message.": "Jump to first unread message.", - "Mark all as read": "Mark all as read", - "Unable to access your microphone": "Unable to access your microphone", - "We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.", - "No microphone found": "No microphone found", - "We didn't find a microphone on your device. Please check your settings and try again.": "We didn't find a microphone on your device. Please check your settings and try again.", - "Stop recording": "Stop recording", - "Error updating main address": "Error updating main address", - "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.", - "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.", - "Error creating address": "Error creating address", - "There was an error creating that address. It may not be allowed by the server or a temporary failure occurred.": "There was an error creating that address. It may not be allowed by the server or a temporary failure occurred.", - "You don't have permission to delete the address.": "You don't have permission to delete the address.", - "There was an error removing that address. It may no longer exist or a temporary error occurred.": "There was an error removing that address. It may no longer exist or a temporary error occurred.", - "Error removing address": "Error removing address", - "Main address": "Main address", - "not specified": "not specified", - "This space has no local addresses": "This space has no local addresses", - "This room has no local addresses": "This room has no local addresses", - "Local address": "Local address", - "Published Addresses": "Published Addresses", - "Published addresses can be used by anyone on any server to join your space.": "Published addresses can be used by anyone on any server to join your space.", - "Published addresses can be used by anyone on any server to join your room.": "Published addresses can be used by anyone on any server to join your room.", - "To publish an address, it needs to be set as a local address first.": "To publish an address, it needs to be set as a local address first.", - "Other published addresses:": "Other published addresses:", - "No other published addresses yet, add one below": "No other published addresses yet, add one below", - "New published address (e.g. #alias:server)": "New published address (e.g. #alias:server)", - "Local Addresses": "Local Addresses", - "Set addresses for this space so users can find this space through your homeserver (%(localDomain)s)": "Set addresses for this space so users can find this space through your homeserver (%(localDomain)s)", - "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)", - "Show more": "Show more", - "Room Name": "Room Name", - "Room Topic": "Room Topic", - "Room avatar": "Room avatar", - "Publish this room to the public in %(domain)s's room directory?": "Publish this room to the public in %(domain)s's room directory?", - "You have enabled URL previews by default.": "You have enabled URL previews by default.", - "You have disabled URL previews by default.": "You have disabled URL previews by default.", - "URL previews are enabled by default for participants in this room.": "URL previews are enabled by default for participants in this room.", - "URL previews are disabled by default for participants in this room.": "URL previews are disabled by default for participants in this room.", - "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.", - "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.", - "URL Previews": "URL Previews", - "Back": "Back", - "To proceed, please accept the verification request on your other device.": "To proceed, please accept the verification request on your other device.", - "Waiting for %(displayName)s to accept…": "Waiting for %(displayName)s to accept…", - "Accepting…": "Accepting…", - "Start Verification": "Start Verification", - "Messages in this room are end-to-end encrypted.": "Messages in this room are end-to-end encrypted.", - "Your messages are secured and only you and the recipient have the unique keys to unlock them.": "Your messages are secured and only you and the recipient have the unique keys to unlock them.", - "Messages in this room are not end-to-end encrypted.": "Messages in this room are not end-to-end encrypted.", - "In encrypted rooms, your messages are secured and only you and the recipient have the unique keys to unlock them.": "In encrypted rooms, your messages are secured and only you and the recipient have the unique keys to unlock them.", - "Verify User": "Verify User", - "For extra security, verify this user by checking a one-time code on both of your devices.": "For extra security, verify this user by checking a one-time code on both of your devices.", - "Your messages are not secure": "Your messages are not secure", - "One of the following may be compromised:": "One of the following may be compromised:", - "Your homeserver": "Your homeserver", - "The homeserver the user you're verifying is connected to": "The homeserver the user you're verifying is connected to", - "Yours, or the other users' internet connection": "Yours, or the other users' internet connection", - "Yours, or the other users' session": "Yours, or the other users' session", - "Nothing pinned, yet": "Nothing pinned, yet", - "If you have permissions, open the menu on any message and select Pin to stick them here.": "If you have permissions, open the menu on any message and select Pin to stick them here.", - "Pinned messages": "Pinned messages", - "Chat": "Chat", - "Room Info": "Room Info", - "You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets", - "Maximise": "Maximise", - "Unpin this widget to view it in this panel": "Unpin this widget to view it in this panel", - "Close this widget to view it in this panel": "Close this widget to view it in this panel", - "Set my room layout for everyone": "Set my room layout for everyone", - "Edit widgets, bridges & bots": "Edit widgets, bridges & bots", - "Add widgets, bridges & bots": "Add widgets, bridges & bots", - "Not encrypted": "Not encrypted", - "About": "About", - "Files": "Files", - "Pinned": "Pinned", - "Export chat": "Export chat", - "Share room": "Share room", - "Room settings": "Room settings", - "Trusted": "Trusted", - "Not trusted": "Not trusted", - "Unable to load session list": "Unable to load session list", - "%(count)s verified sessions|other": "%(count)s verified sessions", - "%(count)s verified sessions|one": "1 verified session", - "Hide verified sessions": "Hide verified sessions", - "%(count)s sessions|other": "%(count)s sessions", - "%(count)s sessions|one": "%(count)s session", - "Hide sessions": "Hide sessions", - "Message": "Message", - "Jump to read receipt": "Jump to read receipt", - "Mention": "Mention", - "Share Link to User": "Share Link to User", - "Demote yourself?": "Demote yourself?", - "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.", - "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.", - "Demote": "Demote", - "Disinvite from space": "Disinvite from space", - "Remove from space": "Remove from space", - "Disinvite from room": "Disinvite from room", - "Remove from room": "Remove from room", - "Disinvite from %(roomName)s": "Disinvite from %(roomName)s", - "Remove from %(roomName)s": "Remove from %(roomName)s", - "Remove them from everything I'm able to": "Remove them from everything I'm able to", - "Remove them from specific things I'm able to": "Remove them from specific things I'm able to", - "They'll still be able to access whatever you're not an admin of.": "They'll still be able to access whatever you're not an admin of.", - "Failed to remove user": "Failed to remove user", - "Remove recent messages": "Remove recent messages", - "Unban from space": "Unban from space", - "Ban from space": "Ban from space", - "Unban from room": "Unban from room", - "Ban from room": "Ban from room", - "Unban from %(roomName)s": "Unban from %(roomName)s", - "Ban from %(roomName)s": "Ban from %(roomName)s", - "Unban them from everything I'm able to": "Unban them from everything I'm able to", - "Ban them from everything I'm able to": "Ban them from everything I'm able to", - "Unban them from specific things I'm able to": "Unban them from specific things I'm able to", - "Ban them from specific things I'm able to": "Ban them from specific things I'm able to", - "They won't be able to access whatever you're not an admin of.": "They won't be able to access whatever you're not an admin of.", - "Failed to ban user": "Failed to ban user", - "Failed to mute user": "Failed to mute user", - "Unmute": "Unmute", - "Mute": "Mute", - "Failed to change power level": "Failed to change power level", - "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.": "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.", - "Are you sure?": "Are you sure?", - "Deactivate user?": "Deactivate user?", - "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?", - "Deactivate user": "Deactivate user", - "Failed to deactivate user": "Failed to deactivate user", - "Role in ": "Role in ", - "This client does not support end-to-end encryption.": "This client does not support end-to-end encryption.", - "Edit devices": "Edit devices", - "Security": "Security", - "The device you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "The device you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.", - "Scan this unique code": "Scan this unique code", - "Compare unique emoji": "Compare unique emoji", - "Compare a unique set of emoji if you don't have a camera on either device": "Compare a unique set of emoji if you don't have a camera on either device", - "Start": "Start", - "or": "or", - "Verify this device by completing one of the following:": "Verify this device by completing one of the following:", - "Verify by scanning": "Verify by scanning", - "Ask %(displayName)s to scan your code:": "Ask %(displayName)s to scan your code:", - "If you can't scan the code above, verify by comparing unique emoji.": "If you can't scan the code above, verify by comparing unique emoji.", - "Verify by comparing unique emoji.": "Verify by comparing unique emoji.", - "Verify by emoji": "Verify by emoji", - "Almost there! Is your other device showing the same shield?": "Almost there! Is your other device showing the same shield?", - "Almost there! Is %(displayName)s showing the same shield?": "Almost there! Is %(displayName)s showing the same shield?", - "Verify all users in a room to ensure it's secure.": "Verify all users in a room to ensure it's secure.", - "In encrypted rooms, verify all users to ensure it's secure.": "In encrypted rooms, verify all users to ensure it's secure.", - "You've successfully verified your device!": "You've successfully verified your device!", - "You've successfully verified %(deviceName)s (%(deviceId)s)!": "You've successfully verified %(deviceName)s (%(deviceId)s)!", - "You've successfully verified %(displayName)s!": "You've successfully verified %(displayName)s!", - "Got it": "Got it", - "Start verification again from the notification.": "Start verification again from the notification.", - "Start verification again from their profile.": "Start verification again from their profile.", - "Verification timed out.": "Verification timed out.", - "You cancelled verification on your other device.": "You cancelled verification on your other device.", - "%(displayName)s cancelled verification.": "%(displayName)s cancelled verification.", - "You cancelled verification.": "You cancelled verification.", - "Verification cancelled": "Verification cancelled", - "Call declined": "Call declined", - "Call back": "Call back", - "No answer": "No answer", - "Could not connect media": "Could not connect media", - "Connection failed": "Connection failed", - "Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone", - "An unknown error occurred": "An unknown error occurred", - "Unknown failure: %(reason)s": "Unknown failure: %(reason)s", - "Retry": "Retry", - "Missed call": "Missed call", - "The call is in an unknown state!": "The call is in an unknown state!", - "Sunday": "Sunday", - "Monday": "Monday", - "Tuesday": "Tuesday", - "Wednesday": "Wednesday", - "Thursday": "Thursday", - "Friday": "Friday", - "Saturday": "Saturday", - "Today": "Today", - "Yesterday": "Yesterday", - "Unable to find event at that date. (%(code)s)": "Unable to find event at that date. (%(code)s)", - "Last week": "Last week", - "Last month": "Last month", - "The beginning of the room": "The beginning of the room", - "Jump to date": "Jump to date", - "Downloading": "Downloading", - "Decrypting": "Decrypting", - "Download": "Download", - "View Source": "View Source", - "Some encryption parameters have been changed.": "Some encryption parameters have been changed.", - "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.", - "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.", - "Encryption enabled": "Encryption enabled", - "Ignored attempt to disable encryption": "Ignored attempt to disable encryption", - "Encryption not enabled": "Encryption not enabled", - "The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.", - "Message pending moderation: %(reason)s": "Message pending moderation: %(reason)s", - "Message pending moderation": "Message pending moderation", - "Pick a date to jump to": "Pick a date to jump to", - "Go": "Go", - "Error processing audio message": "Error processing audio message", - "View live location": "View live location", - "React": "React", - "Can't create a thread from an event with an existing relation": "Can't create a thread from an event with an existing relation", - "Beta feature": "Beta feature", - "Beta feature. Click to learn more.": "Beta feature. Click to learn more.", - "Edit": "Edit", - "Reply": "Reply", - "Collapse quotes": "Collapse quotes", - "Expand quotes": "Expand quotes", - "Click": "Click", - "Download %(text)s": "Download %(text)s", - "Error decrypting attachment": "Error decrypting attachment", - "Decrypt %(text)s": "Decrypt %(text)s", - "Invalid file%(extra)s": "Invalid file%(extra)s", - "Image": "Image", - "Error decrypting image": "Error decrypting image", - "Show image": "Show image", - "Join the conference at the top of this room": "Join the conference at the top of this room", - "Join the conference from the room information card on the right": "Join the conference from the room information card on the right", - "Video conference ended by %(senderName)s": "Video conference ended by %(senderName)s", - "Video conference updated by %(senderName)s": "Video conference updated by %(senderName)s", - "Video conference started by %(senderName)s": "Video conference started by %(senderName)s", - "You have ignored this user, so their message is hidden. Show anyways.": "You have ignored this user, so their message is hidden. Show anyways.", - "You verified %(name)s": "You verified %(name)s", - "You cancelled verifying %(name)s": "You cancelled verifying %(name)s", - "%(name)s cancelled verifying": "%(name)s cancelled verifying", - "You accepted": "You accepted", - "%(name)s accepted": "%(name)s accepted", - "You declined": "You declined", - "You cancelled": "You cancelled", - "%(name)s declined": "%(name)s declined", - "%(name)s cancelled": "%(name)s cancelled", - "Accepting …": "Accepting …", - "Declining …": "Declining …", - "%(name)s wants to verify": "%(name)s wants to verify", - "You sent a verification request": "You sent a verification request", - "Expand map": "Expand map", - "Unable to load map": "Unable to load map", - "Shared their location: ": "Shared their location: ", - "Shared a location: ": "Shared a location: ", - "Can't edit poll": "Can't edit poll", - "Sorry, you can't edit a poll after votes have been cast.": "Sorry, you can't edit a poll after votes have been cast.", - "Vote not registered": "Vote not registered", - "Sorry, your vote was not registered. Please try again.": "Sorry, your vote was not registered. Please try again.", - "Final result based on %(count)s votes|other": "Final result based on %(count)s votes", - "Final result based on %(count)s votes|one": "Final result based on %(count)s vote", - "Results will be visible when the poll is ended": "Results will be visible when the poll is ended", - "No votes cast": "No votes cast", - "%(count)s votes cast. Vote to see the results|other": "%(count)s votes cast. Vote to see the results", - "%(count)s votes cast. Vote to see the results|one": "%(count)s vote cast. Vote to see the results", - "Based on %(count)s votes|other": "Based on %(count)s votes", - "Based on %(count)s votes|one": "Based on %(count)s vote", - "edited": "edited", - "%(count)s votes|other": "%(count)s votes", - "%(count)s votes|one": "%(count)s vote", - "Error decrypting video": "Error decrypting video", - "Error processing voice message": "Error processing voice message", - "Add reaction": "Add reaction", - "Show all": "Show all", - "Reactions": "Reactions", - "%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s", - "reacted with %(shortName)s": "reacted with %(shortName)s", - "Message deleted on %(date)s": "Message deleted on %(date)s", - "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", - "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.", - "%(senderDisplayName)s changed the room avatar to ": "%(senderDisplayName)s changed the room avatar to ", - "Click here to see older messages.": "Click here to see older messages.", - "This room is a continuation of another conversation.": "This room is a continuation of another conversation.", - "Add an Integration": "Add an Integration", - "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?", - "Edited at %(date)s": "Edited at %(date)s", - "Click to view edits": "Click to view edits", - "Edited at %(date)s. Click to view edits.": "Edited at %(date)s. Click to view edits.", - "Submit logs": "Submit logs", - "Can't load this message": "Can't load this message", - "toggle event": "toggle event", - "Live location sharing": "Live location sharing", - "Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.": "Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.", - "Enable live location sharing": "Enable live location sharing", - "Share for %(duration)s": "Share for %(duration)s", - "Location": "Location", - "Could not fetch location": "Could not fetch location", - "Click to move the pin": "Click to move the pin", - "Click to drop a pin": "Click to drop a pin", - "Share location": "Share location", - "%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.": "%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.", - "Failed to fetch your location. Please try again later.": "Failed to fetch your location. Please try again later.", - "Timed out trying to fetch your location. Please try again later.": "Timed out trying to fetch your location. Please try again later.", - "Unknown error fetching location. Please try again later.": "Unknown error fetching location. Please try again later.", - "We couldn't send your location": "We couldn't send your location", - "%(brand)s could not send your location. Please try again later.": "%(brand)s could not send your location. Please try again later.", - "%(displayName)s's live location": "%(displayName)s's live location", - "My current location": "My current location", - "My live location": "My live location", - "Drop a Pin": "Drop a Pin", - "What location type do you want to share?": "What location type do you want to share?", - "Zoom in": "Zoom in", - "Zoom out": "Zoom out", - "Frequently Used": "Frequently Used", - "Smileys & People": "Smileys & People", - "Animals & Nature": "Animals & Nature", - "Food & Drink": "Food & Drink", - "Activities": "Activities", - "Travel & Places": "Travel & Places", - "Objects": "Objects", - "Symbols": "Symbols", - "Flags": "Flags", - "Categories": "Categories", - "Quick Reactions": "Quick Reactions", - "Cancel search": "Cancel search", - "Unknown Address": "Unknown Address", - "Any of the following data may be shared:": "Any of the following data may be shared:", - "Your display name": "Your display name", - "Your avatar URL": "Your avatar URL", - "Your user ID": "Your user ID", - "Your theme": "Your theme", - "%(brand)s URL": "%(brand)s URL", - "Room ID": "Room ID", - "Widget ID": "Widget ID", - "Using this widget may share data with %(widgetDomain)s & your integration manager.": "Using this widget may share data with %(widgetDomain)s & your integration manager.", - "Using this widget may share data with %(widgetDomain)s.": "Using this widget may share data with %(widgetDomain)s.", - "Widgets do not use message encryption.": "Widgets do not use message encryption.", - "Widget added by": "Widget added by", - "This widget may use cookies.": "This widget may use cookies.", - "Loading...": "Loading...", - "Error loading Widget": "Error loading Widget", - "Error - Mixed content": "Error - Mixed content", - "Popout widget": "Popout widget", - "Copy": "Copy", - "Share entire screen": "Share entire screen", - "Application window": "Application window", - "Share content": "Share content", - "Backspace": "Backspace", - "Please create a new issue on GitHub so that we can investigate this bug.": "Please create a new issue on GitHub so that we can investigate this bug.", - "Something went wrong!": "Something went wrong!", - "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", - "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times", - "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)sjoined", - "%(oneUser)sjoined %(count)s times|other": "%(oneUser)sjoined %(count)s times", - "%(oneUser)sjoined %(count)s times|one": "%(oneUser)sjoined", - "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)sleft %(count)s times", - "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)sleft", - "%(oneUser)sleft %(count)s times|other": "%(oneUser)sleft %(count)s times", - "%(oneUser)sleft %(count)s times|one": "%(oneUser)sleft", - "%(severalUsers)sjoined and left %(count)s times|other": "%(severalUsers)sjoined and left %(count)s times", - "%(severalUsers)sjoined and left %(count)s times|one": "%(severalUsers)sjoined and left", - "%(oneUser)sjoined and left %(count)s times|other": "%(oneUser)sjoined and left %(count)s times", - "%(oneUser)sjoined and left %(count)s times|one": "%(oneUser)sjoined and left", - "%(severalUsers)sleft and rejoined %(count)s times|other": "%(severalUsers)sleft and rejoined %(count)s times", - "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)sleft and rejoined", - "%(oneUser)sleft and rejoined %(count)s times|other": "%(oneUser)sleft and rejoined %(count)s times", - "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)sleft and rejoined", - "%(severalUsers)srejected their invitations %(count)s times|other": "%(severalUsers)srejected their invitations %(count)s times", - "%(severalUsers)srejected their invitations %(count)s times|one": "%(severalUsers)srejected their invitations", - "%(oneUser)srejected their invitation %(count)s times|other": "%(oneUser)srejected their invitation %(count)s times", - "%(oneUser)srejected their invitation %(count)s times|one": "%(oneUser)srejected their invitation", - "%(severalUsers)shad their invitations withdrawn %(count)s times|other": "%(severalUsers)shad their invitations withdrawn %(count)s times", - "%(severalUsers)shad their invitations withdrawn %(count)s times|one": "%(severalUsers)shad their invitations withdrawn", - "%(oneUser)shad their invitation withdrawn %(count)s times|other": "%(oneUser)shad their invitation withdrawn %(count)s times", - "%(oneUser)shad their invitation withdrawn %(count)s times|one": "%(oneUser)shad their invitation withdrawn", - "were invited %(count)s times|other": "were invited %(count)s times", - "were invited %(count)s times|one": "were invited", - "was invited %(count)s times|other": "was invited %(count)s times", - "was invited %(count)s times|one": "was invited", - "were banned %(count)s times|other": "were banned %(count)s times", - "were banned %(count)s times|one": "were banned", - "was banned %(count)s times|other": "was banned %(count)s times", - "was banned %(count)s times|one": "was banned", - "were unbanned %(count)s times|other": "were unbanned %(count)s times", - "were unbanned %(count)s times|one": "were unbanned", - "was unbanned %(count)s times|other": "was unbanned %(count)s times", - "was unbanned %(count)s times|one": "was unbanned", - "were removed %(count)s times|other": "were removed %(count)s times", - "were removed %(count)s times|one": "were removed", - "was removed %(count)s times|other": "was removed %(count)s times", - "was removed %(count)s times|one": "was removed", - "%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)schanged their name %(count)s times", - "%(severalUsers)schanged their name %(count)s times|one": "%(severalUsers)schanged their name", - "%(oneUser)schanged their name %(count)s times|other": "%(oneUser)schanged their name %(count)s times", - "%(oneUser)schanged their name %(count)s times|one": "%(oneUser)schanged their name", - "%(severalUsers)schanged their avatar %(count)s times|other": "%(severalUsers)schanged their avatar %(count)s times", - "%(severalUsers)schanged their avatar %(count)s times|one": "%(severalUsers)schanged their avatar", - "%(oneUser)schanged their avatar %(count)s times|other": "%(oneUser)schanged their avatar %(count)s times", - "%(oneUser)schanged their avatar %(count)s times|one": "%(oneUser)schanged their avatar", - "%(severalUsers)smade no changes %(count)s times|other": "%(severalUsers)smade no changes %(count)s times", - "%(severalUsers)smade no changes %(count)s times|one": "%(severalUsers)smade no changes", - "%(oneUser)smade no changes %(count)s times|other": "%(oneUser)smade no changes %(count)s times", - "%(oneUser)smade no changes %(count)s times|one": "%(oneUser)smade no changes", - "%(severalUsers)schanged the server ACLs %(count)s times|other": "%(severalUsers)schanged the server ACLs %(count)s times", - "%(severalUsers)schanged the server ACLs %(count)s times|one": "%(severalUsers)schanged the server ACLs", - "%(oneUser)schanged the server ACLs %(count)s times|other": "%(oneUser)schanged the server ACLs %(count)s times", - "%(oneUser)schanged the server ACLs %(count)s times|one": "%(oneUser)schanged the server ACLs", - "%(severalUsers)schanged the pinned messages for the room %(count)s times|other": "%(severalUsers)schanged the pinned messages for the room %(count)s times", - "%(severalUsers)schanged the pinned messages for the room %(count)s times|one": "%(severalUsers)schanged the pinned messages for the room", - "%(oneUser)schanged the pinned messages for the room %(count)s times|other": "%(oneUser)schanged the pinned messages for the room %(count)s times", - "%(oneUser)schanged the pinned messages for the room %(count)s times|one": "%(oneUser)schanged the pinned messages for the room", - "%(severalUsers)sremoved a message %(count)s times|other": "%(severalUsers)sremoved %(count)s messages", - "%(severalUsers)sremoved a message %(count)s times|one": "%(severalUsers)sremoved a message", - "%(oneUser)sremoved a message %(count)s times|other": "%(oneUser)sremoved %(count)s messages", - "%(oneUser)sremoved a message %(count)s times|one": "%(oneUser)sremoved a message", - "%(severalUsers)ssent %(count)s hidden messages|other": "%(severalUsers)ssent %(count)s hidden messages", - "%(severalUsers)ssent %(count)s hidden messages|one": "%(severalUsers)ssent a hidden message", - "%(oneUser)ssent %(count)s hidden messages|other": "%(oneUser)ssent %(count)s hidden messages", - "%(oneUser)ssent %(count)s hidden messages|one": "%(oneUser)ssent a hidden message", - "collapse": "collapse", - "expand": "expand", - "Rotate Left": "Rotate Left", - "Rotate Right": "Rotate Right", - "Information": "Information", - "Language Dropdown": "Language Dropdown", - "Create poll": "Create poll", - "Create Poll": "Create Poll", - "Edit poll": "Edit poll", - "Done": "Done", - "Failed to post poll": "Failed to post poll", - "Sorry, the poll you tried to create was not posted.": "Sorry, the poll you tried to create was not posted.", - "Poll type": "Poll type", - "Open poll": "Open poll", - "Closed poll": "Closed poll", - "What is your poll question or topic?": "What is your poll question or topic?", - "Question or topic": "Question or topic", - "Write something...": "Write something...", - "Create options": "Create options", - "Option %(number)s": "Option %(number)s", - "Write an option": "Write an option", - "Add option": "Add option", - "Voters see results as soon as they have voted": "Voters see results as soon as they have voted", - "Results are only revealed when you end the poll": "Results are only revealed when you end the poll", - "Power level": "Power level", - "Custom level": "Custom level", - "QR Code": "QR Code", - "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.", - "In reply to ": "In reply to ", - "In reply to this message": "In reply to this message", - "Room address": "Room address", - "e.g. my-room": "e.g. my-room", - "Missing domain separator e.g. (:domain.org)": "Missing domain separator e.g. (:domain.org)", - "Missing room name or separator e.g. (my-room:domain.org)": "Missing room name or separator e.g. (my-room:domain.org)", - "Some characters not allowed": "Some characters not allowed", - "Please provide an address": "Please provide an address", - "This address does not point at this room": "This address does not point at this room", - "This address is available to use": "This address is available to use", - "This address is already in use": "This address is already in use", - "This address had invalid server or is already in use": "This address had invalid server or is already in use", - "View all %(count)s members|other": "View all %(count)s members", - "View all %(count)s members|one": "View 1 member", - "Including you, %(commaSeparatedMembers)s": "Including you, %(commaSeparatedMembers)s", - "Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", - "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", - "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", - "Edit topic": "Edit topic", - "Click to read topic": "Click to read topic", - "Message search initialisation failed, check your settings for more information": "Message search initialisation failed, check your settings for more information", - "Use the Desktop app to see all encrypted files": "Use the Desktop app to see all encrypted files", - "Use the Desktop app to search encrypted messages": "Use the Desktop app to search encrypted messages", - "This version of %(brand)s does not support viewing some encrypted files": "This version of %(brand)s does not support viewing some encrypted files", - "This version of %(brand)s does not support searching encrypted messages": "This version of %(brand)s does not support searching encrypted messages", - "Server Options": "Server Options", - "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.", - "Join millions for free on the largest public server": "Join millions for free on the largest public server", - "Homeserver": "Homeserver", - "Continue with %(provider)s": "Continue with %(provider)s", - "Sign in with single sign-on": "Sign in with single sign-on", - "And %(count)s more...|other": "And %(count)s more...", - "Enter a server name": "Enter a server name", - "Looks good": "Looks good", - "You are not allowed to view this server's rooms list": "You are not allowed to view this server's rooms list", - "Can't find this server or its room list": "Can't find this server or its room list", - "Your server": "Your server", - "Are you sure you want to remove %(serverName)s": "Are you sure you want to remove %(serverName)s", - "Remove server": "Remove server", - "Matrix": "Matrix", - "Add a new server": "Add a new server", - "Enter the name of a new server you want to explore.": "Enter the name of a new server you want to explore.", - "Server name": "Server name", - "Add a new server...": "Add a new server...", - "%(networkName)s rooms": "%(networkName)s rooms", - "Matrix rooms": "Matrix rooms", - "Add existing space": "Add existing space", - "Want to add a new space instead?": "Want to add a new space instead?", - "Create a new space": "Create a new space", - "Search for spaces": "Search for spaces", - "Not all selected were added": "Not all selected were added", - "Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)", - "Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...", - "Direct Messages": "Direct Messages", - "Add existing rooms": "Add existing rooms", - "Want to add a new room instead?": "Want to add a new room instead?", - "Create a new room": "Create a new room", - "Search for rooms": "Search for rooms", - "Adding spaces has moved.": "Adding spaces has moved.", - "You can read all our terms here": "You can read all our terms here", - "Help us identify issues and improve %(analyticsOwner)s by sharing anonymous usage data. To understand how people use multiple devices, we'll generate a random identifier, shared by your devices.": "Help us identify issues and improve %(analyticsOwner)s by sharing anonymous usage data. To understand how people use multiple devices, we'll generate a random identifier, shared by your devices.", - "We don't record or profile any account data": "We don't record or profile any account data", - "We don't share information with third parties": "We don't share information with third parties", - "You can turn this off anytime in settings": "You can turn this off anytime in settings", - "The following users may not exist": "The following users may not exist", - "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?", - "Invite anyway and never warn me again": "Invite anyway and never warn me again", - "Invite anyway": "Invite anyway", - "Close dialog": "Close dialog", - "%(featureName)s Beta feedback": "%(featureName)s Beta feedback", - "To leave the beta, visit your settings.": "To leave the beta, visit your settings.", - "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.", - "Preparing to send logs": "Preparing to send logs", - "Logs sent": "Logs sent", - "Thank you!": "Thank you!", - "Failed to send logs: ": "Failed to send logs: ", - "Preparing to download logs": "Preparing to download logs", - "Reminder: Your browser is unsupported, so your experience may be unpredictable.": "Reminder: Your browser is unsupported, so your experience may be unpredictable.", - "Before submitting logs, you must create a GitHub issue to describe your problem.": "Before submitting logs, you must create a GitHub issue to describe your problem.", - "Download logs": "Download logs", - "GitHub issue": "GitHub issue", - "Notes": "Notes", - "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.", - "Send logs": "Send logs", - "No recent messages by %(user)s found": "No recent messages by %(user)s found", - "Try scrolling up in the timeline to see if there are any earlier ones.": "Try scrolling up in the timeline to see if there are any earlier ones.", - "Remove recent messages by %(user)s": "Remove recent messages by %(user)s", - "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|other": "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?", - "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|one": "You are about to remove %(count)s message by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?", - "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.": "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.", - "Preserve system messages": "Preserve system messages", - "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)": "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)", - "Remove %(count)s messages|other": "Remove %(count)s messages", - "Remove %(count)s messages|one": "Remove 1 message", - "Unable to load commit detail: %(msg)s": "Unable to load commit detail: %(msg)s", - "Unavailable": "Unavailable", - "Changelog": "Changelog", - "You cannot delete this message. (%(code)s)": "You cannot delete this message. (%(code)s)", - "Removing…": "Removing…", - "Confirm Removal": "Confirm Removal", - "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.", - "Reason (optional)": "Reason (optional)", - "Clear all data in this session?": "Clear all data in this session?", - "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.", - "Clear all data": "Clear all data", - "Please enter a name for the room": "Please enter a name for the room", - "Everyone in will be able to find and join this room.": "Everyone in will be able to find and join this room.", - "You can change this at any time from room settings.": "You can change this at any time from room settings.", - "Anyone will be able to find and join this room, not just members of .": "Anyone will be able to find and join this room, not just members of .", - "Anyone will be able to find and join this room.": "Anyone will be able to find and join this room.", - "Only people invited will be able to find and join this room.": "Only people invited will be able to find and join this room.", - "You can't disable this later. Bridges & most bots won't work yet.": "You can't disable this later. Bridges & most bots won't work yet.", - "Your server requires encryption to be enabled in private rooms.": "Your server requires encryption to be enabled in private rooms.", - "Enable end-to-end encryption": "Enable end-to-end encryption", - "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.", - "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.", - "Create a video room": "Create a video room", - "Create a room": "Create a room", - "Create a public room": "Create a public room", - "Create a private room": "Create a private room", - "Topic (optional)": "Topic (optional)", - "Room visibility": "Room visibility", - "Private room (invite only)": "Private room (invite only)", - "Visible to space members": "Visible to space members", - "Block anyone not part of %(serverName)s from ever joining this room.": "Block anyone not part of %(serverName)s from ever joining this room.", - "Create video room": "Create video room", - "Create room": "Create room", - "Anyone in will be able to find and join.": "Anyone in will be able to find and join.", - "Anyone will be able to find and join this space, not just members of .": "Anyone will be able to find and join this space, not just members of .", - "Only people invited will be able to find and join this space.": "Only people invited will be able to find and join this space.", - "Add a space to a space you manage.": "Add a space to a space you manage.", - "Space visibility": "Space visibility", - "Private space (invite only)": "Private space (invite only)", - "Want to add an existing space instead?": "Want to add an existing space instead?", - "Adding...": "Adding...", - "Sign out": "Sign out", - "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this", - "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.", - "Incompatible Database": "Incompatible Database", - "Continue With Encryption Disabled": "Continue With Encryption Disabled", - "Confirm your account deactivation by using Single Sign On to prove your identity.": "Confirm your account deactivation by using Single Sign On to prove your identity.", - "Are you sure you want to deactivate your account? This is irreversible.": "Are you sure you want to deactivate your account? This is irreversible.", - "Confirm account deactivation": "Confirm account deactivation", - "To continue, please enter your account password:": "To continue, please enter your account password:", - "There was a problem communicating with the server. Please try again.": "There was a problem communicating with the server. Please try again.", - "Server did not require any authentication": "Server did not require any authentication", - "Server did not return valid authentication information.": "Server did not return valid authentication information.", - "Confirm that you would like to deactivate your account. If you proceed:": "Confirm that you would like to deactivate your account. If you proceed:", - "You will not be able to reactivate your account": "You will not be able to reactivate your account", - "You will no longer be able to log in": "You will no longer be able to log in", - "No one will be able to reuse your username (MXID), including you: this username will remain unavailable": "No one will be able to reuse your username (MXID), including you: this username will remain unavailable", - "You will leave all rooms and DMs that you are in": "You will leave all rooms and DMs that you are in", - "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number": "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number", - "Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?": "Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?", - "Hide my messages from new joiners": "Hide my messages from new joiners", - "Room": "Room", - "Send custom timeline event": "Send custom timeline event", - "Explore room state": "Explore room state", - "Explore room account data": "Explore room account data", - "View servers in room": "View servers in room", - "Verification explorer": "Verification explorer", - "Active Widgets": "Active Widgets", - "Explore account data": "Explore account data", - "Settings explorer": "Settings explorer", - "Server info": "Server info", - "Toolbox": "Toolbox", - "Developer Tools": "Developer Tools", - "Room ID: %(roomId)s": "Room ID: %(roomId)s", - "The poll has ended. No votes were cast.": "The poll has ended. No votes were cast.", - "The poll has ended. Top answer: %(topAnswer)s": "The poll has ended. Top answer: %(topAnswer)s", - "Failed to end poll": "Failed to end poll", - "Sorry, the poll did not end. Please try again.": "Sorry, the poll did not end. Please try again.", - "End Poll": "End Poll", - "Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.": "Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.", - "An error has occurred.": "An error has occurred.", - "Processing...": "Processing...", - "Enter a number between %(min)s and %(max)s": "Enter a number between %(min)s and %(max)s", - "Size can only be a number between %(min)s MB and %(max)s MB": "Size can only be a number between %(min)s MB and %(max)s MB", - "Number of messages can only be a number between %(min)s and %(max)s": "Number of messages can only be a number between %(min)s and %(max)s", - "Number of messages": "Number of messages", - "MB": "MB", - "Export Cancelled": "Export Cancelled", - "The export was cancelled successfully": "The export was cancelled successfully", - "Export Successful": "Export Successful", - "Your export was successful. Find it in your Downloads folder.": "Your export was successful. Find it in your Downloads folder.", - "Are you sure you want to stop exporting your data? If you do, you'll need to start over.": "Are you sure you want to stop exporting your data? If you do, you'll need to start over.", - "Exporting your data": "Exporting your data", - "Export Chat": "Export Chat", - "Select from the options below to export chats from your timeline": "Select from the options below to export chats from your timeline", - "Format": "Format", - "Size Limit": "Size Limit", - "Include Attachments": "Include Attachments", - "Export": "Export", - "Feedback sent": "Feedback sent", - "Comment": "Comment", - "Your platform and username will be noted to help us use your feedback as much as we can.": "Your platform and username will be noted to help us use your feedback as much as we can.", - "Feedback": "Feedback", - "You may contact me if you want to follow up or to let me test out upcoming ideas": "You may contact me if you want to follow up or to let me test out upcoming ideas", - "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.", - "Report a bug": "Report a bug", - "Please view existing bugs on Github first. No match? Start a new one.": "Please view existing bugs on Github first. No match? Start a new one.", - "Send feedback": "Send feedback", - "You don't have permission to do this": "You don't have permission to do this", - "Sending": "Sending", - "Sent": "Sent", - "Open room": "Open room", - "Send": "Send", - "Forward message": "Forward message", - "Message preview": "Message preview", - "Search for rooms or people": "Search for rooms or people", - "Feedback sent! Thanks, we appreciate it!": "Feedback sent! Thanks, we appreciate it!", - "You may contact me if you have any follow up questions": "You may contact me if you have any follow up questions", - "Confirm abort of host creation": "Confirm abort of host creation", - "Are you sure you wish to abort creation of the host? The process cannot be continued.": "Are you sure you wish to abort creation of the host? The process cannot be continued.", - "Abort": "Abort", - "Failed to connect to your homeserver. Please close this dialog and try again.": "Failed to connect to your homeserver. Please close this dialog and try again.", - "Continuing temporarily allows the %(hostSignupBrand)s setup process to access your account to fetch verified email addresses. This data is not stored.": "Continuing temporarily allows the %(hostSignupBrand)s setup process to access your account to fetch verified email addresses. This data is not stored.", - "Learn more in our , and .": "Learn more in our , and .", - "Cookie Policy": "Cookie Policy", - "Privacy Policy": "Privacy Policy", - "Terms of Service": "Terms of Service", - "You should know": "You should know", - "%(hostSignupBrand)s Setup": "%(hostSignupBrand)s Setup", - "Maximise dialog": "Maximise dialog", - "Minimise dialog": "Minimise dialog", - "Upgrade to %(hostSignupBrand)s": "Upgrade to %(hostSignupBrand)s", - "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.", - "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.", - "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.", - "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.", - "Waiting for partner to confirm...": "Waiting for partner to confirm...", - "Incoming Verification Request": "Incoming Verification Request", - "Integrations are disabled": "Integrations are disabled", - "Enable 'Manage Integrations' in Settings to do this.": "Enable 'Manage Integrations' in Settings to do this.", - "Integrations not allowed": "Integrations not allowed", - "Your %(brand)s doesn't allow you to use an integration manager to do this. Please contact an admin.": "Your %(brand)s doesn't allow you to use an integration manager to do this. Please contact an admin.", - "To continue, use Single Sign On to prove your identity.": "To continue, use Single Sign On to prove your identity.", - "Confirm to continue": "Confirm to continue", - "Click the button below to confirm your identity.": "Click the button below to confirm your identity.", - "Invite by email": "Invite by email", - "We couldn't create your DM.": "We couldn't create your DM.", - "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 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", - "Suggestions": "Suggestions", - "Recently Direct Messaged": "Recently Direct Messaged", - "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.", - "Use an identity server to invite by email. Manage in Settings.": "Use an identity server to invite by email. Manage in Settings.", - "Start a conversation with someone using their name, email address or username (like ).": "Start a conversation with someone using their name, email address or username (like ).", - "Start a conversation with someone using their name or username (like ).": "Start a conversation with someone using their name or username (like ).", - "Some suggestions may be hidden for privacy.": "Some suggestions may be hidden for privacy.", - "If you can't see who you're looking for, send them your invite link below.": "If you can't see who you're looking for, send them your invite link below.", - "Or send invite link": "Or send invite link", - "Unnamed Space": "Unnamed Space", - "Invite to %(roomName)s": "Invite to %(roomName)s", - "Unnamed Room": "Unnamed Room", - "Invite someone using their name, email address, username (like ) or share this space.": "Invite someone using their name, email address, username (like ) or share this space.", - "Invite someone using their name, username (like ) or share this space.": "Invite someone using their name, username (like ) or share this space.", - "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.", - "Invited people will be able to read old messages.": "Invited people will be able to read old messages.", - "Transfer": "Transfer", - "Consult first": "Consult first", - "User Directory": "User Directory", - "Dial pad": "Dial pad", - "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", - "a key signature": "a key signature", - "%(brand)s encountered an error during upload of:": "%(brand)s encountered an error during upload of:", - "Upload completed": "Upload completed", - "Cancelled signature upload": "Cancelled signature upload", - "Unable to upload": "Unable to upload", - "Signature upload success": "Signature upload success", - "Signature upload failed": "Signature upload failed", - "You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.": "You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.", - "If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.", - "Incompatible local cache": "Incompatible local cache", - "Clear cache and resync": "Clear cache and resync", - "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!", - "Updating %(brand)s": "Updating %(brand)s", - "You won't be able to rejoin unless you are re-invited.": "You won't be able to rejoin unless you are re-invited.", - "You're the only admin of this space. Leaving it will mean no one has control over it.": "You're the only admin of this space. Leaving it will mean no one has control over it.", - "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.", - "Leave %(spaceName)s": "Leave %(spaceName)s", - "You are about to leave .": "You are about to leave .", - "Would you like to leave the rooms in this space?": "Would you like to leave the rooms in this space?", - "Don't leave any rooms": "Don't leave any rooms", - "Leave all rooms": "Leave all rooms", - "Leave some rooms": "Leave some rooms", - "Leave space": "Leave space", - "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.", - "Start using Key Backup": "Start using Key Backup", - "I don't want my encrypted messages": "I don't want my encrypted messages", - "Manually export keys": "Manually export keys", - "You'll lose access to your encrypted messages": "You'll lose access to your encrypted messages", - "Are you sure you want to sign out?": "Are you sure you want to sign out?", - "%(count)s rooms|other": "%(count)s rooms", - "%(count)s rooms|one": "%(count)s room", - "You're removing all spaces. Access will default to invite only": "You're removing all spaces. Access will default to invite only", - "Select spaces": "Select spaces", - "Decide which spaces can access this room. If a space is selected, its members can find and join .": "Decide which spaces can access this room. If a space is selected, its members can find and join .", - "Search spaces": "Search spaces", - "Spaces you know that contain this space": "Spaces you know that contain this space", - "Spaces you know that contain this room": "Spaces you know that contain this room", - "Other spaces or rooms you might not know": "Other spaces or rooms you might not know", - "These are likely ones other room admins are a part of.": "These are likely ones other room admins are a part of.", - "Confirm by comparing the following with the User Settings in your other session:": "Confirm by comparing the following with the User Settings in your other session:", - "Confirm this user's session by comparing the following with their User Settings:": "Confirm this user's session by comparing the following with their User Settings:", - "Session name": "Session name", - "Session ID": "Session ID", - "Session key": "Session key", - "If they don't match, the security of your communication may be compromised.": "If they don't match, the security of your communication may be compromised.", - "Verify session": "Verify session", - "Your homeserver doesn't seem to support this feature.": "Your homeserver doesn't seem to support this feature.", - "Message edits": "Message edits", - "Modal Widget": "Modal Widget", - "Data on this screen is shared with %(widgetDomain)s": "Data on this screen is shared with %(widgetDomain)s", - "Continuing without email": "Continuing without email", - "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.": "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.", - "Email (optional)": "Email (optional)", - "Please fill why you're reporting.": "Please fill why you're reporting.", - "Ignore user": "Ignore user", - "Check if you want to hide all current and future messages from this user.": "Check if you want to hide all current and future messages from this user.", - "What this user is writing is wrong.\nThis will be reported to the room moderators.": "What this user is writing is wrong.\nThis will be reported to the room moderators.", - "This user is displaying toxic behaviour, for instance by insulting other users or sharing adult-only content in a family-friendly room or otherwise violating the rules of this room.\nThis will be reported to the room moderators.": "This user is displaying toxic behaviour, for instance by insulting other users or sharing adult-only content in a family-friendly room or otherwise violating the rules of this room.\nThis will be reported to the room moderators.", - "This user is displaying illegal behaviour, for instance by doxing people or threatening violence.\nThis will be reported to the room moderators who may escalate this to legal authorities.": "This user is displaying illegal behaviour, for instance by doxing people or threatening violence.\nThis will be reported to the room moderators who may escalate this to legal authorities.", - "This user is spamming the room with ads, links to ads or to propaganda.\nThis will be reported to the room moderators.": "This user is spamming the room with ads, links to ads or to propaganda.\nThis will be reported to the room moderators.", - "This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\nThis will be reported to the administrators of %(homeserver)s. The administrators will NOT be able to read the encrypted content of this room.": "This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\nThis will be reported to the administrators of %(homeserver)s. The administrators will NOT be able to read the encrypted content of this room.", - "This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\n This will be reported to the administrators of %(homeserver)s.": "This room is dedicated to illegal or toxic content or the moderators fail to moderate illegal or toxic content.\n This will be reported to the administrators of %(homeserver)s.", - "Any other reason. Please describe the problem.\nThis will be reported to the room moderators.": "Any other reason. Please describe the problem.\nThis will be reported to the room moderators.", - "Please pick a nature and describe what makes this message abusive.": "Please pick a nature and describe what makes this message abusive.", - "Report Content": "Report Content", - "Disagree": "Disagree", - "Toxic Behaviour": "Toxic Behaviour", - "Illegal Content": "Illegal Content", - "Spam or propaganda": "Spam or propaganda", - "Report the entire room": "Report the entire room", - "Send report": "Send report", - "Report Content to Your Homeserver Administrator": "Report Content to Your Homeserver Administrator", - "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.": "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.", - "Room Settings - %(roomName)s": "Room Settings - %(roomName)s", - "Failed to upgrade room": "Failed to upgrade room", - "The room upgrade could not be completed": "The room upgrade could not be completed", - "Upgrade this room to version %(version)s": "Upgrade this room to version %(version)s", - "Upgrade Room Version": "Upgrade Room Version", - "Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:": "Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:", - "Create a new room with the same name, description and avatar": "Create a new room with the same name, description and avatar", - "Update any local room aliases to point to the new room": "Update any local room aliases to point to the new room", - "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room", - "Put a link back to the old room at the start of the new room so people can see old messages": "Put a link back to the old room at the start of the new room so people can see old messages", - "Automatically invite members from this room to the new one": "Automatically invite members from this room to the new one", - "Upgrade private room": "Upgrade private room", - "Upgrade public room": "Upgrade public room", - "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.", - "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.", - "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.", - "Please note upgrading will make a new version of the room. All current messages will stay in this archived room.": "Please note upgrading will make a new version of the room. All current messages will stay in this archived room.", - "You'll upgrade this room from to .": "You'll upgrade this room from to .", - "Resend": "Resend", - "You're all caught up.": "You're all caught up.", - "Server isn't responding": "Server isn't responding", - "Your server isn't responding to some of your requests. Below are some of the most likely reasons.": "Your server isn't responding to some of your requests. Below are some of the most likely reasons.", - "The server (%(serverName)s) took too long to respond.": "The server (%(serverName)s) took too long to respond.", - "Your firewall or anti-virus is blocking the request.": "Your firewall or anti-virus is blocking the request.", - "A browser extension is preventing the request.": "A browser extension is preventing the request.", - "The server is offline.": "The server is offline.", - "The server has denied your request.": "The server has denied your request.", - "Your area is experiencing difficulties connecting to the internet.": "Your area is experiencing difficulties connecting to the internet.", - "A connection error occurred while trying to contact the server.": "A connection error occurred while trying to contact the server.", - "The server is not configured to indicate what the problem is (CORS).": "The server is not configured to indicate what the problem is (CORS).", - "Recent changes that have not yet been received": "Recent changes that have not yet been received", - "Unable to validate homeserver": "Unable to validate homeserver", - "Invalid URL": "Invalid URL", - "Specify a homeserver": "Specify a homeserver", - "Matrix.org is the biggest public homeserver in the world, so it's a good place for many.": "Matrix.org is the biggest public homeserver in the world, so it's a good place for many.", - "Sign into your homeserver": "Sign into your homeserver", - "We call the places where you can host your account 'homeservers'.": "We call the places where you can host your account 'homeservers'.", - "Other homeserver": "Other homeserver", - "Use your preferred Matrix homeserver if you have one, or host your own.": "Use your preferred Matrix homeserver if you have one, or host your own.", - "About homeservers": "About homeservers", - "Reset event store?": "Reset event store?", - "You most likely do not want to reset your event index store": "You most likely do not want to reset your event index store", - "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated", - "Reset event store": "Reset event store", - "Sign out and remove encryption keys?": "Sign out and remove encryption keys?", - "Clear Storage and Sign Out": "Clear Storage and Sign Out", - "Send Logs": "Send Logs", - "Refresh": "Refresh", - "Unable to restore session": "Unable to restore session", - "We encountered an error trying to restore your previous session.": "We encountered an error trying to restore your previous session.", - "If you have previously used a more recent version of %(brand)s, your session may be incompatible with this version. Close this window and return to the more recent version.": "If you have previously used a more recent version of %(brand)s, your session may be incompatible with this version. Close this window and return to the more recent version.", - "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.", - "Verification Pending": "Verification Pending", - "Please check your email and click on the link it contains. Once this is done, click continue.": "Please check your email and click on the link it contains. Once this is done, click continue.", - "Email address": "Email address", - "This will allow you to reset your password and receive notifications.": "This will allow you to reset your password and receive notifications.", - "Skip": "Skip", - "Share Room": "Share Room", - "Link to most recent message": "Link to most recent message", - "Share User": "Share User", - "Share Room Message": "Share Room Message", - "Link to selected message": "Link to selected message", - "Link to room": "Link to room", - "Command Help": "Command Help", - "Sections to show": "Sections to show", - "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.": "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.", - "Space settings": "Space settings", - "Settings - %(spaceName)s": "Settings - %(spaceName)s", - "Spaces you're in": "Spaces you're in", - "Other rooms in %(spaceName)s": "Other rooms in %(spaceName)s", - "Join %(roomAddress)s": "Join %(roomAddress)s", - "Use \"%(query)s\" to search": "Use \"%(query)s\" to search", - "Public rooms": "Public rooms", - "Other searches": "Other searches", - "To search messages, look for this icon at the top of a room ": "To search messages, look for this icon at the top of a room ", - "Recent searches": "Recent searches", - "Clear": "Clear", - "Use to scroll": "Use to scroll", - "Search Dialog": "Search Dialog", - "Results not as expected? Please give feedback.": "Results not as expected? Please give feedback.", - "To help us prevent this in future, please send us logs.": "To help us prevent this in future, please send us logs.", - "Missing session data": "Missing session data", - "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.", - "Your browser likely removed this data when running low on disk space.": "Your browser likely removed this data when running low on disk space.", - "Find others by phone or email": "Find others by phone or email", - "Be found by phone or email": "Be found by phone or email", - "Use bots, bridges, widgets and sticker packs": "Use bots, bridges, widgets and sticker packs", - "To continue you need to accept the terms of this service.": "To continue you need to accept the terms of this service.", - "Service": "Service", - "Summary": "Summary", - "Document": "Document", - "Next": "Next", - "You signed in to a new session without verifying it:": "You signed in to a new session without verifying it:", - "Verify your other session using one of the options below.": "Verify your other session using one of the options below.", - "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) signed in to a new session without verifying it:", - "Ask this user to verify their session, or manually verify it below.": "Ask this user to verify their session, or manually verify it below.", - "Not Trusted": "Not Trusted", - "Manually Verify by Text": "Manually Verify by Text", - "Interactively verify by Emoji": "Interactively verify by Emoji", - "Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)", - "Upload files": "Upload files", - "Upload all": "Upload all", - "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.", - "These files are too large to upload. The file size limit is %(limit)s.": "These files are too large to upload. The file size limit is %(limit)s.", - "Some files are too large to be uploaded. The file size limit is %(limit)s.": "Some files are too large to be uploaded. The file size limit is %(limit)s.", - "Upload %(count)s other files|other": "Upload %(count)s other files", - "Upload %(count)s other files|one": "Upload %(count)s other file", - "Cancel All": "Cancel All", - "Upload Error": "Upload Error", - "Verify other device": "Verify other device", - "Verification Request": "Verification Request", - "Approve widget permissions": "Approve widget permissions", - "This widget would like to:": "This widget would like to:", - "Approve": "Approve", - "Decline All": "Decline All", - "Remember my selection for this widget": "Remember my selection for this widget", - "Allow this widget to verify your identity": "Allow this widget to verify your identity", - "The widget will verify your user ID, but won't be able to perform actions for you:": "The widget will verify your user ID, but won't be able to perform actions for you:", - "Remember this": "Remember this", - "Wrong file type": "Wrong file type", - "Looks good!": "Looks good!", - "Wrong Security Key": "Wrong Security Key", - "Invalid Security Key": "Invalid Security Key", - "Forgotten or lost all recovery methods? Reset all": "Forgotten or lost all recovery methods? Reset all", - "Reset everything": "Reset everything", - "Only do this if you have no other device to complete verification with.": "Only do this if you have no other device to complete verification with.", - "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.", - "Security Phrase": "Security Phrase", - "Unable to access secret storage. Please verify that you entered the correct Security Phrase.": "Unable to access secret storage. Please verify that you entered the correct Security Phrase.", - "Enter your Security Phrase or to continue.": "Enter your Security Phrase or to continue.", - "Security Key": "Security Key", - "Use your Security Key to continue.": "Use your Security Key to continue.", - "Destroy cross-signing keys?": "Destroy cross-signing keys?", - "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.": "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.", - "Clear cross-signing keys": "Clear cross-signing keys", - "Confirm encryption setup": "Confirm encryption setup", - "Click the button below to confirm setting up encryption.": "Click the button below to confirm setting up encryption.", - "Unable to set up keys": "Unable to set up keys", - "Restoring keys from backup": "Restoring keys from backup", - "Fetching keys from server...": "Fetching keys from server...", - "%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored", - "Unable to load backup status": "Unable to load backup status", - "Security Key mismatch": "Security Key mismatch", - "Backup could not be decrypted with this Security Key: please verify that you entered the correct Security Key.": "Backup could not be decrypted with this Security Key: please verify that you entered the correct Security Key.", - "Incorrect Security Phrase": "Incorrect Security Phrase", - "Backup could not be decrypted with this Security Phrase: please verify that you entered the correct Security Phrase.": "Backup could not be decrypted with this Security Phrase: please verify that you entered the correct Security Phrase.", - "Unable to restore backup": "Unable to restore backup", - "No backup found!": "No backup found!", - "Keys restored": "Keys restored", - "Failed to decrypt %(failedCount)s sessions!": "Failed to decrypt %(failedCount)s sessions!", - "Successfully restored %(sessionCount)s keys": "Successfully restored %(sessionCount)s keys", - "Enter Security Phrase": "Enter Security Phrase", - "Warning: you should only set up key backup from a trusted computer.": "Warning: you should only set up key backup from a trusted computer.", - "Access your secure message history and set up secure messaging by entering your Security Phrase.": "Access your secure message history and set up secure messaging by entering your Security Phrase.", - "If you've forgotten your Security Phrase you can use your Security Key or set up new recovery options": "If you've forgotten your Security Phrase you can use your Security Key or set up new recovery options", - "Enter Security Key": "Enter Security Key", - "This looks like a valid Security Key!": "This looks like a valid Security Key!", - "Not a valid Security Key": "Not a valid Security Key", - "Warning: You should only set up key backup from a trusted computer.": "Warning: You should only set up key backup from a trusted computer.", - "Access your secure message history and set up secure messaging by entering your Security Key.": "Access your secure message history and set up secure messaging by entering your Security Key.", - "If you've forgotten your Security Key you can ": "If you've forgotten your Security Key you can ", - "Send custom account data event": "Send custom account data event", - "Send custom room account data event": "Send custom room account data event", - "Event Type": "Event Type", - "State Key": "State Key", - "Doesn't look like valid JSON.": "Doesn't look like valid JSON.", - "Failed to send event!": "Failed to send event!", - "Event sent!": "Event sent!", - "Event Content": "Event Content", - "Filter results": "Filter results", - "No results found": "No results found", - "<%(count)s spaces>|other": "<%(count)s spaces>", - "<%(count)s spaces>|one": "", - "<%(count)s spaces>|zero": "", - "Send custom state event": "Send custom state event", - "Capabilities": "Capabilities", - "Failed to load.": "Failed to load.", - "Client Versions": "Client Versions", - "Server Versions": "Server Versions", - "Server": "Server", - "Number of users": "Number of users", - "Failed to save settings.": "Failed to save settings.", - "Save setting values": "Save setting values", - "Setting:": "Setting:", - "Caution:": "Caution:", - "This UI does NOT check the types of the values. Use at your own risk.": "This UI does NOT check the types of the values. Use at your own risk.", - "Setting definition:": "Setting definition:", - "Level": "Level", - "Settable at global": "Settable at global", - "Settable at room": "Settable at room", - "Values at explicit levels": "Values at explicit levels", - "Values at explicit levels in this room": "Values at explicit levels in this room", - "Edit values": "Edit values", - "Value:": "Value:", - "Value in this room:": "Value in this room:", - "Values at explicit levels:": "Values at explicit levels:", - "Values at explicit levels in this room:": "Values at explicit levels in this room:", - "Setting ID": "Setting ID", - "Value": "Value", - "Value in this room": "Value in this room", - "Edit setting": "Edit setting", - "Unsent": "Unsent", - "Requested": "Requested", - "Ready": "Ready", - "Started": "Started", - "Cancelled": "Cancelled", - "Transaction": "Transaction", - "Phase": "Phase", - "Timeout": "Timeout", - "Methods": "Methods", - "Requester": "Requester", - "Observe only": "Observe only", - "No verification requests found": "No verification requests found", - "There was an error finding this widget.": "There was an error finding this widget.", - "Resume": "Resume", - "Hold": "Hold", - "Input devices": "Input devices", - "Output devices": "Output devices", - "Cameras": "Cameras", - "Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)", - "Open in OpenStreetMap": "Open in OpenStreetMap", - "Forward": "Forward", - "View source": "View source", - "Show preview": "Show preview", - "Source URL": "Source URL", - "Collapse reply thread": "Collapse reply thread", - "View related event": "View related event", - "Report": "Report", - "Copy link": "Copy link", - "Forget": "Forget", - "Mentions only": "Mentions only", - "See room timeline (devtools)": "See room timeline (devtools)", - "Space": "Space", - "Space home": "Space home", - "Manage & explore rooms": "Manage & explore rooms", - "Thread options": "Thread options", - "Unable to start audio streaming.": "Unable to start audio streaming.", - "Failed to start livestream": "Failed to start livestream", - "Start audio stream": "Start audio stream", - "Take a picture": "Take a picture", - "Delete Widget": "Delete Widget", - "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?", - "Delete widget": "Delete widget", - "Remove for everyone": "Remove for everyone", - "Revoke permissions": "Revoke permissions", - "Move left": "Move left", - "Move right": "Move right", - "This is a beta feature": "This is a beta feature", - "Click for more info": "Click for more info", - "Beta": "Beta", - "Join the beta": "Join the beta", - "Updated %(humanizedUpdateTime)s": "Updated %(humanizedUpdateTime)s", - "Live until %(expiryTime)s": "Live until %(expiryTime)s", - "Loading live location...": "Loading live location...", - "Live location ended": "Live location ended", - "Live location error": "Live location error", - "No live locations": "No live locations", - "View list": "View list", - "View List": "View List", - "Close sidebar": "Close sidebar", - "An error occurred while stopping your live location": "An error occurred while stopping your live location", - "An error occurred whilst sharing your live location": "An error occurred whilst sharing your live location", - "You are sharing your live location": "You are sharing your live location", - "%(timeRemaining)s left": "%(timeRemaining)s left", - "Live location enabled": "Live location enabled", - "An error occurred whilst sharing your live location, please try again": "An error occurred whilst sharing your live location, please try again", - "An error occurred while stopping your live location, please try again": "An error occurred while stopping your live location, please try again", - "Stop sharing": "Stop sharing", - "Stop sharing and close": "Stop sharing and close", - "Avatar": "Avatar", - "This room is public": "This room is public", - "Away": "Away", - "powered by Matrix": "powered by Matrix", - "This homeserver would like to make sure you are not a robot.": "This homeserver would like to make sure you are not a robot.", - "Country Dropdown": "Country Dropdown", - "Email": "Email", - "Enter email address": "Enter email address", - "Doesn't look like a valid email address": "Doesn't look like a valid email address", - "Confirm your identity by entering your account password below.": "Confirm your identity by entering your account password below.", - "Password": "Password", - "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.", - "Please review and accept all of the homeserver's policies": "Please review and accept all of the homeserver's policies", - "Please review and accept the policies of this homeserver:": "Please review and accept the policies of this homeserver:", - "Check your email to continue": "Check your email to continue", - "Unread email icon": "Unread email icon", - "To create your account, open the link in the email we just sent to %(emailAddress)s.": "To create your account, open the link in the email we just sent to %(emailAddress)s.", - "Did not receive it? Resend it": "Did not receive it? Resend it", - "Resent!": "Resent!", - "Token incorrect": "Token incorrect", - "A text message has been sent to %(msisdn)s": "A text message has been sent to %(msisdn)s", - "Please enter the code it contains:": "Please enter the code it contains:", - "Code": "Code", - "Submit": "Submit", - "Something went wrong in confirming your identity. Cancel and try again.": "Something went wrong in confirming your identity. Cancel and try again.", - "Start authentication": "Start authentication", - "Enter password": "Enter password", - "Nice, strong password!": "Nice, strong password!", - "Password is allowed, but unsafe": "Password is allowed, but unsafe", - "Keep going...": "Keep going...", - "Enter username": "Enter username", - "Enter phone number": "Enter phone number", - "That phone number doesn't look quite right, please check and try again": "That phone number doesn't look quite right, please check and try again", - "Username": "Username", - "Phone": "Phone", - "Forgot password?": "Forgot password?", - "Sign in with": "Sign in with", - "Sign in": "Sign in", - "Use an email address to recover your account": "Use an email address to recover your account", - "Enter email address (required on this homeserver)": "Enter email address (required on this homeserver)", - "Other users can invite you to rooms using your contact details": "Other users can invite you to rooms using your contact details", - "Enter phone number (required on this homeserver)": "Enter phone number (required on this homeserver)", - "Use lowercase letters, numbers, dashes and underscores only": "Use lowercase letters, numbers, dashes and underscores only", - "Unable to check if username has been taken. Try again later.": "Unable to check if username has been taken. Try again later.", - "Someone already has that username. Try another or if it is you, sign in below.": "Someone already has that username. Try another or if it is you, sign in below.", - "Phone (optional)": "Phone (optional)", - "Register": "Register", - "Add an email to be able to reset your password.": "Add an email to be able to reset your password.", - "Use email or phone to optionally be discoverable by existing contacts.": "Use email or phone to optionally be discoverable by existing contacts.", - "Use email to optionally be discoverable by existing contacts.": "Use email to optionally be discoverable by existing contacts.", - "Sign in with SSO": "Sign in with SSO", - "Unnamed audio": "Unnamed audio", - "Error downloading audio": "Error downloading audio", - "Pause": "Pause", - "Play": "Play", - "Couldn't load page": "Couldn't load page", - "Drop file here to upload": "Drop file here to upload", - "You must register to use this functionality": "You must register to use this functionality", - "You must join the room to see its files": "You must join the room to see its files", - "No files visible in this room": "No files visible in this room", - "Attach files from chat or just drag and drop them anywhere in a room.": "Attach files from chat or just drag and drop them anywhere in a room.", - "Great, that'll help people know it's you": "Great, that'll help people know it's you", - "Add a photo so people know it's you.": "Add a photo so people know it's you.", - "Welcome %(name)s": "Welcome %(name)s", - "Now, let's help you get started": "Now, let's help you get started", - "Welcome to %(appName)s": "Welcome to %(appName)s", - "Own your conversations.": "Own your conversations.", - "Send a Direct Message": "Send a Direct Message", - "Explore Public Rooms": "Explore Public Rooms", - "Create a Group Chat": "Create a Group Chat", - "Open dial pad": "Open dial pad", - "Wait!": "Wait!", - "If someone told you to copy/paste something here, there is a high likelihood you're being scammed!": "If someone told you to copy/paste something here, there is a high likelihood you're being scammed!", - "If you know what you're doing, Element is open-source, be sure to check out our GitHub (https://github.com/vector-im/element-web/) and contribute!": "If you know what you're doing, Element is open-source, be sure to check out our GitHub (https://github.com/vector-im/element-web/) and contribute!", - "Reject invitation": "Reject invitation", - "Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?", - "Failed to reject invitation": "Failed to reject invitation", - "You are the only person here. If you leave, no one will be able to join in the future, including you.": "You are the only person here. If you leave, no one will be able to join in the future, including you.", - "This space is not public. You will not be able to rejoin without an invite.": "This space is not public. You will not be able to rejoin without an invite.", - "This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.", - "Are you sure you want to leave the space '%(spaceName)s'?": "Are you sure you want to leave the space '%(spaceName)s'?", - "Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?", - "Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s", - "Unable to copy room link": "Unable to copy room link", - "Unable to copy a link to the room to the clipboard.": "Unable to copy a link to the room to the clipboard.", - "New search beta available": "New search beta available", - "We're testing a new search to make finding what you want quicker.\n": "We're testing a new search to make finding what you want quicker.\n", - "Signed Out": "Signed Out", - "For security, this session has been signed out. Please sign in again.": "For security, this session has been signed out. Please sign in again.", - "Terms and Conditions": "Terms and Conditions", - "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.", - "Review terms and conditions": "Review terms and conditions", - "Old cryptography data detected": "Old cryptography data detected", - "Data from an older version of %(brand)s has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "Data from an older version of %(brand)s has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.", - "Verification requested": "Verification requested", - "Logout": "Logout", - "%(creator)s created this DM.": "%(creator)s created this DM.", - "%(creator)s created and configured the room.": "%(creator)s created and configured the room.", - "You're all caught up": "You're all caught up", - "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.", - "Delete the room address %(alias)s and remove %(name)s from the directory?": "Delete the room address %(alias)s and remove %(name)s from the directory?", - "Remove %(name)s from the directory?": "Remove %(name)s from the directory?", - "Remove from Directory": "Remove from Directory", - "remove %(name)s from the directory.": "remove %(name)s from the directory.", - "delete the address.": "delete the address.", - "The server may be unavailable or overloaded": "The server may be unavailable or overloaded", - "Create new room": "Create new room", - "No results for \"%(query)s\"": "No results for \"%(query)s\"", - "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.", - "Find a room…": "Find a room…", - "Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)", - "If you can't find the room you're looking for, ask for an invite or create a new room.": "If you can't find the room you're looking for, ask for an invite or create a new room.", - "Filter": "Filter", - "Filter rooms and people": "Filter rooms and people", - "Clear filter": "Clear filter", - "You can't send any messages until you review and agree to our terms and conditions.": "You can't send any messages until you review and agree to our terms and conditions.", - "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.", - "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.", - "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.", - "Some of your messages have not been sent": "Some of your messages have not been sent", - "Delete all": "Delete all", - "Retry all": "Retry all", - "You can select all or individual messages to retry or delete": "You can select all or individual messages to retry or delete", - "Connectivity to the server has been lost.": "Connectivity to the server has been lost.", - "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", - "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", - "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", - "Search failed": "Search failed", - "Server may be unavailable, overloaded, or search timed out :(": "Server may be unavailable, overloaded, or search timed out :(", - "No more results": "No more results", - "Failed to reject invite": "Failed to reject invite", - "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.", - "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.", - "Joining": "Joining", - "You don't have permission": "You don't have permission", - "This room is suggested as a good one to join": "This room is suggested as a good one to join", - "Suggested": "Suggested", - "Select a room below first": "Select a room below first", - "Failed to remove some rooms. Try again later": "Failed to remove some rooms. Try again later", - "Removing...": "Removing...", - "Mark as not suggested": "Mark as not suggested", - "Mark as suggested": "Mark as suggested", - "Failed to load list of rooms.": "Failed to load list of rooms.", - "Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.", - "You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.", - "Results": "Results", - "Rooms and spaces": "Rooms and spaces", - "Search names and descriptions": "Search names and descriptions", - "Welcome to ": "Welcome to ", - "Random": "Random", - "Support": "Support", - "Room name": "Room name", - "Failed to create initial space rooms": "Failed to create initial space rooms", - "Skip for now": "Skip for now", - "Creating rooms...": "Creating rooms...", - "What do you want to organise?": "What do you want to organise?", - "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.", - "Search for rooms or spaces": "Search for rooms or spaces", - "Share %(name)s": "Share %(name)s", - "It's just you at the moment, it will be even better with others.": "It's just you at the moment, it will be even better with others.", - "Go to my first room": "Go to my first room", - "Go to my space": "Go to my space", - "Who are you working with?": "Who are you working with?", - "Make sure the right people have access to %(name)s": "Make sure the right people have access to %(name)s", - "Just me": "Just me", - "A private space to organise your rooms": "A private space to organise your rooms", - "Me and my teammates": "Me and my teammates", - "A private space for you and your teammates": "A private space for you and your teammates", - "Failed to invite the following users to your space: %(csvUsers)s": "Failed to invite the following users to your space: %(csvUsers)s", - "Inviting...": "Inviting...", - "Invite your teammates": "Invite your teammates", - "Make sure the right people have access. You can invite more later.": "Make sure the right people have access. You can invite more later.", - "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.", - "Invite by username": "Invite by username", - "What are some things you want to discuss in %(spaceName)s?": "What are some things you want to discuss in %(spaceName)s?", - "Let's create a room for each of them.": "Let's create a room for each of them.", - "You can add more later too, including already existing ones.": "You can add more later too, including already existing ones.", - "What projects are your team working on?": "What projects are your team working on?", - "We'll create rooms for each of them.": "We'll create rooms for each of them.", - "All threads": "All threads", - "Shows all threads from current room": "Shows all threads from current room", - "My threads": "My threads", - "Shows all threads you've participated in": "Shows all threads you've participated in", - "Show:": "Show:", - "Reply to an ongoing thread or use “%(replyInThread)s” when hovering over a message to start a new one.": "Reply to an ongoing thread or use “%(replyInThread)s” when hovering over a message to start a new one.", - "Show all threads": "Show all threads", - "Threads help keep your conversations on-topic and easy to track.": "Threads help keep your conversations on-topic and easy to track.", - "Tip: Use “%(replyInThread)s” when hovering over a message.": "Tip: Use “%(replyInThread)s” when hovering over a message.", - "Keep discussions organised with threads": "Keep discussions organised with threads", - "Threads are a beta feature": "Threads are a beta feature", - "Give feedback": "Give feedback", - "Thread": "Thread", - "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", - "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", - "Failed to load timeline position": "Failed to load timeline position", - "Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others", - "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", - "Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other", - "Got an account? Sign in": "Got an account? Sign in", - "New here? Create an account": "New here? Create an account", - "Switch to light mode": "Switch to light mode", - "Switch to dark mode": "Switch to dark mode", - "Switch theme": "Switch theme", - "User menu": "User menu", - "Could not load user profile": "Could not load user profile", - "Decrypted event source": "Decrypted event source", - "Original event source": "Original event source", - "Event ID: %(eventId)s": "Event ID: %(eventId)s", - "Unable to verify this device": "Unable to verify this device", - "Verify this device": "Verify this device", - "Device verified": "Device verified", - "Really reset verification keys?": "Really reset verification keys?", - "Skip verification for now": "Skip verification for now", - "Failed to send email": "Failed to send email", - "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.", - "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.", - "If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.": "If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.", - "The email address linked to your account must be entered.": "The email address linked to your account must be entered.", - "The email address doesn't appear to be valid.": "The email address doesn't appear to be valid.", - "A new password must be entered.": "A new password must be entered.", - "New passwords must match each other.": "New passwords must match each other.", - "Sign out all devices": "Sign out all devices", - "A verification email will be sent to your inbox to confirm setting your new password.": "A verification email will be sent to your inbox to confirm setting your new password.", - "Send Reset Email": "Send Reset Email", - "Sign in instead": "Sign in instead", - "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.", - "I have verified my email address": "I have verified my email address", - "Your password has been reset.": "Your password has been reset.", - "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.", - "Return to login screen": "Return to login screen", - "Set a new password": "Set a new password", - "Invalid homeserver discovery response": "Invalid homeserver discovery response", - "Failed to get autodiscovery configuration from server": "Failed to get autodiscovery configuration from server", - "Invalid base_url for m.homeserver": "Invalid base_url for m.homeserver", - "Homeserver URL does not appear to be a valid Matrix homeserver": "Homeserver URL does not appear to be a valid Matrix homeserver", - "Invalid identity server discovery response": "Invalid identity server discovery response", - "Invalid base_url for m.identity_server": "Invalid base_url for m.identity_server", - "Identity server URL does not appear to be a valid identity server": "Identity server URL does not appear to be a valid identity server", - "General failure": "General failure", - "This homeserver does not support login using email address.": "This homeserver does not support login using email address.", - "Please contact your service administrator to continue using this service.": "Please contact your service administrator to continue using this service.", - "This account has been deactivated.": "This account has been deactivated.", - "Incorrect username and/or password.": "Incorrect username and/or password.", - "Please note you are logging into the %(hs)s server, not matrix.org.": "Please note you are logging into the %(hs)s server, not matrix.org.", - "Failed to perform homeserver discovery": "Failed to perform homeserver discovery", - "This homeserver doesn't offer any login flows which are supported by this client.": "This homeserver doesn't offer any login flows which are supported by this client.", - "There was a problem communicating with the homeserver, please try again later.": "There was a problem communicating with the homeserver, please try again later.", - "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.", - "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.": "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.", - "Syncing...": "Syncing...", - "Signing In...": "Signing In...", - "If you've joined lots of rooms, this might take a while": "If you've joined lots of rooms, this might take a while", - "New? Create account": "New? Create account", - "Registration has been disabled on this homeserver.": "Registration has been disabled on this homeserver.", - "Unable to query for supported registration methods.": "Unable to query for supported registration methods.", - "This server does not support authentication with a phone number.": "This server does not support authentication with a phone number.", - "Someone already has that username, please try another.": "Someone already has that username, please try another.", - "That e-mail address is already in use.": "That e-mail address is already in use.", - "Continue with %(ssoButtons)s": "Continue with %(ssoButtons)s", - "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s Or %(usernamePassword)s", - "Already have an account? Sign in here": "Already have an account? Sign in here", - "Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).": "Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).", - "Continue with previous account": "Continue with previous account", - "Log in to your new account.": "Log in to your new account.", - "Registration Successful": "Registration Successful", - "Create account": "Create account", - "Host account on": "Host account on", - "Decide where your account is hosted": "Decide where your account is hosted", - "It looks like you don't have a Security Key or any other devices you can verify against. This device will not be able to access old encrypted messages. In order to verify your identity on this device, you'll need to reset your verification keys.": "It looks like you don't have a Security Key or any other devices you can verify against. This device will not be able to access old encrypted messages. In order to verify your identity on this device, you'll need to reset your verification keys.", - "Proceed with reset": "Proceed with reset", - "Verify with Security Key or Phrase": "Verify with Security Key or Phrase", - "Verify with Security Key": "Verify with Security Key", - "Verify with another device": "Verify with another device", - "Verify your identity to access encrypted messages and prove your identity to others.": "Verify your identity to access encrypted messages and prove your identity to others.", - "Your new device is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Your new device is now verified. It has access to your encrypted messages, and other users will see it as trusted.", - "Your new device is now verified. Other users will see it as trusted.": "Your new device is now verified. Other users will see it as trusted.", - "Without verifying, you won't have access to all your messages and may appear as untrusted to others.": "Without verifying, you won't have access to all your messages and may appear as untrusted to others.", - "I'll verify later": "I'll verify later", - "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.": "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.", - "Please only proceed if you're sure you've lost all of your other devices and your security key.": "Please only proceed if you're sure you've lost all of your other devices and your security key.", - "Failed to re-authenticate due to a homeserver problem": "Failed to re-authenticate due to a homeserver problem", - "Incorrect password": "Incorrect password", - "Failed to re-authenticate": "Failed to re-authenticate", - "Forgotten your password?": "Forgotten your password?", - "Regain access to your account and recover encryption keys stored in this session. Without them, you won't be able to read all of your secure messages in any session.": "Regain access to your account and recover encryption keys stored in this session. Without them, you won't be able to read all of your secure messages in any session.", - "Enter your password to sign in and regain access to your account.": "Enter your password to sign in and regain access to your account.", - "Sign in and regain access to your account.": "Sign in and regain access to your account.", - "You cannot sign in to your account. Please contact your homeserver admin for more information.": "You cannot sign in to your account. Please contact your homeserver admin for more information.", - "You're signed out": "You're signed out", - "Clear personal data": "Clear personal data", - "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.", - "Commands": "Commands", - "Command Autocomplete": "Command Autocomplete", - "Emoji Autocomplete": "Emoji Autocomplete", - "Notify the whole room": "Notify the whole room", - "Room Notification": "Room Notification", - "Notification Autocomplete": "Notification Autocomplete", - "Room Autocomplete": "Room Autocomplete", - "Space Autocomplete": "Space Autocomplete", - "Users": "Users", - "User Autocomplete": "User Autocomplete", - "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.", - "For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.", - "Enter a Security Phrase": "Enter a Security Phrase", - "Great! This Security Phrase looks strong enough.": "Great! This Security Phrase looks strong enough.", - "Set up with a Security Key": "Set up with a Security Key", - "That matches!": "That matches!", - "Use a different passphrase?": "Use a different passphrase?", - "That doesn't match.": "That doesn't match.", - "Go back to set it again.": "Go back to set it again.", - "Enter your Security Phrase a second time to confirm it.": "Enter your Security Phrase a second time to confirm it.", - "Repeat your Security Phrase...": "Repeat your Security Phrase...", - "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.": "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.", - "Keep a copy of it somewhere secure, like a password manager or even a safe.": "Keep a copy of it somewhere secure, like a password manager or even a safe.", - "Your Security Key": "Your Security Key", - "Your Security Key has been copied to your clipboard, paste it to:": "Your Security Key has been copied to your clipboard, paste it to:", - "Your Security Key is in your Downloads folder.": "Your Security Key is in your Downloads folder.", - "Print it and store it somewhere safe": "Print it and store it somewhere safe", - "Save it on a USB key or backup drive": "Save it on a USB key or backup drive", - "Copy it to your personal cloud storage": "Copy it to your personal cloud storage", - "Your keys are being backed up (the first backup could take a few minutes).": "Your keys are being backed up (the first backup could take a few minutes).", - "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.": "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.", - "Set up Secure Message Recovery": "Set up Secure Message Recovery", - "Secure your backup with a Security Phrase": "Secure your backup with a Security Phrase", - "Confirm your Security Phrase": "Confirm your Security Phrase", - "Make a copy of your Security Key": "Make a copy of your Security Key", - "Starting backup...": "Starting backup...", - "Success!": "Success!", - "Create key backup": "Create key backup", - "Unable to create key backup": "Unable to create key backup", - "Generate a Security Key": "Generate a Security Key", - "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.", - "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Use a secret phrase only you know, and optionally save a Security Key to use for backup.", - "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.", - "Enter your account password to confirm the upgrade:": "Enter your account password to confirm the upgrade:", - "Restore your key backup to upgrade your encryption": "Restore your key backup to upgrade your encryption", - "Restore": "Restore", - "You'll need to authenticate with the server to confirm the upgrade.": "You'll need to authenticate with the server to confirm the upgrade.", - "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.", - "Enter a security phrase only you know, as it's used to safeguard your data. To be secure, you shouldn't re-use your account password.": "Enter a security phrase only you know, as it's used to safeguard your data. To be secure, you shouldn't re-use your account password.", - "Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.": "Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.", - "Unable to query secret storage status": "Unable to query secret storage status", - "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.", - "You can also set up Secure Backup & manage your keys in Settings.": "You can also set up Secure Backup & manage your keys in Settings.", - "Upgrade your encryption": "Upgrade your encryption", - "Set a Security Phrase": "Set a Security Phrase", - "Confirm Security Phrase": "Confirm Security Phrase", - "Save your Security Key": "Save your Security Key", - "Unable to set up secret storage": "Unable to set up secret storage", - "Passphrases must match": "Passphrases must match", - "Passphrase must not be empty": "Passphrase must not be empty", - "Unknown error": "Unknown error", - "Export room keys": "Export room keys", - "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.": "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.", - "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.", - "Enter passphrase": "Enter passphrase", - "Confirm passphrase": "Confirm passphrase", - "Import room keys": "Import room keys", - "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.", - "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.": "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.", - "File to import": "File to import", - "Import": "Import", - "New Recovery Method": "New Recovery Method", - "A new Security Phrase and key for Secure Messages have been detected.": "A new Security Phrase and key for Secure Messages have been detected.", - "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.", - "This session is encrypting history using the new recovery method.": "This session is encrypting history using the new recovery method.", - "Go to Settings": "Go to Settings", - "Set up Secure Messages": "Set up Secure Messages", - "Recovery Method Removed": "Recovery Method Removed", - "This session has detected that your Security Phrase and key for Secure Messages have been removed.": "This session has detected that your Security Phrase and key for Secure Messages have been removed.", - "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.": "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.", - "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.", - "If disabled, messages from encrypted rooms won't appear in search results.": "If disabled, messages from encrypted rooms won't appear in search results.", - "Disable": "Disable", - "Not currently indexing messages for any room.": "Not currently indexing messages for any room.", - "Currently indexing: %(currentRoom)s": "Currently indexing: %(currentRoom)s", - "%(brand)s is securely caching encrypted messages locally for them to appear in search results:": "%(brand)s is securely caching encrypted messages locally for them to appear in search results:", - "Space used:": "Space used:", - "Indexed messages:": "Indexed messages:", - "Indexed rooms:": "Indexed rooms:", - "%(doneRooms)s out of %(totalRooms)s": "%(doneRooms)s out of %(totalRooms)s", - "Message downloading sleep time(ms)": "Message downloading sleep time(ms)", - "Failed to set direct chat tag": "Failed to set direct chat tag", - "Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room", - "Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room", - "Page Up": "Page Up", - "Page Down": "Page Down", - "Esc": "Esc", - "Enter": "Enter", - "End": "End", - "Alt": "Alt", - "Ctrl": "Ctrl", - "Shift": "Shift", - "[number]": "[number]", - "Calls": "Calls", - "Room List": "Room List", - "Accessibility": "Accessibility", - "Navigation": "Navigation", - "Autocomplete": "Autocomplete", - "Toggle Bold": "Toggle Bold", - "Toggle Italics": "Toggle Italics", - "Toggle Quote": "Toggle Quote", - "Toggle Code Block": "Toggle Code Block", - "Toggle Link": "Toggle Link", - "Cancel replying to a message": "Cancel replying to a message", - "Navigate to next message to edit": "Navigate to next message to edit", - "Navigate to previous message to edit": "Navigate to previous message to edit", - "Jump to start of the composer": "Jump to start of the composer", - "Jump to end of the composer": "Jump to end of the composer", - "Navigate to next message in composer history": "Navigate to next message in composer history", - "Navigate to previous message in composer history": "Navigate to previous message in composer history", - "Send a sticker": "Send a sticker", - "Toggle microphone mute": "Toggle microphone mute", - "Toggle webcam on/off": "Toggle webcam on/off", - "Dismiss read marker and jump to bottom": "Dismiss read marker and jump to bottom", - "Jump to oldest unread message": "Jump to oldest unread message", - "Upload a file": "Upload a file", - "Scroll up in the timeline": "Scroll up in the timeline", - "Scroll down in the timeline": "Scroll down in the timeline", - "Jump to room search": "Jump to room search", - "Select room from the room list": "Select room from the room list", - "Collapse room list section": "Collapse room list section", - "Expand room list section": "Expand room list section", - "Clear room list filter field": "Clear room list filter field", - "Navigate down in the room list": "Navigate down in the room list", - "Navigate up in the room list": "Navigate up in the room list", - "Toggle the top left menu": "Toggle the top left menu", - "Toggle right panel": "Toggle right panel", - "Open this settings tab": "Open this settings tab", - "Go to Home View": "Go to Home View", - "Next unread room or DM": "Next unread room or DM", - "Previous unread room or DM": "Previous unread room or DM", - "Next room or DM": "Next room or DM", - "Previous room or DM": "Previous room or DM", - "Cancel autocomplete": "Cancel autocomplete", - "Next autocomplete suggestion": "Next autocomplete suggestion", - "Previous autocomplete suggestion": "Previous autocomplete suggestion", - "Toggle space panel": "Toggle space panel", - "Toggle hidden event visibility": "Toggle hidden event visibility", - "Jump to first message": "Jump to first message", - "Jump to last message": "Jump to last message", - "Undo edit": "Undo edit", - "Redo edit": "Redo edit", - "Previous recently visited room or space": "Previous recently visited room or space", - "Next recently visited room or space": "Next recently visited room or space", - "Switch to space by number": "Switch to space by number", - "Open user settings": "Open user settings", - "Close dialog or context menu": "Close dialog or context menu", - "Activate selected button": "Activate selected button", - "New line": "New line", - "Force complete": "Force complete", - "Search (must be enabled)": "Search (must be enabled)" -} From b2118cf6cc7c0c8222f9d76bac46083a9574c978 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 25 May 2022 16:38:16 +0200 Subject: [PATCH 24/73] Fix roomId access in EventTile --- src/components/views/rooms/EventTile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 5dda1838be8..2bd97cba24a 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -768,7 +768,7 @@ export class UnwrappedEventTile extends React.Component { const ev = this.props.mxEvent; // no icon for local rooms - if (ev.getRoomId().startsWith(LOCAL_ROOM_ID_PREFIX)) { + if (ev.getRoomId()?.startsWith(LOCAL_ROOM_ID_PREFIX)) { return; } From 0f72b9bac32a534ed19d1711af1e68bae0904276 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 30 May 2022 13:52:57 +0200 Subject: [PATCH 25/73] LocalRoom model refactoring --- res/css/structures/_ScrollPanel.scss | 2 +- src/components/structures/LoggedInView.tsx | 5 ++-- src/components/structures/RoomView.tsx | 6 +++- src/models/LocalRoom.ts | 34 +++++----------------- src/utils/direct-messages.ts | 25 +++++++++++++++- 5 files changed, 41 insertions(+), 31 deletions(-) diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss index e497256535f..d3e372b9328 100644 --- a/res/css/structures/_ScrollPanel.scss +++ b/res/css/structures/_ScrollPanel.scss @@ -26,6 +26,6 @@ limitations under the License. } } -.mx_RoomView_newLocal .mx_ScrollPanel .mx_RoomView_MessageList { +.mx_RoomView_local .mx_ScrollPanel .mx_RoomView_MessageList { justify-content: flex-start; } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 34900cd81b5..4b3b47a11ba 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -74,6 +74,7 @@ import LegacyGroupView from "./LegacyGroupView"; import { IConfigOptions } from "../../IConfigOptions"; import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning'; import { LocalRoom } from '../../models/LocalRoom'; +import { createRoomFromLocalRoom } from '../../utils/direct-messages'; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -664,7 +665,7 @@ class LoggedInView extends React.Component { room.afterCreateCallbacks.push(async (client, roomId) => { await client.sendEvent(roomId, ...rest); }); - room.createRealRoom(this._matrixClient); + createRoomFromLocalRoom(this._matrixClient, room); this._matrixClient.emit(ClientEvent.Room, room); return; }; @@ -674,7 +675,7 @@ class LoggedInView extends React.Component { room.afterCreateCallbacks.push(async (client, roomId) => { await client.unstable_createLiveBeacon(roomId, ...rest); }); - room.createRealRoom(this._matrixClient); + createRoomFromLocalRoom(this._matrixClient, room); return; }; showReadMarkers = false; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index a6d3e98ebd5..6a125a97a69 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1763,6 +1763,10 @@ export class RoomView extends React.Component { this.setState({ narrow }); }; + private get viewLocalRoom(): boolean { + return this.state.room instanceof LocalRoom; + } + render() { if (!this.state.room) { const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading; @@ -2128,7 +2132,7 @@ export class RoomView extends React.Component { const mainClasses = classNames("mx_RoomView", { mx_RoomView_inCall: Boolean(activeCall), mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Video, - mx_RoomView_newLocal: this.state.room instanceof LocalRoom && this.state.room.isDraft, + mx_RoomView_local: this.viewLocalRoom, }); const showChatEffects = SettingsStore.getValue('showChatEffects'); diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index 2406c376548..65e693d206a 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -14,16 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { Room } from "matrix-js-sdk/src/models/room"; +import { Room } from "matrix-js-sdk/src/matrix"; -import { Member, startDm } from "../utils/direct-messages"; +import { Member } from "../utils/direct-messages"; export const LOCAL_ROOM_ID_PREFIX = 'local+'; export enum LocalRoomState { - DRAFT, // local room created; only known to the client - CREATED, // room has been created via API; events applied + NEW, // new local room; only known to the client + CREATING, // real room is being created + CREATED, // real room has been created via API; events applied } /** @@ -33,27 +33,9 @@ export enum LocalRoomState { export class LocalRoom extends Room { targets: Member[]; afterCreateCallbacks: Function[] = []; - state: LocalRoomState = LocalRoomState.DRAFT; + state: LocalRoomState = LocalRoomState.NEW; - public async createRealRoom(client: MatrixClient) { - if (!this.isDraft) { - return; - } - - const roomId = await startDm(client, this.targets); - await this.applyAfterCreateCallbacks(client, roomId); - this.state = LocalRoomState.CREATED; - } - - private async applyAfterCreateCallbacks(client: MatrixClient, roomId: string) { - for (const afterCreateCallback of this.afterCreateCallbacks) { - await afterCreateCallback(client, roomId); - } - - this.afterCreateCallbacks = []; - } - - public get isDraft(): boolean { - return this.state === LocalRoomState.DRAFT; + public get isNew(): boolean { + return this.state === LocalRoomState.NEW; } } diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 3b7add61aa1..895824bb9a6 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -28,7 +28,7 @@ import DMRoomMap from "./DMRoomMap"; import { isJoinedOrNearlyJoined } from "./membership"; import dis from "../dispatcher/dispatcher"; import { privateShouldBeEncrypted } from "./rooms"; -import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from '../models/LocalRoom'; +import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from '../models/LocalRoom'; export function findDMForUser(client: MatrixClient, userId: string): Room { const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); @@ -213,6 +213,29 @@ async function determineCreateRoomEncryptionOption(client: MatrixClient, targets return false; } +async function applyAfterCreateCallbacks( + client: MatrixClient, + localRoom: LocalRoom, + roomId: string, +) { + for (const afterCreateCallback of localRoom.afterCreateCallbacks) { + await afterCreateCallback(client, roomId); + } + + localRoom.afterCreateCallbacks = []; +} + +export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: LocalRoom) { + if (!localRoom.isNew) { + return; + } + + localRoom.state = LocalRoomState.CREATING; + const roomId = await startDm(client, localRoom.targets); + await applyAfterCreateCallbacks(client, localRoom, roomId); + localRoom.state = LocalRoomState.CREATED; +} + /** * Start a DM. * From 98c8268044dd094798b78f88c1902ccf162e49d1 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 30 May 2022 14:09:59 +0200 Subject: [PATCH 26/73] Fix undefined error --- src/components/structures/MatrixChat.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index f0d4f062dba..7ddd3a6b3a1 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -920,8 +920,8 @@ export default class MatrixChat extends React.PureComponent { let replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId; if ( - !roomInfo.room_id.startsWith(LOCAL_ROOM_ID_PREFIX) - && this.state.currentRoomId.startsWith(LOCAL_ROOM_ID_PREFIX) + !roomInfo.room_id?.startsWith(LOCAL_ROOM_ID_PREFIX) + && this.state.currentRoomId?.startsWith(LOCAL_ROOM_ID_PREFIX) ) { // Replace local room history items replaceLast = true; From 8f901fcd5a38486d9abbba3b7309c427d5aafc2b Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 30 May 2022 16:31:21 +0200 Subject: [PATCH 27/73] Implement room create spinner --- res/css/structures/_RoomView.scss | 22 ++++++++++ src/components/structures/LoggedInView.tsx | 13 ++++-- src/components/structures/RoomView.tsx | 47 +++++++++++++++++++-- src/components/views/rooms/NewRoomIntro.tsx | 19 ++++----- src/i18n/strings/en_EN.json | 2 +- src/utils/direct-messages.ts | 11 ++++- 6 files changed, 94 insertions(+), 20 deletions(-) diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index eba8ae8f6e8..e3f3d37dbe2 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -337,3 +337,25 @@ hr.mx_RoomView_myReadMarker { background: inherit; } } + +.mx_RoomView_LocalRoomLoader { + align-items: center; + display: flex; + flex-direction: column; + height: 100%; + + .mx_Spinner { + flex: unset; + height: auto; + margin-bottom: 32px; + margin-top: 33vh; + } + + .mx_RoomView_LocalRoomLoader_text { + font-size: 24px; + font-weight: 600; + padding: 0 16px; + position: relative; + text-align: center; + } +} diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 4b3b47a11ba..8e68708d575 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -23,7 +23,6 @@ import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; -import { EventStatus, EventType, IContent } from 'matrix-js-sdk/src/matrix'; import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; @@ -626,6 +625,7 @@ class LoggedInView extends React.Component { let showReadMarkers = true; let showHeaderButtons = true; let enableHeaderRoomOptionsMenu = true; + let showCreateRoomLoader = false; let client = this._matrixClient; let room: LocalRoom; @@ -636,6 +636,7 @@ class LoggedInView extends React.Component { client = Object.assign(Object.create(Object.getPrototypeOf(this._matrixClient)), this._matrixClient); client.sendEvent = async (localRoomId: string, ...rest): Promise => { + /* let eventType: EventType; let content: IContent; @@ -662,11 +663,13 @@ class LoggedInView extends React.Component { event.setTxnId(txnId); event.setStatus(EventStatus.SENDING); room.addLiveEvents([event]); + */ room.afterCreateCallbacks.push(async (client, roomId) => { await client.sendEvent(roomId, ...rest); }); - createRoomFromLocalRoom(this._matrixClient, room); - this._matrixClient.emit(ClientEvent.Room, room); + showCreateRoomLoader = true; + await createRoomFromLocalRoom(this._matrixClient, room); + //this._matrixClient.emit(ClientEvent.Room, room); return; }; client.unstable_createLiveBeacon = async ( @@ -675,7 +678,8 @@ class LoggedInView extends React.Component { room.afterCreateCallbacks.push(async (client, roomId) => { await client.unstable_createLiveBeacon(roomId, ...rest); }); - createRoomFromLocalRoom(this._matrixClient, room); + showCreateRoomLoader = true; + await createRoomFromLocalRoom(this._matrixClient, room); return; }; showReadMarkers = false; @@ -695,6 +699,7 @@ class LoggedInView extends React.Component { showReadMarkers={showReadMarkers} showHeaderButtons={showHeaderButtons} enableHeaderRoomOptionsMenu={enableHeaderRoomOptionsMenu} + showCreateRoomLoader={showCreateRoomLoader} />; break; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 6a125a97a69..cd6a3755d23 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -20,7 +20,7 @@ limitations under the License. // TODO: This component is enormous! There's several things which could stand-alone: // - Search results component -import React, { createRef } from 'react'; +import React, { createRef, ReactNode } from 'react'; import classNames from 'classnames'; import { IRecommendedVersion, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; @@ -110,7 +110,7 @@ import FileDropTarget from './FileDropTarget'; import Measured from '../views/elements/Measured'; import { FocusComposerPayload } from '../../dispatcher/payloads/FocusComposerPayload'; import { haveRendererForEvent } from "../../events/EventTileFactory"; -import { LocalRoom } from '../../models/LocalRoom'; +import { LocalRoom, LocalRoomState } from '../../models/LocalRoom'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -137,6 +137,7 @@ interface IRoomProps extends MatrixClientProps { showReadMarkers?: boolean; showHeaderButtons?: boolean; enableHeaderRoomOptionsMenu?: boolean; + showCreateRoomLoader?: boolean; } // This defines the content of the mainSplit. @@ -1763,11 +1764,49 @@ export class RoomView extends React.Component { this.setState({ narrow }); }; - private get viewLocalRoom(): boolean { + private get viewsLocalRoom(): boolean { return this.state.room instanceof LocalRoom; } + private renderLocalRoomviewLoader(): ReactNode { + const text = _t("We're creating a room with %(names)s", { + names: this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()), + }); + return ( +
+ + +
+ +
+ { text } +
+
+
+
+ ); + } + render() { + if (this.state.room instanceof LocalRoom && this.state.room.state === LocalRoomState.CREATING) { + return this.renderLocalRoomviewLoader(); + } + if (!this.state.room) { const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading; if (loading) { @@ -2132,7 +2171,7 @@ export class RoomView extends React.Component { const mainClasses = classNames("mx_RoomView", { mx_RoomView_inCall: Boolean(activeCall), mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Video, - mx_RoomView_local: this.viewLocalRoom, + mx_RoomView_local: this.viewsLocalRoom, }); const showChatEffects = SettingsStore.getValue('showChatEffects'); diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 2cec0ef97b9..bf29eaed89f 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -50,19 +50,18 @@ const NewRoomIntro = () => { const cli = useContext(MatrixClientContext); const { room, roomId } = useContext(RoomContext); - let dmPartner; - - // @todo MiW - if (room instanceof LocalRoom) { - dmPartner = room.targets[0].userId; - } else { - dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); - } + const dmPartner = room instanceof LocalRoom + ? room.targets[0].userId + : DMRoomMap.shared().getUserIdForRoomId(roomId); let body; if (dmPartner) { + let introMessage = "This is the beginning of your direct message history with ."; let caption; - if ((room.getJoinedMemberCount() + room.getInvitedMemberCount()) === 2) { + + if (room instanceof LocalRoom) { + introMessage = "Send your first message to invite to chat"; + } else if ((room.getJoinedMemberCount() + room.getInvitedMemberCount()) === 2) { caption = _t("Only the two of you are in this conversation, unless either of you invites anyone to join."); } @@ -84,7 +83,7 @@ const NewRoomIntro = () => {

{ room.name }

-

{ _t("This is the beginning of your direct message history with .", {}, { +

{ _t(introMessage, {}, { displayName: () => { displayName }, }) }

{ caption &&

{ caption }

} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6aad377442b..ae171337720 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1734,7 +1734,6 @@ "Quote": "Quote", "Insert link": "Insert link", "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Only the two of you are in this conversation, unless either of you invites anyone to join.", - "This is the beginning of your direct message history with .": "This is the beginning of your direct message history with .", "Topic: %(topic)s (edit)": "Topic: %(topic)s (edit)", "Topic: %(topic)s ": "Topic: %(topic)s ", "Add a topic to help people know what it is about.": "Add a topic to help people know what it is about.", @@ -3104,6 +3103,7 @@ "Server may be unavailable, overloaded, or search timed out :(": "Server may be unavailable, overloaded, or search timed out :(", "No more results": "No more results", "Failed to reject invite": "Failed to reject invite", + "We're creating a room with %(names)s": "We're creating a room with %(names)s", "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.", "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.", "Joining": "Joining", diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 895824bb9a6..41c6c53eba0 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { IInvite3PID } from "matrix-js-sdk/src/@types/requests"; -import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { ClientEvent, MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { EventType } from "matrix-js-sdk/src/matrix"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -225,12 +225,21 @@ async function applyAfterCreateCallbacks( localRoom.afterCreateCallbacks = []; } +/* +function timeout(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} +*/ + export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: LocalRoom) { if (!localRoom.isNew) { return; } localRoom.state = LocalRoomState.CREATING; + client.emit(ClientEvent.Room, localRoom); + //await timeout(600000); + const roomId = await startDm(client, localRoom.targets); await applyAfterCreateCallbacks(client, localRoom, roomId); localRoom.state = LocalRoomState.CREATED; From a71e63e177a1e828ab8d8a4680a7e2ffe53ef32a Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 31 May 2022 09:24:30 +0200 Subject: [PATCH 28/73] Remove RoomView props --- src/components/structures/LoggedInView.tsx | 39 +--------------------- src/components/structures/RoomView.tsx | 19 ++++------- 2 files changed, 8 insertions(+), 50 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 8e68708d575..9c612ef3cf5 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -622,9 +622,6 @@ class LoggedInView extends React.Component { render() { let pageElement; - let showReadMarkers = true; - let showHeaderButtons = true; - let enableHeaderRoomOptionsMenu = true; let showCreateRoomLoader = false; let client = this._matrixClient; @@ -635,41 +632,13 @@ class LoggedInView extends React.Component { room = this._matrixClient.store.getRoom(this.props.currentRoomId) as LocalRoom; client = Object.assign(Object.create(Object.getPrototypeOf(this._matrixClient)), this._matrixClient); + // MiW wrap client.sendEvent = async (localRoomId: string, ...rest): Promise => { - /* - let eventType: EventType; - let content: IContent; - - if (typeof rest[1] === 'object') { - eventType = rest[0]; - content = rest[1]; - } - - if (typeof rest[2] === 'object') { - eventType = rest[1]; - content = rest[2]; - } - - const txnId = this._matrixClient.makeTxnId(); - const event = new MatrixEvent({ - type: eventType, - content, - event_id: "~" + localRoomId + ":" + txnId, - user_id: this._matrixClient.getUserId(), - sender: this._matrixClient.getUserId(), - room_id: localRoomId, - origin_server_ts: new Date().getTime(), - }); - event.setTxnId(txnId); - event.setStatus(EventStatus.SENDING); - room.addLiveEvents([event]); - */ room.afterCreateCallbacks.push(async (client, roomId) => { await client.sendEvent(roomId, ...rest); }); showCreateRoomLoader = true; await createRoomFromLocalRoom(this._matrixClient, room); - //this._matrixClient.emit(ClientEvent.Room, room); return; }; client.unstable_createLiveBeacon = async ( @@ -682,9 +651,6 @@ class LoggedInView extends React.Component { await createRoomFromLocalRoom(this._matrixClient, room); return; }; - showReadMarkers = false; - showHeaderButtons = false; - enableHeaderRoomOptionsMenu = false; // fallthrough case PageTypes.RoomView: pageElement = { resizeNotifier={this.props.resizeNotifier} justCreatedOpts={this.props.roomJustCreatedOpts} forceTimeline={this.props.forceTimeline} - showReadMarkers={showReadMarkers} - showHeaderButtons={showHeaderButtons} - enableHeaderRoomOptionsMenu={enableHeaderRoomOptionsMenu} showCreateRoomLoader={showCreateRoomLoader} />; break; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index cd6a3755d23..cc9a269e0f4 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -134,9 +134,6 @@ interface IRoomProps extends MatrixClientProps { // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) onRegistered?(credentials: IMatrixClientCreds): void; - showReadMarkers?: boolean; - showHeaderButtons?: boolean; - enableHeaderRoomOptionsMenu?: boolean; showCreateRoomLoader?: boolean; } @@ -227,12 +224,6 @@ export interface IRoomState { } export class RoomView extends React.Component { - static defaultProps = { - showReadMarkers: true, - showHeaderButtons: true, - enableRoomOptionsMenu: true, - }; - private readonly dispatcherRef: string; private readonly roomStoreToken: EventSubscription; private settingWatchers: string[]; @@ -1768,6 +1759,10 @@ export class RoomView extends React.Component { return this.state.room instanceof LocalRoom; } + private get manageReadMarkers(): boolean { + return !this.viewsLocalRoom && !this.state.isPeeking; + } + private renderLocalRoomviewLoader(): ReactNode { const text = _t("We're creating a room with %(names)s", { names: this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()), @@ -2118,7 +2113,7 @@ export class RoomView extends React.Component { showReadReceipts={this.state.showReadReceipts} manageReadReceipts={!this.state.isPeeking} sendReadReceiptOnLoad={!this.state.wasContextSwitch} - manageReadMarkers={this.props.showReadMarkers && !this.state.isPeeking} + manageReadMarkers={this.manageReadMarkers} hidden={hideMessagePanel} highlightedEventId={highlightedEventId} eventId={this.state.initialEventId} @@ -2275,8 +2270,8 @@ export class RoomView extends React.Component { appsShown={this.state.showApps} onCallPlaced={onCallPlaced} excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} - showButtons={this.props.showHeaderButtons} - enableRoomOptionsMenu={this.props.enableHeaderRoomOptionsMenu} + showButtons={!this.viewsLocalRoom} + enableRoomOptionsMenu={!this.viewsLocalRoom} />
From 71150a839b3313b7974a15fd7a8c6da8aa316ab8 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 31 May 2022 09:36:14 +0200 Subject: [PATCH 29/73] Use consts --- src/utils/direct-messages.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 41c6c53eba0..af4ee231d33 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -18,7 +18,8 @@ import { IInvite3PID } from "matrix-js-sdk/src/@types/requests"; import { ClientEvent, MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { EventType } from "matrix-js-sdk/src/matrix"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { Room } from "matrix-js-sdk/src/models/room"; +import { KNOWN_SAFE_ROOM_VERSION, Room } from "matrix-js-sdk/src/models/room"; +import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; import createRoom, { canEncryptToAllUsers } from "../createRoom"; import { Action } from "../dispatcher/actions"; @@ -117,7 +118,7 @@ export async function createDmLocalRoom( type: EventType.RoomCreate, content: { creator: userId, - room_version: "9", + room_version: KNOWN_SAFE_ROOM_VERSION, }, state_key: "", user_id: userId, @@ -132,7 +133,7 @@ export async function createDmLocalRoom( event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, type: EventType.RoomEncryption, content: { - algorithm: "m.megolm.v1.aes-sha2", + algorithm: MEGOLM_ALGORITHM, }, user_id: userId, sender: userId, From a5267479c0febaa5011aabde38e2bff23012e67b Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 31 May 2022 14:36:40 +0200 Subject: [PATCH 30/73] Wrap matrix client; remove local room view; update layout --- res/css/structures/_ScrollPanel.scss | 2 +- src/ContentMessages.ts | 12 +- src/MatrixClientWrapper.ts | 135 ++++++++++++++++++ src/PageTypes.ts | 1 - src/PosthogTrackers.ts | 1 - src/components/structures/LoggedInView.tsx | 35 +---- src/components/structures/MatrixChat.tsx | 3 - src/components/structures/RoomView.tsx | 2 - .../views/elements/PollCreateDialog.tsx | 4 +- .../views/location/shareLocation.ts | 4 +- .../views/rooms/SendMessageComposer.tsx | 3 +- .../views/rooms/VoiceRecordComposerTile.tsx | 3 +- src/dispatcher/actions.ts | 2 - src/stores/OwnBeaconStore.ts | 4 +- src/stores/RoomViewStore.tsx | 1 - src/utils/direct-messages.ts | 7 +- 16 files changed, 163 insertions(+), 56 deletions(-) create mode 100644 src/MatrixClientWrapper.ts diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss index d3e372b9328..60555b15c23 100644 --- a/res/css/structures/_ScrollPanel.scss +++ b/res/css/structures/_ScrollPanel.scss @@ -27,5 +27,5 @@ limitations under the License. } .mx_RoomView_local .mx_ScrollPanel .mx_RoomView_MessageList { - justify-content: flex-start; + justify-content: center; } diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 13bac7b1408..c00ebf2951a 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -50,6 +50,7 @@ import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog" import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog"; import { createThumbnail } from "./utils/image-media"; import { attachRelation } from "./components/views/rooms/SendMessageComposer"; +import { MatrixClientWrapper } from "./MatrixClientWrapper"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre @@ -351,7 +352,14 @@ export default class ContentMessages { text: string, matrixClient: MatrixClient, ): Promise { - const prom = matrixClient.sendStickerMessage(roomId, threadId, url, info, text).catch((e) => { + const prom = MatrixClientWrapper.sendStickerMessage( + matrixClient, + roomId, + threadId, + url, + info, + text, + ).catch((e) => { logger.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); throw e; }); @@ -570,7 +578,7 @@ export default class ContentMessages { const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null; - const prom = matrixClient.sendMessage(roomId, threadId, content); + const prom = MatrixClientWrapper.sendMessage(matrixClient, roomId, threadId, content); if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { prom.then(resp => { sendRoundTripMetric(matrixClient, roomId, resp.event_id); diff --git a/src/MatrixClientWrapper.ts b/src/MatrixClientWrapper.ts new file mode 100644 index 00000000000..e406aa65190 --- /dev/null +++ b/src/MatrixClientWrapper.ts @@ -0,0 +1,135 @@ +/* +Copyright 2022 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 { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "./models/LocalRoom"; +import { createRoomFromLocalRoom } from "./utils/direct-messages"; + +export interface IMatrixClientWrapper { + sendEvent( + matrixClient: MatrixClient, + roomId: string, + ...rest + ): Promise; + + sendMessage( + matrixClient: MatrixClient, + roomId: string, + ...rest + ): Promise; + + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_createLiveBeacon( + matrixClient: MatrixClient, + roomId: string, + ...rest + ): Promise; + + sendStickerMessage( + matrixClient: MatrixClient, + roomId: string, + ...rest + ): Promise; +} + +export class MatrixClientWrapperClass implements IMatrixClientWrapper { + public async sendEvent(matrixClient: MatrixClient, roomId: string, ...rest): Promise { + if (!roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { + const params = [roomId, ...rest] as Parameters; + return matrixClient.sendEvent(...params); + } + + return this.handleLocalRoom( + matrixClient, + roomId, + async (roomId: string) => { + const params = [roomId, ...rest] as Parameters; + await matrixClient.sendEvent(...params); + }, + ); + } + + public async sendMessage(matrixClient: MatrixClient, roomId: string, ...rest): Promise { + if (!roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { + const params = [roomId, ...rest] as Parameters; + return matrixClient.sendMessage(...params); + } + + return this.handleLocalRoom( + matrixClient, + roomId, + async (roomId: string) => { + const params = [roomId, ...rest] as Parameters; + await matrixClient.sendMessage(...params); + }, + ); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + public async unstable_createLiveBeacon( + matrixClient: MatrixClient, + roomId: string, + ...rest: any[] + ): Promise { + if (!roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { + const params = [roomId, ...rest] as Parameters; + return matrixClient.unstable_createLiveBeacon(...params); + } + + return this.handleLocalRoom( + matrixClient, + roomId, + async (roomId: string) => { + const params = [roomId, ...rest] as Parameters; + await matrixClient.unstable_createLiveBeacon(...params); + }, + ); + } + + sendStickerMessage( + matrixClient: MatrixClient, + roomId: string, + ...rest + ): Promise { + if (!roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { + const params = [roomId, ...rest] as Parameters; + return matrixClient.sendStickerMessage(...params); + } + + return this.handleLocalRoom( + matrixClient, + roomId, + async (roomId: string) => { + const params = [roomId, ...rest] as Parameters; + await matrixClient.sendStickerMessage(...params); + }, + ); + } + + private async handleLocalRoom( + matrixClient: MatrixClient, + roomId: string, + callback: Function, + ): Promise { + const room = matrixClient.store.getRoom(roomId) as LocalRoom; + room.afterCreateCallbacks.push(callback); + await createRoomFromLocalRoom(matrixClient, room); + return; + } +} + +export const MatrixClientWrapper = new MatrixClientWrapperClass(); diff --git a/src/PageTypes.ts b/src/PageTypes.ts index 447a34799cf..fb0424f6e05 100644 --- a/src/PageTypes.ts +++ b/src/PageTypes.ts @@ -19,7 +19,6 @@ limitations under the License. enum PageType { HomePage = "home_page", RoomView = "room_view", - LocalRoomView = "local_room_view", UserView = "user_view", LegacyGroupView = "legacy_group_view", } diff --git a/src/PosthogTrackers.ts b/src/PosthogTrackers.ts index a2b8d379808..434d142c8cd 100644 --- a/src/PosthogTrackers.ts +++ b/src/PosthogTrackers.ts @@ -39,7 +39,6 @@ const notLoggedInMap: Record, ScreenName> = { const loggedInPageTypeMap: Record = { [PageType.HomePage]: "Home", [PageType.RoomView]: "Room", - [PageType.LocalRoomView]: "Room", [PageType.UserView]: "User", [PageType.LegacyGroupView]: "Group", }; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 9c612ef3cf5..ba02a38eb8f 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -22,7 +22,6 @@ import classNames from 'classnames'; import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; -import { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; @@ -72,8 +71,6 @@ import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload import LegacyGroupView from "./LegacyGroupView"; import { IConfigOptions } from "../../IConfigOptions"; import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning'; -import { LocalRoom } from '../../models/LocalRoom'; -import { createRoomFromLocalRoom } from '../../utils/direct-messages'; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -622,36 +619,7 @@ class LoggedInView extends React.Component { render() { let pageElement; - let showCreateRoomLoader = false; - - let client = this._matrixClient; - let room: LocalRoom; - switch (this.props.page_type) { - case PageTypes.LocalRoomView: - room = this._matrixClient.store.getRoom(this.props.currentRoomId) as LocalRoom; - - client = Object.assign(Object.create(Object.getPrototypeOf(this._matrixClient)), this._matrixClient); - // MiW wrap - client.sendEvent = async (localRoomId: string, ...rest): Promise => { - room.afterCreateCallbacks.push(async (client, roomId) => { - await client.sendEvent(roomId, ...rest); - }); - showCreateRoomLoader = true; - await createRoomFromLocalRoom(this._matrixClient, room); - return; - }; - client.unstable_createLiveBeacon = async ( - localRoomId: string, ...rest - ): Promise => { - room.afterCreateCallbacks.push(async (client, roomId) => { - await client.unstable_createLiveBeacon(roomId, ...rest); - }); - showCreateRoomLoader = true; - await createRoomFromLocalRoom(this._matrixClient, room); - return; - }; - // fallthrough case PageTypes.RoomView: pageElement = { resizeNotifier={this.props.resizeNotifier} justCreatedOpts={this.props.roomJustCreatedOpts} forceTimeline={this.props.forceTimeline} - showCreateRoomLoader={showCreateRoomLoader} />; break; @@ -695,7 +662,7 @@ class LoggedInView extends React.Component { }); return ( - +
{ } break; } - case Action.ViewLocalRoom: - this.viewRoom(payload as ViewRoomPayload, PageType.LocalRoomView); - break; case Action.ViewRoom: { // Takes either a room ID or room alias: if switching to a room the client is already // known to be in (eg. user clicks on a room in the recents panel), supply the ID diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index cc9a269e0f4..decb9a6c6b9 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -133,8 +133,6 @@ interface IRoomProps extends MatrixClientProps { // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) onRegistered?(credentials: IMatrixClientCreds): void; - - showCreateRoomLoader?: boolean; } // This defines the content of the mainSplit. diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx index faed54f8f09..b8a6749abf0 100644 --- a/src/components/views/elements/PollCreateDialog.tsx +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -36,6 +36,7 @@ import { arrayFastClone, arraySeed } from "../../../utils/arrays"; import Field from "./Field"; import AccessibleButton from "./AccessibleButton"; import Spinner from "./Spinner"; +import { MatrixClientWrapper } from "../../../MatrixClientWrapper"; interface IProps extends IDialogProps { room: Room; @@ -170,7 +171,8 @@ export default class PollCreateDialog extends ScrollableBaseModal beacon.beaconInfoOwner === userId; @@ -397,7 +398,8 @@ export class OwnBeaconStore extends AsyncStoreWithClient { matrixClient = matrixClient || this.matrixClient; // eslint-disable-next-line camelcase - const { event_id } = await matrixClient.unstable_createLiveBeacon( + const { event_id } = await MatrixClientWrapper.unstable_createLiveBeacon( + matrixClient, roomId, beaconInfoContent, ); diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 325dcd6e049..d8259097a2a 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -171,7 +171,6 @@ export class RoomViewStore extends Store { // - event_offset: 100 // - highlighted: true case Action.ViewRoom: - case Action.ViewLocalRoom: this.viewRoom(payload); break; // for these events blank out the roomId as we are no longer in the RoomView diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index af4ee231d33..5aa1ebf0c1f 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -88,7 +88,7 @@ export async function startDmOnFirstMessage( const room = await createDmLocalRoom(client, targets); dis.dispatch({ - action: Action.ViewLocalRoom, + action: Action.ViewRoom, room_id: room.roomId, joining: false, targets, @@ -215,12 +215,11 @@ async function determineCreateRoomEncryptionOption(client: MatrixClient, targets } async function applyAfterCreateCallbacks( - client: MatrixClient, localRoom: LocalRoom, roomId: string, ) { for (const afterCreateCallback of localRoom.afterCreateCallbacks) { - await afterCreateCallback(client, roomId); + await afterCreateCallback(roomId); } localRoom.afterCreateCallbacks = []; @@ -242,7 +241,7 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L //await timeout(600000); const roomId = await startDm(client, localRoom.targets); - await applyAfterCreateCallbacks(client, localRoom, roomId); + await applyAfterCreateCallbacks(localRoom, roomId); localRoom.state = LocalRoomState.CREATED; } From c5369ec5cfc1c5184b1bdd0df1561ff32a1881f3 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 1 Jun 2022 11:14:58 +0200 Subject: [PATCH 31/73] Replace ClientWrapper by doMaybeLocalRoomAction --- src/ContentMessages.ts | 17 +-- src/MatrixClientWrapper.ts | 135 ------------------ .../views/elements/PollCreateDialog.tsx | 15 +- .../views/location/shareLocation.ts | 10 +- .../views/rooms/SendMessageComposer.tsx | 8 +- .../views/rooms/VoiceRecordComposerTile.tsx | 12 +- src/stores/OwnBeaconStore.ts | 8 +- src/utils/direct-messages.ts | 26 +++- 8 files changed, 65 insertions(+), 166 deletions(-) delete mode 100644 src/MatrixClientWrapper.ts diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index c00ebf2951a..05134449a46 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -50,7 +50,7 @@ import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog" import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog"; import { createThumbnail } from "./utils/image-media"; import { attachRelation } from "./components/views/rooms/SendMessageComposer"; -import { MatrixClientWrapper } from "./MatrixClientWrapper"; +import { doMaybeLocalRoomAction } from "./utils/direct-messages"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre @@ -352,13 +352,10 @@ export default class ContentMessages { text: string, matrixClient: MatrixClient, ): Promise { - const prom = MatrixClientWrapper.sendStickerMessage( - matrixClient, + const prom = doMaybeLocalRoomAction( roomId, - threadId, - url, - info, - text, + (actualRoomId: string) => matrixClient.sendStickerMessage(actualRoomId, threadId, url, info, text), + matrixClient, ).catch((e) => { logger.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); throw e; @@ -578,7 +575,11 @@ export default class ContentMessages { const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null; - const prom = MatrixClientWrapper.sendMessage(matrixClient, roomId, threadId, content); + const prom = doMaybeLocalRoomAction( + roomId, + (actualRoomId: string) => matrixClient.sendMessage(actualRoomId, threadId, content), + matrixClient, + ); if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { prom.then(resp => { sendRoundTripMetric(matrixClient, roomId, resp.event_id); diff --git a/src/MatrixClientWrapper.ts b/src/MatrixClientWrapper.ts deleted file mode 100644 index e406aa65190..00000000000 --- a/src/MatrixClientWrapper.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* -Copyright 2022 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 { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; - -import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "./models/LocalRoom"; -import { createRoomFromLocalRoom } from "./utils/direct-messages"; - -export interface IMatrixClientWrapper { - sendEvent( - matrixClient: MatrixClient, - roomId: string, - ...rest - ): Promise; - - sendMessage( - matrixClient: MatrixClient, - roomId: string, - ...rest - ): Promise; - - // eslint-disable-next-line @typescript-eslint/naming-convention - unstable_createLiveBeacon( - matrixClient: MatrixClient, - roomId: string, - ...rest - ): Promise; - - sendStickerMessage( - matrixClient: MatrixClient, - roomId: string, - ...rest - ): Promise; -} - -export class MatrixClientWrapperClass implements IMatrixClientWrapper { - public async sendEvent(matrixClient: MatrixClient, roomId: string, ...rest): Promise { - if (!roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { - const params = [roomId, ...rest] as Parameters; - return matrixClient.sendEvent(...params); - } - - return this.handleLocalRoom( - matrixClient, - roomId, - async (roomId: string) => { - const params = [roomId, ...rest] as Parameters; - await matrixClient.sendEvent(...params); - }, - ); - } - - public async sendMessage(matrixClient: MatrixClient, roomId: string, ...rest): Promise { - if (!roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { - const params = [roomId, ...rest] as Parameters; - return matrixClient.sendMessage(...params); - } - - return this.handleLocalRoom( - matrixClient, - roomId, - async (roomId: string) => { - const params = [roomId, ...rest] as Parameters; - await matrixClient.sendMessage(...params); - }, - ); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - public async unstable_createLiveBeacon( - matrixClient: MatrixClient, - roomId: string, - ...rest: any[] - ): Promise { - if (!roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { - const params = [roomId, ...rest] as Parameters; - return matrixClient.unstable_createLiveBeacon(...params); - } - - return this.handleLocalRoom( - matrixClient, - roomId, - async (roomId: string) => { - const params = [roomId, ...rest] as Parameters; - await matrixClient.unstable_createLiveBeacon(...params); - }, - ); - } - - sendStickerMessage( - matrixClient: MatrixClient, - roomId: string, - ...rest - ): Promise { - if (!roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { - const params = [roomId, ...rest] as Parameters; - return matrixClient.sendStickerMessage(...params); - } - - return this.handleLocalRoom( - matrixClient, - roomId, - async (roomId: string) => { - const params = [roomId, ...rest] as Parameters; - await matrixClient.sendStickerMessage(...params); - }, - ); - } - - private async handleLocalRoom( - matrixClient: MatrixClient, - roomId: string, - callback: Function, - ): Promise { - const room = matrixClient.store.getRoom(roomId) as LocalRoom; - room.afterCreateCallbacks.push(callback); - await createRoomFromLocalRoom(matrixClient, room); - return; - } -} - -export const MatrixClientWrapper = new MatrixClientWrapperClass(); diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx index b8a6749abf0..713642a8a7b 100644 --- a/src/components/views/elements/PollCreateDialog.tsx +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -36,7 +36,7 @@ import { arrayFastClone, arraySeed } from "../../../utils/arrays"; import Field from "./Field"; import AccessibleButton from "./AccessibleButton"; import Spinner from "./Spinner"; -import { MatrixClientWrapper } from "../../../MatrixClientWrapper"; +import { doMaybeLocalRoomAction } from "../../../utils/direct-messages"; interface IProps extends IDialogProps { room: Room; @@ -171,12 +171,15 @@ export default class PollCreateDialog extends ScrollableBaseModal this.matrixClient.sendEvent( + actualRoomId, + this.props.threadId, + pollEvent.type, + pollEvent.content, + ), + this.matrixClient, ).then( () => this.props.onFinished(true), ).catch(e => { diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts index 7a10e98737c..dac06cf9a4c 100644 --- a/src/components/views/location/shareLocation.ts +++ b/src/components/views/location/shareLocation.ts @@ -26,7 +26,7 @@ import Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; import SdkConfig from "../../../SdkConfig"; import { OwnBeaconStore } from "../../../stores/OwnBeaconStore"; -import { MatrixClientWrapper } from "../../../MatrixClientWrapper"; +import { doMaybeLocalRoomAction } from "../../../utils/direct-messages"; export enum LocationShareType { Own = 'Own', @@ -98,11 +98,11 @@ export const shareLocation = ( try { const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null; const assetType = shareType === LocationShareType.Pin ? LocationAssetType.Pin : LocationAssetType.Self; - await MatrixClientWrapper.sendMessage( - client, + const content = makeLocationContent(undefined, uri, timestamp, undefined, assetType); + await doMaybeLocalRoomAction( roomId, - threadId, - makeLocationContent(undefined, uri, timestamp, undefined, assetType), + (actualRoomId: string) => client.sendMessage(actualRoomId, threadId, content), + client, ); } catch (error) { handleShareError(error, openMenu, shareType); diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 88478a93a75..a2de1585f32 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -58,7 +58,7 @@ import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } fr import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { addReplyToMessageContent } from '../../../utils/Reply'; -import { MatrixClientWrapper } from '../../../MatrixClientWrapper'; +import { doMaybeLocalRoomAction } from '../../../utils/direct-messages'; // Merges favouring the given relation export function attachRelation(content: IContent, relation?: IEventRelation): void { @@ -402,7 +402,11 @@ export class SendMessageComposer extends React.Component this.props.mxClient.sendMessage(actualRoomId, threadId, content), + this.props.mxClient, + ); if (replyToEvent) { // Clear reply_to_event as we put the message into the queue // if the send fails, retry will handle resending. diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 08bad786165..9885b8e0229 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -39,7 +39,7 @@ import { NotificationColor } from "../../../stores/notifications/NotificationCol import InlineSpinner from "../elements/InlineSpinner"; import { PlaybackManager } from "../../../audio/PlaybackManager"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { MatrixClientWrapper } from "../../../MatrixClientWrapper"; +import { doMaybeLocalRoomAction } from "../../../utils/direct-messages"; interface IProps { room: Room; @@ -109,7 +109,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent Math.round(v * 1024)), }, "org.matrix.msc3245.voice": {}, // No content, this is a rendering hint - }); + }; + + doMaybeLocalRoomAction( + this.props.room.roomId, + (actualRoomId: string) => this.matrixClient.sendMessage(actualRoomId, content), + this.matrixClient, + ); } catch (e) { logger.error("Error sending voice message:", e); diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index caf7ffa5ea6..e2de5555749 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -45,7 +45,7 @@ import { watchPosition, } from "../utils/beacon"; import { getCurrentPosition } from "../utils/beacon"; -import { MatrixClientWrapper } from "../MatrixClientWrapper"; +import { doMaybeLocalRoomAction } from "../utils/direct-messages"; const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconInfoOwner === userId; @@ -398,10 +398,10 @@ export class OwnBeaconStore extends AsyncStoreWithClient { matrixClient = matrixClient || this.matrixClient; // eslint-disable-next-line camelcase - const { event_id } = await MatrixClientWrapper.unstable_createLiveBeacon( - matrixClient, + const { event_id } = await doMaybeLocalRoomAction( roomId, - beaconInfoContent, + (actualRoomId: string) => matrixClient.unstable_createLiveBeacon(actualRoomId, beaconInfoContent), + matrixClient, ); storeLocallyCreateBeaconEventId(event_id); diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 5aa1ebf0c1f..4698a29fcd4 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -18,7 +18,7 @@ import { IInvite3PID } from "matrix-js-sdk/src/@types/requests"; import { ClientEvent, MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { EventType } from "matrix-js-sdk/src/matrix"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { KNOWN_SAFE_ROOM_VERSION, Room } from "matrix-js-sdk/src/models/room"; +import { Room } from "matrix-js-sdk/src/models/room"; import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; import createRoom, { canEncryptToAllUsers } from "../createRoom"; @@ -29,7 +29,8 @@ import DMRoomMap from "./DMRoomMap"; import { isJoinedOrNearlyJoined } from "./membership"; import dis from "../dispatcher/dispatcher"; import { privateShouldBeEncrypted } from "./rooms"; -import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from '../models/LocalRoom'; +import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; +import { MatrixClientPeg } from "../MatrixClientPeg"; export function findDMForUser(client: MatrixClient, userId: string): Room { const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); @@ -118,7 +119,8 @@ export async function createDmLocalRoom( type: EventType.RoomCreate, content: { creator: userId, - room_version: KNOWN_SAFE_ROOM_VERSION, + // @todo MiW + room_version: "9", }, state_key: "", user_id: userId, @@ -245,6 +247,24 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L localRoom.state = LocalRoomState.CREATED; } +export async function doMaybeLocalRoomAction( + roomId: string, + fn: (actualRoomId: string) => Promise, + client?: MatrixClient, +): Promise { + if (roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { + return new Promise((resolve, reject) => { + client = client ?? MatrixClientPeg.get(); + const room = client.getRoom(roomId) as LocalRoom; + room.afterCreateCallbacks.push((newRoomId: string) => { + fn(newRoomId).then(resolve).catch(reject); + }); + }); + } + + return fn(roomId); +} + /** * Start a DM. * From 68ff8d3833559ec6ef3e2806c83d6c5eb945720f Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 2 Jun 2022 10:52:09 +0200 Subject: [PATCH 32/73] Refactor modules; introduce indirection to break cyclic imports --- src/ContentMessages.ts | 2 +- src/components/structures/RoomView.tsx | 10 +++++ .../views/elements/PollCreateDialog.tsx | 2 +- .../views/location/shareLocation.ts | 2 +- .../views/rooms/SendMessageComposer.tsx | 2 +- .../views/rooms/VoiceRecordComposerTile.tsx | 2 +- src/stores/OwnBeaconStore.ts | 2 +- src/utils/direct-messages.ts | 21 +-------- src/utils/local-room.ts | 43 +++++++++++++++++++ 9 files changed, 60 insertions(+), 26 deletions(-) create mode 100644 src/utils/local-room.ts diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 05134449a46..a047db8a202 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -50,7 +50,7 @@ import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog" import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog"; import { createThumbnail } from "./utils/image-media"; import { attachRelation } from "./components/views/rooms/SendMessageComposer"; -import { doMaybeLocalRoomAction } from "./utils/direct-messages"; +import { doMaybeLocalRoomAction } from "./utils/local-room"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index decb9a6c6b9..3a8389f8a1d 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -111,6 +111,7 @@ import Measured from '../views/elements/Measured'; import { FocusComposerPayload } from '../../dispatcher/payloads/FocusComposerPayload'; import { haveRendererForEvent } from "../../events/EventTileFactory"; import { LocalRoom, LocalRoomState } from '../../models/LocalRoom'; +import { createRoomFromLocalRoom } from '../../utils/direct-messages'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -872,6 +873,10 @@ export class RoomView extends React.Component { this.onSearchClick(); break; + case 'local_room_event': + this.onLocalRoomEvent(payload.roomId); + break; + case Action.EditEvent: { // Quit early if we're trying to edit events in wrong rendering context if (payload.timelineRenderingType !== this.state.timelineRenderingType) return; @@ -924,6 +929,11 @@ export class RoomView extends React.Component { } }; + private onLocalRoomEvent(roomId: string) { + if (roomId !== this.state.room.roomId) return; + createRoomFromLocalRoom(this.props.mxClient, this.state.room as LocalRoom); + } + private onRoomTimeline = (ev: MatrixEvent, room: Room | null, toStartOfTimeline: boolean, removed, data) => { if (this.unmounted) return; diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx index 713642a8a7b..a7fa2dbae34 100644 --- a/src/components/views/elements/PollCreateDialog.tsx +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -36,7 +36,7 @@ import { arrayFastClone, arraySeed } from "../../../utils/arrays"; import Field from "./Field"; import AccessibleButton from "./AccessibleButton"; import Spinner from "./Spinner"; -import { doMaybeLocalRoomAction } from "../../../utils/direct-messages"; +import { doMaybeLocalRoomAction } from "../../../utils/local-room"; interface IProps extends IDialogProps { room: Room; diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts index dac06cf9a4c..35f0f5cab55 100644 --- a/src/components/views/location/shareLocation.ts +++ b/src/components/views/location/shareLocation.ts @@ -26,7 +26,7 @@ import Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; import SdkConfig from "../../../SdkConfig"; import { OwnBeaconStore } from "../../../stores/OwnBeaconStore"; -import { doMaybeLocalRoomAction } from "../../../utils/direct-messages"; +import { doMaybeLocalRoomAction } from "../../../utils/local-room"; export enum LocationShareType { Own = 'Own', diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index a2de1585f32..b176c7961c3 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -58,7 +58,7 @@ import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } fr import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { addReplyToMessageContent } from '../../../utils/Reply'; -import { doMaybeLocalRoomAction } from '../../../utils/direct-messages'; +import { doMaybeLocalRoomAction } from '../../../utils/local-room'; // Merges favouring the given relation export function attachRelation(content: IContent, relation?: IEventRelation): void { diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 9885b8e0229..29e9eacfe9c 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -39,7 +39,7 @@ import { NotificationColor } from "../../../stores/notifications/NotificationCol import InlineSpinner from "../elements/InlineSpinner"; import { PlaybackManager } from "../../../audio/PlaybackManager"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { doMaybeLocalRoomAction } from "../../../utils/direct-messages"; +import { doMaybeLocalRoomAction } from "../../../utils/local-room"; interface IProps { room: Room; diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index e2de5555749..d93a653fbbc 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -45,7 +45,7 @@ import { watchPosition, } from "../utils/beacon"; import { getCurrentPosition } from "../utils/beacon"; -import { doMaybeLocalRoomAction } from "../utils/direct-messages"; +import { doMaybeLocalRoomAction } from "../utils/local-room"; const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconInfoOwner === userId; diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 4698a29fcd4..2d4674563c2 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -30,7 +30,6 @@ import { isJoinedOrNearlyJoined } from "./membership"; import dis from "../dispatcher/dispatcher"; import { privateShouldBeEncrypted } from "./rooms"; import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; -import { MatrixClientPeg } from "../MatrixClientPeg"; export function findDMForUser(client: MatrixClient, userId: string): Room { const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); @@ -57,7 +56,7 @@ export function findDMForUser(client: MatrixClient, userId: string): Room { } } -export function findDMRoom(client: MatrixClient, targets: Member[]): Room | null { +function findDMRoom(client: MatrixClient, targets: Member[]): Room | null { const targetIds = targets.map(t => t.userId); let existingRoom: Room; if (targetIds.length === 1) { @@ -247,24 +246,6 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L localRoom.state = LocalRoomState.CREATED; } -export async function doMaybeLocalRoomAction( - roomId: string, - fn: (actualRoomId: string) => Promise, - client?: MatrixClient, -): Promise { - if (roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { - return new Promise((resolve, reject) => { - client = client ?? MatrixClientPeg.get(); - const room = client.getRoom(roomId) as LocalRoom; - room.afterCreateCallbacks.push((newRoomId: string) => { - fn(newRoomId).then(resolve).catch(reject); - }); - }); - } - - return fn(roomId); -} - /** * Start a DM. * diff --git a/src/utils/local-room.ts b/src/utils/local-room.ts new file mode 100644 index 00000000000..22cb66e6b73 --- /dev/null +++ b/src/utils/local-room.ts @@ -0,0 +1,43 @@ +/* +Copyright 2022 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 { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import defaultDispatcher from "../dispatcher/dispatcher"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; + +export async function doMaybeLocalRoomAction( + roomId: string, + fn: (actualRoomId: string) => Promise, + client?: MatrixClient, +): Promise { + if (roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { + return new Promise((resolve, reject) => { + client = client ?? MatrixClientPeg.get(); + const room = client.getRoom(roomId) as LocalRoom; + room.afterCreateCallbacks.push((newRoomId: string) => { + fn(newRoomId).then(resolve).catch(reject); + }); + defaultDispatcher.dispatch({ + action: "local_room_event", + roomId: room.roomId, + }); + }); + } + + return fn(roomId); +} From d14868bb37cda3a133a71a6afb2b76c28805f694 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 2 Jun 2022 13:05:05 +0200 Subject: [PATCH 33/73] Implement minimal local room view --- res/css/structures/_RoomView.scss | 4 + res/css/structures/_ScrollPanel.scss | 4 - src/components/structures/RoomView.tsx | 160 +++++++++++++++++++------ 3 files changed, 125 insertions(+), 43 deletions(-) diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index e3f3d37dbe2..7d8f5e9f09a 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -183,6 +183,10 @@ limitations under the License. box-sizing: border-box; } +.mx_RoomView--local .mx_ScrollPanel .mx_RoomView_MessageList { + justify-content: center; +} + .mx_RoomView_MessageList li { clear: both; } diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss index 60555b15c23..a668594bba6 100644 --- a/res/css/structures/_ScrollPanel.scss +++ b/res/css/structures/_ScrollPanel.scss @@ -25,7 +25,3 @@ limitations under the License. contain-intrinsic-size: 50px; } } - -.mx_RoomView_local .mx_ScrollPanel .mx_RoomView_MessageList { - justify-content: center; -} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 3a8389f8a1d..f878fbfb3e8 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -20,7 +20,7 @@ limitations under the License. // TODO: This component is enormous! There's several things which could stand-alone: // - Search results component -import React, { createRef, ReactNode } from 'react'; +import React, { createRef, ReactNode, useContext } from 'react'; import classNames from 'classnames'; import { IRecommendedVersion, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; @@ -112,6 +112,8 @@ import { FocusComposerPayload } from '../../dispatcher/payloads/FocusComposerPay import { haveRendererForEvent } from "../../events/EventTileFactory"; import { LocalRoom, LocalRoomState } from '../../models/LocalRoom'; import { createRoomFromLocalRoom } from '../../utils/direct-messages'; +import NewRoomIntro from '../views/rooms/NewRoomIntro'; +import EncryptionEvent from '../views/messages/EncryptionEvent'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -222,6 +224,98 @@ export interface IRoomState { narrow: boolean; } +interface ILocalRoomViewProps { + resizeNotifier: ResizeNotifier; + permalinkCreator: RoomPermalinkCreator; +} + +function LocalRoomView(props: ILocalRoomViewProps) { + const context = useContext(RoomContext); + const encryptionEvent = context.room.currentState.getStateEvents(EventType.RoomEncryption)[0]; + let encryptionTile: ReactNode; + + if (encryptionEvent) { + encryptionTile = ; + } + + return ( +
+ + +
+
+ + { encryptionTile } + + +
+ +
+
+
+ ); +} + +interface ILocalRoomCreateLoaderProps { + names: string; + resizeNotifier: ResizeNotifier; +} + +function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps) { + const context = useContext(RoomContext); + const text = _t("We're creating a room with %(names)s", { names: props.names }); + return ( +
+ + +
+
+ +
+ { text } +
+
+
+
+
+ ); +} + export class RoomView extends React.Component { private readonly dispatcherRef: string; private readonly roomStoreToken: EventSubscription; @@ -1487,7 +1581,7 @@ export class RoomView extends React.Component { searchResult={result} searchHighlights={this.state.searchHighlights} resultLink={resultLink} - permalinkCreator={this.getPermalinkCreatorForRoom(room)} + permalinkCreator={this.permalinkCreator} onHeightChanged={onHeightChanged} />); } @@ -1771,43 +1865,32 @@ export class RoomView extends React.Component { return !this.viewsLocalRoom && !this.state.isPeeking; } - private renderLocalRoomviewLoader(): ReactNode { - const text = _t("We're creating a room with %(names)s", { - names: this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()), - }); - return ( -
- - -
- -
- { text } -
-
-
-
- ); + private get permalinkCreator(): RoomPermalinkCreator { + return this.getPermalinkCreatorForRoom(this.state.room); } render() { if (this.state.room instanceof LocalRoom && this.state.room.state === LocalRoomState.CREATING) { - return this.renderLocalRoomviewLoader(); + const names = this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()); + return ( + + + + ); + } + + if (this.state.room instanceof LocalRoom) { + return ( + + + + ); } if (!this.state.room) { @@ -2067,7 +2150,7 @@ export class RoomView extends React.Component { e2eStatus={this.state.e2eStatus} resizeNotifier={this.props.resizeNotifier} replyToEvent={this.state.replyToEvent} - permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} + permalinkCreator={this.permalinkCreator} />; } @@ -2133,7 +2216,7 @@ export class RoomView extends React.Component { showUrlPreview={this.state.showUrlPreview} className={this.messagePanelClassNames} membersLoaded={this.state.membersLoaded} - permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} + permalinkCreator={this.permalinkCreator} resizeNotifier={this.props.resizeNotifier} showReactions={true} layout={this.state.layout} @@ -2163,7 +2246,7 @@ export class RoomView extends React.Component { ? : null; @@ -2174,7 +2257,6 @@ export class RoomView extends React.Component { const mainClasses = classNames("mx_RoomView", { mx_RoomView_inCall: Boolean(activeCall), mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Video, - mx_RoomView_local: this.viewsLocalRoom, }); const showChatEffects = SettingsStore.getValue('showChatEffects'); From 91004d21869f1ef55a805d02b45f03352244f89c Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 2 Jun 2022 16:05:43 +0200 Subject: [PATCH 34/73] Revert unnecessary changes --- src/ContentMessages.ts | 2 +- src/components/structures/LoggedInView.tsx | 1 + src/components/structures/MatrixChat.tsx | 19 +++++++--------- src/components/structures/MessagePanel.tsx | 10 +++------ src/components/structures/RoomView.tsx | 9 ++------ src/components/views/dialogs/InviteDialog.tsx | 4 ++-- .../views/elements/PollCreateDialog.tsx | 6 ----- .../views/location/shareLocation.ts | 1 - .../views/rooms/MessageComposerButtons.tsx | 9 ++++---- .../views/rooms/VoiceRecordComposerTile.tsx | 12 +--------- .../payloads/ViewLocalRoomPayload.ts | 22 ------------------- src/stores/OwnBeaconStore.ts | 8 ++----- src/utils/EventRenderingUtils.ts | 16 -------------- src/utils/direct-messages.ts | 3 --- 14 files changed, 24 insertions(+), 98 deletions(-) delete mode 100644 src/dispatcher/payloads/ViewLocalRoomPayload.ts diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index a047db8a202..9ab676bbe6d 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -470,7 +470,7 @@ export default class ContentMessages { } } - public sendContentToRoom( + private sendContentToRoom( file: File, roomId: string, relation: IEventRelation | undefined, diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index ba02a38eb8f..de60ca71fa1 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -619,6 +619,7 @@ class LoggedInView extends React.Component { render() { let pageElement; + switch (this.props.page_type) { case PageTypes.RoomView: pageElement = { event.getRoomId() === this.state.currentRoomId ) { // re-view the current room so we can update alias/id in the URL properly - this.viewRoom( - { - action: Action.ViewRoom, - room_id: this.state.currentRoomId, - metricsTrigger: undefined, // room doesn't change - }, - PageType.RoomView, - ); + this.viewRoom({ + action: Action.ViewRoom, + room_id: this.state.currentRoomId, + metricsTrigger: undefined, // room doesn't change + }); } break; } @@ -675,7 +672,7 @@ export default class MatrixChat extends React.PureComponent { // known to be in (eg. user clicks on a room in the recents panel), supply the ID // If the user is clicking on a room in the context of the alias being presented // to them, supply the room alias. If both are supplied, the room ID will be ignored. - const promise = this.viewRoom(payload as ViewRoomPayload, PageType.RoomView); + const promise = this.viewRoom(payload as ViewRoomPayload); if (payload.deferred_action) { promise.then(() => { dis.dispatch(payload.deferred_action); @@ -873,7 +870,7 @@ export default class MatrixChat extends React.PureComponent { } // switch view to the given room - private async viewRoom(roomInfo: ViewRoomPayload, pageType: PageType) { + private async viewRoom(roomInfo: ViewRoomPayload) { this.focusComposer = true; if (roomInfo.room_alias) { @@ -938,7 +935,7 @@ export default class MatrixChat extends React.PureComponent { this.setState({ view: Views.LOGGED_IN, currentRoomId: roomInfo.room_id || null, - page_type: pageType, + page_type: PageType.RoomView, threepidInvite: roomInfo.threepid_invite, roomOobData: roomInfo.oob_data, forceTimeline: roomInfo.forceTimeline, diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 45ffef634fd..f0344727b60 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -52,7 +52,7 @@ import Spinner from "../views/elements/Spinner"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import EditorStateTransfer from "../../utils/EditorStateTransfer"; import { Action } from '../../dispatcher/actions'; -import { getEventDisplayInfo, shouldRenderEventTiles } from "../../utils/EventRenderingUtils"; +import { getEventDisplayInfo } from "../../utils/EventRenderingUtils"; import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker"; import { haveRendererForEvent } from "../../events/EventTileFactory"; import { editorRoomKey } from "../../Editing"; @@ -719,10 +719,6 @@ export default class MessagePanel extends React.Component { nextEvent?: MatrixEvent, nextEventWithTile?: MatrixEvent, ): ReactNode[] { - if (!this.showHiddenEvents && !shouldRenderEventTiles(mxEv)) { - return []; - } - const ret = []; const isEditing = this.props.editState?.getEvent().getId() === mxEv.getId(); @@ -1164,8 +1160,6 @@ class CreationGrouper extends BaseGrouper { )); } - ret.push(); - const eventTiles = this.events.map((e) => { // In order to prevent DateSeparators from appearing in the expanded form // of GenericEventListSummary, render each member event as if the previous @@ -1185,6 +1179,8 @@ class CreationGrouper extends BaseGrouper { summaryText = _t("%(creator)s created and configured the room.", { creator }); } + ret.push(); + ret.push( { // Let the staus bar handle this return; } - }, - ); + }); } private onSearch = (term: string, scope: SearchScope) => { @@ -1861,10 +1860,6 @@ export class RoomView extends React.Component { return this.state.room instanceof LocalRoom; } - private get manageReadMarkers(): boolean { - return !this.viewsLocalRoom && !this.state.isPeeking; - } - private get permalinkCreator(): RoomPermalinkCreator { return this.getPermalinkCreatorForRoom(this.state.room); } @@ -2204,7 +2199,7 @@ export class RoomView extends React.Component { showReadReceipts={this.state.showReadReceipts} manageReadReceipts={!this.state.isPeeking} sendReadReceiptOnLoad={!this.state.wasContextSwitch} - manageReadMarkers={this.manageReadMarkers} + manageReadMarkers={!this.state.isPeeking} hidden={hideMessagePanel} highlightedEventId={highlightedEventId} eventId={this.state.initialEventId} diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 73c235d3024..2657382936a 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -570,9 +570,9 @@ export default class InviteDialog extends React.PureComponent { try { - const client = MatrixClientPeg.get(); + const cli = MatrixClientPeg.get(); const targets = this.convertFilter(); - startDmOnFirstMessage(client, targets); + startDmOnFirstMessage(cli, targets); this.props.onFinished(true); } catch (err) { logger.error(err); diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx index a7fa2dbae34..c5f5737d0a1 100644 --- a/src/components/views/elements/PollCreateDialog.tsx +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -25,7 +25,6 @@ import { PollStartEvent, } from "matrix-events-sdk"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { MatrixClient } from "matrix-js-sdk/src/client"; import ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal"; import { IDialogProps } from "../dialogs/IDialogProps"; @@ -42,7 +41,6 @@ interface IProps extends IDialogProps { room: Room; threadId?: string; editingMxEvent?: MatrixEvent; // Truthy if we are editing an existing poll - mxClient?: MatrixClient; } enum FocusTarget { @@ -163,10 +161,6 @@ export default class PollCreateDialog extends ScrollableBaseModal = (props: IProps) => { uploadButton(), // props passed via UploadButtonContext showStickersButton(props), voiceRecordingButton(props, narrow), - props.showPollsButton && pollButton(room, props.relation, matrixClient), + props.showPollsButton && pollButton(room, props.relation), showLocationButton(props, room, roomId, matrixClient), ]; } else { @@ -87,7 +87,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { moreButtons = [ showStickersButton(props), voiceRecordingButton(props, narrow), - props.showPollsButton && pollButton(room, props.relation, matrixClient), + props.showPollsButton && pollButton(room, props.relation), showLocationButton(props, room, roomId, matrixClient), ]; } @@ -295,8 +295,8 @@ function voiceRecordingButton(props: IProps, narrow: boolean): ReactElement { ); } -function pollButton(room: Room, relation?: IEventRelation, mxClient?: MatrixClient): ReactElement { - return ; +function pollButton(room: Room, relation?: IEventRelation): ReactElement { + return ; } interface IPollButtonProps { @@ -339,7 +339,6 @@ class PollButton extends React.PureComponent { { room: this.props.room, threadId, - mxClient: this.props.mxClient, }, 'mx_CompoundDialog', false, // isPriorityModal diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 29e9eacfe9c..88dff92215e 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -19,7 +19,6 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { MsgType } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; import { Optional } from "matrix-events-sdk"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { _t } from "../../../languageHandler"; @@ -38,7 +37,6 @@ import { StaticNotificationState } from "../../../stores/notifications/StaticNot import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import InlineSpinner from "../elements/InlineSpinner"; import { PlaybackManager } from "../../../audio/PlaybackManager"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { doMaybeLocalRoomAction } from "../../../utils/local-room"; interface IProps { @@ -55,9 +53,6 @@ interface IState { * Container tile for rendering the voice message recorder in the composer. */ export default class VoiceRecordComposerTile extends React.PureComponent { - public static contextType = MatrixClientContext; - public context: React.ContextType; - public constructor(props) { super(props); @@ -142,8 +137,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent this.matrixClient.sendMessage(actualRoomId, content), - this.matrixClient, + (actualRoomId: string) => MatrixClientPeg.get().sendMessage(actualRoomId, content), ); } catch (e) { logger.error("Error sending voice message:", e); @@ -155,10 +149,6 @@ export default class VoiceRecordComposerTile extends React.PureComponent { public createLiveBeacon = async ( roomId: Room['roomId'], beaconInfoContent: MBeaconInfoEventContent, - matrixClient?: MatrixClient, ): Promise => { - matrixClient = matrixClient || this.matrixClient; - // eslint-disable-next-line camelcase const { event_id } = await doMaybeLocalRoomAction( roomId, - (actualRoomId: string) => matrixClient.unstable_createLiveBeacon(actualRoomId, beaconInfoContent), - matrixClient, + (actualRoomId: string) => this.matrixClient.unstable_createLiveBeacon(actualRoomId, beaconInfoContent), + this.matrixClient, ); storeLocallyCreateBeaconEventId(event_id); diff --git a/src/utils/EventRenderingUtils.ts b/src/utils/EventRenderingUtils.ts index ab6edc95079..ced38748049 100644 --- a/src/utils/EventRenderingUtils.ts +++ b/src/utils/EventRenderingUtils.ts @@ -23,11 +23,6 @@ import SettingsStore from "../settings/SettingsStore"; import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils"; -import { LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; - -const LOCAL_ROOM_NO_TILE_EVENTS = [ - EventType.RoomMember, -]; export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: boolean, hideEvent?: boolean): { isInfoMessage: boolean; @@ -113,14 +108,3 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: bool isSeeingThroughMessageHiddenForModeration, }; } - -export function shouldRenderEventTiles(mxEvent: MatrixEvent): boolean { - if ( - mxEvent.getRoomId().startsWith(LOCAL_ROOM_ID_PREFIX) - && LOCAL_ROOM_NO_TILE_EVENTS.includes(mxEvent.getType() as EventType) - ) { - return false; - } - - return true; -} diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 2d4674563c2..53b8f2fe3fa 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -305,10 +305,7 @@ export async function startDm(client: MatrixClient, targets: Member[]): Promise< ); } - // MiW - //createRoomOptions.andView = false; createRoomOptions.spinner = false; - return createRoom(createRoomOptions); } From 307da0e6f287bf48678ed88ec8b13d557ea800e5 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 2 Jun 2022 16:10:46 +0200 Subject: [PATCH 35/73] Update i18n files --- src/i18n/strings/en_EN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 27019a50d06..3694d5a0cad 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3097,13 +3097,13 @@ "You can select all or individual messages to retry or delete": "You can select all or individual messages to retry or delete", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", + "We're creating a room with %(names)s": "We're creating a room with %(names)s", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", "Search failed": "Search failed", "Server may be unavailable, overloaded, or search timed out :(": "Server may be unavailable, overloaded, or search timed out :(", "No more results": "No more results", "Failed to reject invite": "Failed to reject invite", - "We're creating a room with %(names)s": "We're creating a room with %(names)s", "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.", "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.", "Joining": "Joining", From 7e96add190439516a9a37f6c13591f851571d71d Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 7 Jun 2022 15:45:58 +0200 Subject: [PATCH 36/73] Fix file uploads --- src/ContentMessages.ts | 20 ++++++++++++++------ src/components/structures/RoomView.tsx | 15 ++++++++------- src/models/LocalRoom.ts | 5 +++++ src/utils/direct-messages.ts | 9 ++------- src/utils/local-room.ts | 9 +++++++-- 5 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 9ab676bbe6d..b9aa84dd21e 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -431,7 +431,19 @@ export default class ContentMessages { uploadAll = true; } } - promBefore = this.sendContentToRoom(file, roomId, relation, matrixClient, replyToEvent, promBefore); + + const loopPromiseBefore = promBefore; + promBefore = doMaybeLocalRoomAction( + roomId, + (actualRoomId) => this.sendContentToRoom( + file, + actualRoomId, + relation, + matrixClient, + replyToEvent, + loopPromiseBefore, + ), + ); } if (replyToEvent) { @@ -575,11 +587,7 @@ export default class ContentMessages { const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null; - const prom = doMaybeLocalRoomAction( - roomId, - (actualRoomId: string) => matrixClient.sendMessage(actualRoomId, threadId, content), - matrixClient, - ); + const prom = matrixClient.sendMessage(roomId, threadId, content); if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { prom.then(resp => { sendRoundTripMetric(matrixClient, roomId, resp.event_id); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 1b5fe1ac6cd..0c71c2a7dc5 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -20,7 +20,7 @@ limitations under the License. // TODO: This component is enormous! There's several things which could stand-alone: // - Search results component -import React, { createRef, ReactNode, useContext } from 'react'; +import React, { createRef, ReactNode, RefObject, useContext } from 'react'; import classNames from 'classnames'; import { IRecommendedVersion, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; @@ -227,6 +227,8 @@ export interface IRoomState { interface ILocalRoomViewProps { resizeNotifier: ResizeNotifier; permalinkCreator: RoomPermalinkCreator; + roomView: RefObject; + onFileDrop: (dataTransfer: DataTransfer) => Promise; } function LocalRoomView(props: ILocalRoomViewProps) { @@ -256,7 +258,8 @@ function LocalRoomView(props: ILocalRoomViewProps) { showButtons={false} enableRoomOptionsMenu={false} /> -
+
+
-
+
); @@ -855,10 +858,6 @@ export class RoomView extends React.Component { for (const watcher of this.settingWatchers) { SettingsStore.unwatchSetting(watcher); } - - if (this.state.room instanceof LocalRoom) { - this.context.store.removeRoom(this.state.room.roomId); - } } private onRightPanelStoreUpdate = () => { @@ -1883,6 +1882,8 @@ export class RoomView extends React.Component { ); diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index 65e693d206a..bada4504700 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -31,6 +31,7 @@ export enum LocalRoomState { * Its main purpose is to be used for temporary rooms when creating a DM. */ export class LocalRoom extends Room { + realRoomId: string; targets: Member[]; afterCreateCallbacks: Function[] = []; state: LocalRoomState = LocalRoomState.NEW; @@ -38,4 +39,8 @@ export class LocalRoom extends Room { public get isNew(): boolean { return this.state === LocalRoomState.NEW; } + + public get isCreated(): boolean { + return this.state === LocalRoomState.CREATED; + } } diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 53b8f2fe3fa..79cf0521805 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -226,22 +226,17 @@ async function applyAfterCreateCallbacks( localRoom.afterCreateCallbacks = []; } -/* -function timeout(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} -*/ - export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: LocalRoom) { if (!localRoom.isNew) { + // This action only makes sense for new local rooms. return; } localRoom.state = LocalRoomState.CREATING; client.emit(ClientEvent.Room, localRoom); - //await timeout(600000); const roomId = await startDm(client, localRoom.targets); + localRoom.realRoomId = roomId; await applyAfterCreateCallbacks(localRoom, roomId); localRoom.state = LocalRoomState.CREATED; } diff --git a/src/utils/local-room.ts b/src/utils/local-room.ts index 22cb66e6b73..d2a8aa14896 100644 --- a/src/utils/local-room.ts +++ b/src/utils/local-room.ts @@ -26,9 +26,14 @@ export async function doMaybeLocalRoomAction( client?: MatrixClient, ): Promise { if (roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { + client = client ?? MatrixClientPeg.get(); + const room = client.getRoom(roomId) as LocalRoom; + + if (room.isCreated) { + return fn(room.realRoomId); + } + return new Promise((resolve, reject) => { - client = client ?? MatrixClientPeg.get(); - const room = client.getRoom(roomId) as LocalRoom; room.afterCreateCallbacks.push((newRoomId: string) => { fn(newRoomId).then(resolve).catch(reject); }); From 2b79f0ed205841c46a339a1d5f6b87786743b07a Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 13 Jun 2022 10:46:16 +0200 Subject: [PATCH 37/73] Extend cypress test; wait for local room to be ready --- cypress/integration/5-threads/threads.spec.ts | 2 +- cypress/integration/6-spaces/spaces.spec.ts | 4 +- cypress/integration/7-crypto/crypto.spec.ts | 122 ++++++++++-------- cypress/support/bot.ts | 46 ++++++- cypress/support/client.ts | 20 +++ .../security/CreateSecretStorageDialog.tsx | 1 + src/components/structures/UserMenu.tsx | 2 + .../auth/InteractiveAuthEntryComponents.tsx | 1 + src/components/views/dialogs/InviteDialog.tsx | 2 + .../views/right_panel/RoomHeaderButtons.tsx | 4 +- .../views/rooms/BasicMessageComposer.tsx | 1 + src/components/views/rooms/RoomList.tsx | 1 + .../views/settings/SecureBackupPanel.tsx | 7 +- src/models/LocalRoom.ts | 1 + src/utils/direct-messages.ts | 57 +++++++- .../src/scenarios/e2e-encryption.ts | 1 + .../src/usecases/create-room.ts | 6 +- 17 files changed, 206 insertions(+), 72 deletions(-) diff --git a/cypress/integration/5-threads/threads.spec.ts b/cypress/integration/5-threads/threads.spec.ts index 226e63576d8..ea2512d8581 100644 --- a/cypress/integration/5-threads/threads.spec.ts +++ b/cypress/integration/5-threads/threads.spec.ts @@ -72,7 +72,7 @@ describe("Threads", () => { it("should be usable for a conversation", () => { let bot: MatrixClient; - cy.getBot(synapse, "BotBob").then(_bot => { + cy.getBot(synapse, { displayName: "BobBot" }).then(_bot => { bot = _bot; }); diff --git a/cypress/integration/6-spaces/spaces.spec.ts b/cypress/integration/6-spaces/spaces.spec.ts index e5c03229bf2..8adfb34c51c 100644 --- a/cypress/integration/6-spaces/spaces.spec.ts +++ b/cypress/integration/6-spaces/spaces.spec.ts @@ -167,7 +167,7 @@ describe("Spaces", () => { it("should allow user to invite another to a space", () => { let bot: MatrixClient; - cy.getBot(synapse, "BotBob").then(_bot => { + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { bot = _bot; }); @@ -202,7 +202,7 @@ describe("Spaces", () => { }); getSpacePanelButton("My Space").should("exist"); - cy.getBot(synapse, "BotBob").then({ timeout: 10000 }, async bot => { + cy.getBot(synapse, { displayName: "BotBob" }).then({ timeout: 10000 }, async bot => { const { room_id: roomId } = await bot.createRoom(spaceCreateOptions("Space Space")); await bot.invite(roomId, user.userId); }); diff --git a/cypress/integration/7-crypto/crypto.spec.ts b/cypress/integration/7-crypto/crypto.spec.ts index 6f1f7aa6c89..04b3dea044b 100644 --- a/cypress/integration/7-crypto/crypto.spec.ts +++ b/cypress/integration/7-crypto/crypto.spec.ts @@ -14,73 +14,85 @@ See the License for the specific language governing permissions and limitations under the License. */ -/// +import { CryptoEvent } from "matrix-js-sdk/src/crypto"; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import { SynapseInstance } from "../../plugins/synapsedocker"; +import { UserCredentials } from "../../support/login"; -function waitForEncryption(cli: MatrixClient, roomId: string, win: Cypress.AUTWindow): Promise { - return new Promise(resolve => { - const onEvent = () => { - cli.crypto.cryptoStore.getEndToEndRooms(null, (result) => { - if (result[roomId]) { - cli.off(win.matrixcs.ClientEvent.Event, onEvent); - resolve(); - } - }); +const waitForVerificationRequest = (cli: MatrixClient): Promise => { + return new Promise(resolve => { + const onVerificationRequestEvent = (request: VerificationRequest) => { + cli.off(CryptoEvent.VerificationRequest, onVerificationRequestEvent); + resolve(request); }; - cli.on(win.matrixcs.ClientEvent.Event, onEvent); + cli.on(CryptoEvent.VerificationRequest, onVerificationRequestEvent); }); -} +}; -describe("Cryptography", () => { - beforeEach(() => { - cy.startSynapse("default").as('synapse').then( - synapse => cy.initTestUser(synapse, "Alice"), - ); - }); +describe("Starting a new DM", () => { + let credentials: UserCredentials; + let synapse: SynapseInstance; + let bob: MatrixClient; - afterEach(() => { - cy.get('@synapse').then(synapse => cy.stopSynapse(synapse)); - }); + const startDMWithBob = () => { + cy.get('[data-test-id="create-chat-button"]').click(); + cy.get('[data-test-id="invite-dialog-input"]').type(bob.getUserId()); + cy.contains(".mx_InviteDialog_roomTile_name", "Bob").click(); + cy.contains(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name", "Bob").should("exist"); + cy.get('[data-test-id="invite-dialog-go-button"]').click(); + cy.get('[data-test-id="basic-message-composer-input"]').should("have.focus").type("Hey!{enter}"); + cy.get(".mx_GenericEventListSummary_toggle").click(); + cy.contains(".mx_TextualEvent", "Alice invited Bob").should("exist"); + }; - it("should receive and decrypt encrypted messages", () => { - cy.get('@synapse').then(synapse => cy.getBot(synapse, "Beatrice").as('bot')); + const checkEncryption = () => { + cy.contains(".mx_RoomView_body .mx_cryptoEvent", "Encryption enabled").should("exist"); + // @todo verify this message is really encrypted (e.g. by inspecting the message source) + cy.contains(".mx_EventTile_body", "Hey!") + .closest(".mx_EventTile_line") + .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); + }; - cy.createRoom({ - initial_state: [ - { - type: "m.room.encryption", - state_key: '', - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - }).as('roomId'); + const joinBob = () => { + cy.botJoinRoomByName(bob, "Alice").as("bobsRoom"); + cy.contains(".mx_TextualEvent", "Bob joined the room").should("exist"); + }; - cy.all([ - cy.get('@bot'), - cy.get('@roomId'), - cy.window(), - ]).then(([bot, roomId, win]) => { - cy.inviteUser(roomId, bot.getUserId()); - cy.wrap( - waitForEncryption( - bot, roomId, win, - ).then(() => bot.sendMessage(roomId, { - body: "Top secret message", - msgtype: "m.text", - })), - ); - cy.visit("/#/room/" + roomId); + const verify = () => { + const bobsVerificationRequestPromise = waitForVerificationRequest(bob); + cy.get('[data-test-id="room-info-button"]').click(); + cy.get(".mx_RoomSummaryCard_icon_people").click(); + cy.contains(".mx_EntityTile_name", "Bob").click(); + cy.contains(".mx_UserInfo_verifyButton", "Verify").click(), + cy.wrap(bobsVerificationRequestPromise).then((verificationRequest: VerificationRequest) => { + // ↓ doesn't work + verificationRequest.accept(); }); + }; - cy.get(".mx_RoomView_body .mx_cryptoEvent").should("contain", "Encryption enabled"); + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + cy.initTestUser(synapse, "Alice").then(_credentials => { + credentials = _credentials; + }); + cy.getBot(synapse, { displayName: "Bob", autoAcceptInvites: false }).then(_bob => { + bob = _bob; + }); + }); + }); - cy.get(".mx_EventTile_body") - .contains("Top secret message") - .closest(".mx_EventTile_line") - .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should work, be e2e-encrypted, enable verification", () => { + cy.setupKeyBackup(credentials.password); + startDMWithBob(); + checkEncryption(); + joinBob(); + verify(); }); }); diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index e3bdf49d312..9f4557f48e6 100644 --- a/cypress/support/bot.ts +++ b/cypress/support/bot.ts @@ -18,11 +18,26 @@ limitations under the License. import request from "browser-request"; -import type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { SynapseInstance } from "../plugins/synapsedocker"; import { MockStorage } from "./storage"; import Chainable = Cypress.Chainable; +interface ICreateBotOpts { + /** + * Whether the bot should automatically accept all invites. + */ + autoAcceptInvites?: boolean; + /** + * The display name to give to that bot user + */ + displayName?: string; +} + +const defaultCreateBotOptions = { + autoAcceptInvites: true, +} as ICreateBotOpts; + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { @@ -30,17 +45,34 @@ declare global { /** * Returns a new Bot instance * @param synapse the instance on which to register the bot user - * @param displayName the display name to give to the bot user + * @param opts create bot options */ - getBot(synapse: SynapseInstance, displayName?: string): Chainable; + getBot(synapse: SynapseInstance, opts: ICreateBotOpts): Chainable; + botJoinRoom(bot: MatrixClient, roomId: string): Chainable; + botJoinRoomByName(bot: MatrixClient, roomName: string): Chainable; } } } -Cypress.Commands.add("getBot", (synapse: SynapseInstance, displayName?: string): Chainable => { +Cypress.Commands.add("botJoinRoom", (bot: MatrixClient, roomId: string): Chainable => { + return cy.wrap(bot.joinRoom(roomId)); +}); + +Cypress.Commands.add("botJoinRoomByName", (bot: MatrixClient, roomName: string): Chainable => { + const room = bot.getRooms().find((r) => r.getDefaultRoomName(bot.getUserId()) === roomName); + + if (room) { + return cy.botJoinRoom(bot, room.roomId); + } + + return cy.wrap(Promise.reject()); +}); + +Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: ICreateBotOpts): Chainable => { + opts = Object.assign({}, defaultCreateBotOptions, opts); const username = Cypress._.uniqueId("userId_"); const password = Cypress._.uniqueId("password_"); - return cy.registerUser(synapse, username, password, displayName).then(credentials => { + return cy.registerUser(synapse, username, password, opts.displayName).then(credentials => { return cy.window({ log: false }).then(win => { const cli = new win.matrixcs.MatrixClient({ baseUrl: synapse.baseUrl, @@ -56,7 +88,9 @@ Cypress.Commands.add("getBot", (synapse: SynapseInstance, displayName?: string): cli.on(win.matrixcs.RoomMemberEvent.Membership, (event, member) => { if (member.membership === "invite" && member.userId === cli.getUserId()) { - cli.joinRoom(member.roomId); + if (opts.autoAcceptInvites) { + cli.joinRoom(member.roomId); + } } }); diff --git a/cypress/support/client.ts b/cypress/support/client.ts index 6a6a3932711..3791b029dfe 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -47,10 +47,30 @@ declare global { * @param userId the id of the user to invite */ inviteUser(roomId: string, userId: string): Chainable<{}>; + /** + * Sets up key backup + * @param password the user password + * @return recovery key + */ + setupKeyBackup(password: string): Chainable; } } } +Cypress.Commands.add("setupKeyBackup", (password: string): Chainable => { + cy.get('[data-test-id="user-menu-button"]').click(); + cy.get('[data-test-id="user-menu-security-item"]').click(); + cy.get('[data-test-id="set-up-secure-backup-button"]').click(); + cy.get('[data-test-id="dialog-primary-button"]').click(); + cy.get('[data-test-id="copy-recovery-key-button"]').click(); + cy.get('[data-test-id="dialog-primary-button"]:not([disabled])').click(); + cy.get('#mx_Field_2').type(password); + cy.get('[data-test-id="submit-password-button"]:not([disabled])').click(); + cy.contains('.mx_Dialog_title', 'Setting up keys').should('exist'); + cy.contains('.mx_Dialog_title', 'Setting up keys').should('not.exist'); + return; +}); + Cypress.Commands.add("getClient", (): Chainable => { return cy.window({ log: false }).then(win => win.mxMatrixClientPeg.matrixClient); }); diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index f58a8b2003d..6888112f326 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -740,6 +740,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.state.copied ? _t("Copied!") : _t("Copy") } diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index a9f072b21fa..8a62129bbc7 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -333,6 +333,7 @@ export default class UserMenu extends React.Component { iconClassName="mx_UserMenu_iconLock" label={_t("Security & Privacy")} onClick={(e) => this.onSettingsOpen(e, UserTab.Security)} + data-test-id="user-menu-security-item" /> { label={_t("User menu")} isExpanded={!!this.state.contextMenuPosition} onContextMenu={this.onContextMenu} + data-test-id="user-menu-button" >
); } diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 2657382936a..596eaec578e 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -1058,6 +1058,7 @@ export default class InviteDialog extends React.PureComponent 0)} autoComplete="off" placeholder={hasPlaceholder ? _t("Search") : null} + data-test-id="invite-dialog-input" /> ); return ( @@ -1314,6 +1315,7 @@ export default class InviteDialog extends React.PureComponent { buttonText } ; diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index cb661d00a13..f08185b4c41 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -265,7 +265,9 @@ export default class RoomHeaderButtons extends HeaderButtons { title={_t('Room Info')} isHighlighted={this.isPhase(ROOM_INFO_PHASES)} onClick={this.onRoomSummaryClicked} - analytics={['Right Panel', 'Room Summary Button', 'click']} />, + analytics={['Right Panel', 'Room Summary Button', 'click']} + data-test-id="room-info-button" + />, ); return <> diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index fd3f5eed3dc..e02df10f347 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -789,6 +789,7 @@ export default class BasicMessageEditor extends React.Component aria-activedescendant={activeDescendant} dir="auto" aria-disabled={this.props.disabled} + data-test-id="basic-message-composer-input" />
); } diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index a0632d763e1..eddce5e3293 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -186,6 +186,7 @@ const DmAuxButton = ({ tabIndex, dispatcher = defaultDispatcher }: IAuxButtonPro tooltipClassName="mx_RoomSublist_addRoomTooltip" aria-label={_t("Start chat")} title={_t("Start chat")} + data-test-id="create-chat-button" />; } diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index da5df6c7a71..78db9b9f731 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -408,7 +408,12 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {

{ _t("Back up your keys before signing out to avoid losing them.") }

; actions.push( - + { _t("Set up") } , ); diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index bada4504700..503d312e8b0 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -31,6 +31,7 @@ export enum LocalRoomState { * Its main purpose is to be used for temporary rooms when creating a DM. */ export class LocalRoom extends Room { + encrypted: boolean; realRoomId: string; targets: Member[]; afterCreateCallbacks: Function[] = []; diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 79cf0521805..2adfd5f27d7 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -129,6 +129,7 @@ export async function createDmLocalRoom( })); if (await determineCreateRoomEncryptionOption(client, targets)) { + localRoom.encrypted = true; events.push( new MatrixEvent({ event_id: `~${localRoom.roomId}:${client.makeTxnId()}`, @@ -226,6 +227,34 @@ async function applyAfterCreateCallbacks( localRoom.afterCreateCallbacks = []; } +/** + * Tests whether a room created based on a local room is ready. + */ +function isRoomReady( + client: MatrixClient, + localRoom: LocalRoom, +): boolean { + // not ready if no real room id exists + if (!localRoom.realRoomId) return false; + + const room = client.getRoom(localRoom.realRoomId); + // not ready if the room does not exist + if (!room) return false; + + // not ready if not all targets have been invited + if (room.getInvitedMemberCount() !== localRoom.targets.length) return false; + + const roomHistoryVisibilityEvents = room.currentState.getStateEvents(EventType.RoomHistoryVisibility); + // not ready if the room history has not been configured + if (roomHistoryVisibilityEvents.length === 0) return false; + + const roomEncryptionEvents = room.currentState.getStateEvents(EventType.RoomEncryption); + // not ready if encryption has not been configured (applies only to encrypted rooms) + if (localRoom.encrypted === true && roomEncryptionEvents.length === 0) return false; + + return true; +} + export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: LocalRoom) { if (!localRoom.isNew) { // This action only makes sense for new local rooms. @@ -235,10 +264,30 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L localRoom.state = LocalRoomState.CREATING; client.emit(ClientEvent.Room, localRoom); - const roomId = await startDm(client, localRoom.targets); - localRoom.realRoomId = roomId; - await applyAfterCreateCallbacks(localRoom, roomId); - localRoom.state = LocalRoomState.CREATED; + return new Promise((resolve) => { + let checkRoomStateInterval: number; + let stopgapTimeoutHandle: number; + + const finish = () => { + if (checkRoomStateInterval) clearInterval(checkRoomStateInterval); + if (stopgapTimeoutHandle) clearTimeout(stopgapTimeoutHandle); + + applyAfterCreateCallbacks(localRoom, localRoom.realRoomId).then(() => { + localRoom.state = LocalRoomState.CREATED; + resolve(); + }); + }; + + startDm(client, localRoom.targets).then((roomId) => { + localRoom.realRoomId = roomId; + if (isRoomReady(client, localRoom)) finish(); + stopgapTimeoutHandle = setTimeout(finish, 5000); + // polling the room state is not as beautiful as listening on the events, but it is more reliable + checkRoomStateInterval = setInterval(() => { + if (isRoomReady(client, localRoom)) finish(); + }, 500); + }); + }); } /** diff --git a/test/end-to-end-tests/src/scenarios/e2e-encryption.ts b/test/end-to-end-tests/src/scenarios/e2e-encryption.ts index c9e62232217..15b0877e19b 100644 --- a/test/end-to-end-tests/src/scenarios/e2e-encryption.ts +++ b/test/end-to-end-tests/src/scenarios/e2e-encryption.ts @@ -29,6 +29,7 @@ import { measureStart, measureStop } from '../util'; export async function e2eEncryptionScenarios(alice: ElementSession, bob: ElementSession) { console.log(" creating an e2e encrypted DM and join through invite:"); + // to be replaced by Cypress crypto test return; await createDm(bob, ['@alice:localhost']); await checkRoomSettings(bob, { encryption: true }); // for sanity, should be e2e-by-default diff --git a/test/end-to-end-tests/src/usecases/create-room.ts b/test/end-to-end-tests/src/usecases/create-room.ts index b0e7738fb4e..c3e76d27f23 100644 --- a/test/end-to-end-tests/src/usecases/create-room.ts +++ b/test/end-to-end-tests/src/usecases/create-room.ts @@ -19,6 +19,7 @@ import * as puppeteer from "puppeteer"; import { measureStart, measureStop } from '../util'; import { ElementSession } from "../session"; +import { sendMessage } from "./send-message"; export async function openRoomDirectory(session: ElementSession): Promise { const roomDirectoryButton = await session.query('.mx_LeftPanel_exploreButton'); @@ -81,8 +82,9 @@ export async function createDm(session: ElementSession, invitees: string[]): Pro const goButton = await session.query('.mx_InviteDialog_goButton'); await goButton.click(); - await session.query('.mx_MessageComposer'); - session.log.done(); + sendMessage(session, 'Hi'); + await session.query('.mx_EventTile_body'); + session.log.done(); await measureStop(session, "mx_CreateDM"); } From 3adc8462c695988d474bd0226300429891d82ae0 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 13 Jun 2022 11:15:17 +0200 Subject: [PATCH 38/73] After merge cleanup --- cypress/integration/10-user-view/user-view.spec.ts | 2 +- res/css/structures/_RoomView.scss | 3 ++- src/components/views/rooms/RoomHeader.tsx | 1 - src/utils/direct-messages.ts | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cypress/integration/10-user-view/user-view.spec.ts b/cypress/integration/10-user-view/user-view.spec.ts index e98a1d47f41..30c3ff23ca9 100644 --- a/cypress/integration/10-user-view/user-view.spec.ts +++ b/cypress/integration/10-user-view/user-view.spec.ts @@ -27,7 +27,7 @@ describe("UserView", () => { synapse = data; cy.initTestUser(synapse, "Violet"); - cy.getBot(synapse, "Usman").as("bot"); + cy.getBot(synapse, { displayName: "Usman" }).as("bot"); }); }); diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 49afc6da1b1..4b29ad3209d 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -32,7 +32,8 @@ limitations under the License. position: relative; } -.mx_MainSplit_timeline { +.mx_MainSplit_timeline, +.mx_RoomView--local { .mx_MessageComposer_wrapper { margin: $spacing-8 $spacing-16; } diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 7a6534de299..81ed22e35bb 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -320,7 +320,6 @@ export default class RoomHeader extends React.Component { { topicElement } { betaPill } { buttons } -
diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 2adfd5f27d7..c5bb56856d0 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -108,7 +108,6 @@ export async function createDmLocalRoom( userId, { pendingEventOrdering: PendingEventOrdering.Detached, - unstableClientRelationAggregation: true, }, ); const events = []; From d6f7f50d9c7b7fb9171a46fbd7692614568c640a Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 13 Jun 2022 13:56:34 +0200 Subject: [PATCH 39/73] Clean up, fix Threads test --- cypress/integration/5-threads/threads.spec.ts | 2 +- src/components/views/rooms/MessageComposerButtons.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/cypress/integration/5-threads/threads.spec.ts b/cypress/integration/5-threads/threads.spec.ts index ea2512d8581..57d88a462c5 100644 --- a/cypress/integration/5-threads/threads.spec.ts +++ b/cypress/integration/5-threads/threads.spec.ts @@ -72,7 +72,7 @@ describe("Threads", () => { it("should be usable for a conversation", () => { let bot: MatrixClient; - cy.getBot(synapse, { displayName: "BobBot" }).then(_bot => { + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { bot = _bot; }); diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index e21f8e1bc8c..fe5c42ea261 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -302,7 +302,6 @@ function pollButton(room: Room, relation?: IEventRelation): ReactElement { interface IPollButtonProps { room: Room; relation?: IEventRelation; - mxClient?: MatrixClient; } class PollButton extends React.PureComponent { From 8216e131bf1d621c88bfbc2e712e62e65bc5d108 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 15 Jun 2022 09:43:30 +0200 Subject: [PATCH 40/73] Add local room create error handling --- res/css/structures/_RoomStatusBar.scss | 2 +- src/components/structures/RoomStatusBar.tsx | 32 ++------ src/components/structures/RoomView.tsx | 45 ++++++++-- .../UnsentMessagesRoomStatusBar.tsx | 55 +++++++++++++ src/models/LocalRoom.ts | 5 ++ src/utils/direct-messages.ts | 29 +++++-- test/LocalRoom-test.ts | 82 +++++++++++++++++++ 7 files changed, 210 insertions(+), 40 deletions(-) create mode 100644 src/components/structures/UnsentMessagesRoomStatusBar.tsx create mode 100644 test/LocalRoom-test.ts diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index a54ceae49e9..51fea7a49a0 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -138,7 +138,7 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/trashcan.svg'); } - &.mx_RoomStatusBar_unsentResendAllBtn { + &.mx_RoomStatusBar_unsentRetry { padding-left: 34px; // 28px from above, but +6px to account for the wider icon &::before { diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index a89f205a88e..b9a489f20f5 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -24,11 +24,11 @@ import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; import { messageForResourceLimitError } from '../../utils/ErrorUtils'; import { Action } from "../../dispatcher/actions"; -import NotificationBadge from "../views/rooms/NotificationBadge"; import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; import AccessibleButton from "../views/elements/AccessibleButton"; import InlineSpinner from "../views/elements/InlineSpinner"; import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { UnsentMessagesRoomStatusBar } from './UnsentMessagesRoomStatusBar'; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -240,7 +240,7 @@ export default class RoomStatusBar extends React.PureComponent { { _t("Delete all") } - + { _t("Retry all") } ; @@ -252,28 +252,12 @@ export default class RoomStatusBar extends React.PureComponent { ; } - return <> -
-
-
- -
-
-
- { title } -
-
- { _t("You can select all or individual messages to retry or delete") } -
-
-
- { buttonRow } -
-
-
- ; + return ; } public render(): JSX.Element { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 3030cc3f392..aa4cdbb95cd 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -20,7 +20,7 @@ limitations under the License. // TODO: This component is enormous! There's several things which could stand-alone: // - Search results component -import React, { createRef, ReactNode, RefObject, useContext } from 'react'; +import React, { createRef, ReactElement, ReactNode, RefObject, useContext } from 'react'; import classNames from 'classnames'; import { IRecommendedVersion, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; @@ -46,7 +46,7 @@ import ResizeNotifier from '../../utils/ResizeNotifier'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; import CallHandler, { CallHandlerEvent } from '../../CallHandler'; -import dis from '../../dispatcher/dispatcher'; +import dis, { defaultDispatcher } from '../../dispatcher/dispatcher'; import * as Rooms from '../../Rooms'; import eventSearch, { searchPagination } from '../../Searching'; import MainSplit from './MainSplit'; @@ -115,6 +115,8 @@ import { LocalRoom, LocalRoomState } from '../../models/LocalRoom'; import { createRoomFromLocalRoom } from '../../utils/direct-messages'; import NewRoomIntro from '../views/rooms/NewRoomIntro'; import EncryptionEvent from '../views/messages/EncryptionEvent'; +import { UnsentMessagesRoomStatusBar } from './UnsentMessagesRoomStatusBar'; +import { StaticNotificationState } from '../../stores/notifications/StaticNotificationState'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -235,6 +237,7 @@ interface ILocalRoomViewProps { function LocalRoomView(props: ILocalRoomViewProps) { const context = useContext(RoomContext); + const room = context.room as LocalRoom; const encryptionEvent = context.room.currentState.getStateEvents(EventType.RoomEncryption)[0]; let encryptionTile: ReactNode; @@ -242,6 +245,37 @@ function LocalRoomView(props: ILocalRoomViewProps) { encryptionTile = ; } + const onRetryClicked = () => { + room.state = LocalRoomState.NEW; + defaultDispatcher.dispatch({ + action: "local_room_event", + roomId: room.roomId, + }); + }; + + let statusBar: ReactElement; + let composer: ReactElement; + + if (room.isError) { + const buttons = ( + + { _t("Retry") } + + ); + + statusBar = ; + } else { + composer = ; + } + return (
@@ -271,11 +305,8 @@ function LocalRoomView(props: ILocalRoomViewProps) {
- + { statusBar } + { composer }
diff --git a/src/components/structures/UnsentMessagesRoomStatusBar.tsx b/src/components/structures/UnsentMessagesRoomStatusBar.tsx new file mode 100644 index 00000000000..25fbae7a733 --- /dev/null +++ b/src/components/structures/UnsentMessagesRoomStatusBar.tsx @@ -0,0 +1,55 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ReactElement } from "react"; + +import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; +import NotificationBadge from "../views/rooms/NotificationBadge"; + +interface IUnsentMessagesRoomStatusBarProps { + title: string; + description?: string; + notificationState: StaticNotificationState; + buttons: ReactElement; +} + +export const UnsentMessagesRoomStatusBar = (props: IUnsentMessagesRoomStatusBarProps): ReactElement => { + return ( +
+
+
+ +
+
+
+ { props.title } +
+ { + props.description && +
+ { props.description } +
+ } +
+
+ { props.buttons } +
+
+
+ ); +}; diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index 503d312e8b0..7dbdbefd34f 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -24,6 +24,7 @@ export enum LocalRoomState { NEW, // new local room; only known to the client CREATING, // real room is being created CREATED, // real room has been created via API; events applied + ERROR, // error during room creation } /** @@ -44,4 +45,8 @@ export class LocalRoom extends Room { public get isCreated(): boolean { return this.state === LocalRoomState.CREATED; } + + public get isError(): boolean { + return this.state === LocalRoomState.ERROR; + } } diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index c5bb56856d0..d40c4e808e6 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -20,6 +20,7 @@ import { EventType } from "matrix-js-sdk/src/matrix"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; +import { logger } from "matrix-js-sdk/src/logger"; import createRoom, { canEncryptToAllUsers } from "../createRoom"; import { Action } from "../dispatcher/actions"; @@ -267,6 +268,11 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L let checkRoomStateInterval: number; let stopgapTimeoutHandle: number; + const stopgapFinish = () => { + logger.warn(`Assuming local room ${localRoom.roomId} is ready after hitting timeout`); + finish(); + }; + const finish = () => { if (checkRoomStateInterval) clearInterval(checkRoomStateInterval); if (stopgapTimeoutHandle) clearTimeout(stopgapTimeoutHandle); @@ -277,15 +283,22 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L }); }; - startDm(client, localRoom.targets).then((roomId) => { - localRoom.realRoomId = roomId; - if (isRoomReady(client, localRoom)) finish(); - stopgapTimeoutHandle = setTimeout(finish, 5000); - // polling the room state is not as beautiful as listening on the events, but it is more reliable - checkRoomStateInterval = setInterval(() => { + startDm(client, localRoom.targets).then( + (roomId) => { + localRoom.realRoomId = roomId; if (isRoomReady(client, localRoom)) finish(); - }, 500); - }); + stopgapTimeoutHandle = setTimeout(stopgapFinish, 5000); + // polling the room state is not as beautiful as listening on the events, but it is more reliable + checkRoomStateInterval = setInterval(() => { + if (isRoomReady(client, localRoom)) finish(); + }, 500); + }, + () => { + logger.warn(`Error creating DM for local room ${localRoom.roomId}`); + localRoom.state = LocalRoomState.ERROR; + client.emit(ClientEvent.Room, localRoom); + }, + ); }); } diff --git a/test/LocalRoom-test.ts b/test/LocalRoom-test.ts new file mode 100644 index 00000000000..2d456c547b7 --- /dev/null +++ b/test/LocalRoom-test.ts @@ -0,0 +1,82 @@ +/* +Copyright 2022 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 { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../src/models/LocalRoom"; + +const stateTestData = [ + { + name: "NEW", + state: LocalRoomState.NEW, + isNew: true, + isCreated: false, + isError: false, + }, + { + name: "CREATING", + state: LocalRoomState.CREATING, + isNew: false, + isCreated: false, + isError: false, + }, + { + name: "CREATED", + state: LocalRoomState.CREATED, + isNew: false, + isCreated: true, + isError: false, + }, + { + name: "ERROR", + state: LocalRoomState.ERROR, + isNew: false, + isCreated: false, + isError: true, + }, +]; + +describe("LocalRoom", () => { + let room: LocalRoom; + + beforeEach(() => { + room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", {} as unknown as MatrixClient, "@test:localhost"); + }); + + it("should not have after create callbacks", () => { + expect(room.afterCreateCallbacks).toHaveLength(0); + }); + + stateTestData.forEach((stateTestDatum) => { + describe(`in state ${stateTestDatum.name}`, () => { + beforeEach(() => { + room.state = stateTestDatum.state; + }); + + it(`isNew should return ${stateTestDatum.isNew}`, () => { + expect(room.isNew).toBe(stateTestDatum.isNew); + }); + + it(`isCreated should return ${stateTestDatum.isCreated}`, () => { + expect(room.isCreated).toBe(stateTestDatum.isCreated); + }); + + it(`isError should return ${stateTestDatum.isError}`, () => { + expect(room.isError).toBe(stateTestDatum.isError); + }); + }); + }); +}); From f32e63e218a748793e4b57c656037b3a994ff6e7 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 15 Jun 2022 09:47:12 +0200 Subject: [PATCH 41/73] After merge cleanup --- src/utils/direct-messages.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index d40c4e808e6..c9698707754 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -192,9 +192,7 @@ export async function createDmLocalRoom( localRoom.addLiveEvents(events); localRoom.currentState.setStateEvents(events); localRoom.name = localRoom.getDefaultRoomName(userId); - client.store.storeRoom(localRoom); - client.sessionStore.store.setItem('mx_pending_events_local_room', []); return localRoom; } From bad7dc0d6aadb799210c162ed170957fd916c224 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 27 Jun 2022 09:48:24 +0200 Subject: [PATCH 42/73] Implement start DM on first message from user profile --- src/components/views/right_panel/UserInfo.tsx | 51 +++++-------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 04a5097649c..2e7a0417b21 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -33,7 +33,6 @@ import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import dis from '../../../dispatcher/dispatcher'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -import createRoom from '../../../createRoom'; import DMRoomMap from '../../../utils/DMRoomMap'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; import SdkConfig from '../../../SdkConfig'; @@ -78,8 +77,7 @@ import { IRightPanelCardState } from '../../../stores/right-panel/RightPanelStor import UserIdentifierCustomisations from '../../../customisations/UserIdentifier'; import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { findDMForUser } from "../../../utils/direct-messages"; -import { privateShouldBeEncrypted } from "../../../utils/rooms"; +import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages"; export interface IDevice { deviceId: string; @@ -124,38 +122,13 @@ export const getE2EStatus = (cli: MatrixClient, userId: string, devices: IDevice return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified; }; -async function openDMForUser(matrixClient: MatrixClient, userId: string, viaKeyboard = false): Promise { - const lastActiveRoom = findDMForUser(matrixClient, userId); - - if (lastActiveRoom) { - dis.dispatch({ - action: Action.ViewRoom, - room_id: lastActiveRoom.roomId, - metricsTrigger: "MessageUser", - metricsViaKeyboard: viaKeyboard, - }); - return; - } - - const createRoomOptions = { - dmUserId: userId, - encryption: undefined, - }; - - if (privateShouldBeEncrypted()) { - // Check whether all users have uploaded device keys before. - // If so, enable encryption in the new room. - const usersToDevicesMap = await matrixClient.downloadKeys([userId]); - const allHaveDeviceKeys = Object.values(usersToDevicesMap).every(devices => { - // `devices` is an object of the form { deviceId: deviceInfo, ... }. - return Object.keys(devices).length > 0; - }); - if (allHaveDeviceKeys) { - createRoomOptions.encryption = true; - } - } - - await createRoom(createRoomOptions); +async function openDMForUser(matrixClient: MatrixClient, user: RoomMember): Promise { + const startDMUser = new DirectoryMember({ + user_id: user.userId, + display_name: user.rawDisplayName, + avatar_url: user.getMxcAvatarUrl(), + }); + startDmOnFirstMessage(matrixClient, [startDMUser]); } type SetUpdating = (updating: boolean) => void; @@ -328,17 +301,17 @@ function DevicesSection({ devices, userId, loading }: { devices: IDevice[], user ); } -const MessageButton = ({ userId }: { userId: string }) => { +const MessageButton = ({ member }: { member: RoomMember }) => { const cli = useContext(MatrixClientContext); const [busy, setBusy] = useState(false); return ( { + onClick={async () => { if (busy) return; setBusy(true); - await openDMForUser(cli, userId, ev.type !== "click"); + await openDMForUser(cli, member); setBusy(false); }} className="mx_UserInfo_field" @@ -484,7 +457,7 @@ const UserOptionsSection: React.FC<{ let directMessageButton: JSX.Element; if (!isMe) { - directMessageButton = ; + directMessageButton = ; } return ( From eec735eb1cdb4cb9b7321d567235bd1c860bc219 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 27 Jun 2022 11:42:18 +0200 Subject: [PATCH 43/73] Implement Avatar tests --- test/Avatar-test.ts | 102 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 test/Avatar-test.ts diff --git a/test/Avatar-test.ts b/test/Avatar-test.ts new file mode 100644 index 00000000000..ac4e559f913 --- /dev/null +++ b/test/Avatar-test.ts @@ -0,0 +1,102 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; + +import { avatarUrlForRoom } from "../src/Avatar"; +import { Media, mediaFromMxc } from "../src/customisations/Media"; +import DMRoomMap from "../src/utils/DMRoomMap"; + +jest.mock("../src/customisations/Media", () => ({ + mediaFromMxc: jest.fn(), +})); + +const roomId = "!room:example.com"; +const avatarUrl1 = "https://example.com/avatar1"; +const avatarUrl2 = "https://example.com/avatar2"; + +describe("avatarUrlForRoom should return", () => { + let getThumbnailOfSourceHttp: jest.Mock; + let room: Room; + let roomMember: RoomMember; + let dmRoomMap: DMRoomMap; + + beforeEach(() => { + getThumbnailOfSourceHttp = jest.fn(); + mocked(mediaFromMxc).mockImplementation((): Media => { + return { + getThumbnailOfSourceHttp, + } as unknown as Media; + }); + room = { + roomId, + getMxcAvatarUrl: jest.fn(), + isSpaceRoom: jest.fn(), + getAvatarFallbackMember: jest.fn(), + } as unknown as Room; + dmRoomMap = { + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap; + DMRoomMap.setShared(dmRoomMap); + roomMember = { + getMxcAvatarUrl: jest.fn(), + } as unknown as RoomMember; + }); + + it("null for a null room", () => { + expect(avatarUrlForRoom(null, 128, 128)).toBeNull(); + }); + + it("the HTTP source if the room provides a MXC url", () => { + mocked(room.getMxcAvatarUrl).mockReturnValue(avatarUrl1); + getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2); + expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2); + expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop"); + }); + + it("null for a space room", () => { + mocked(room.isSpaceRoom).mockReturnValue(true); + expect(avatarUrlForRoom(room, 128, 128)).toBeNull(); + }); + + it("null if the room is not a DM", () => { + mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue(null); + expect(avatarUrlForRoom(room, 128, 128)).toBeNull(); + expect(dmRoomMap.getUserIdForRoomId).toHaveBeenCalledWith(roomId); + }); + + it("null if there is no other member in the room", () => { + mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com"); + mocked(room.getAvatarFallbackMember).mockReturnValue(null); + expect(avatarUrlForRoom(room, 128, 128)).toBeNull(); + }); + + it("null if the other member has no avatar URL", () => { + mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com"); + mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember); + expect(avatarUrlForRoom(room, 128, 128)).toBeNull(); + }); + + it("the other member's avatar URL", () => { + mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com"); + mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember); + mocked(roomMember.getMxcAvatarUrl).mockReturnValue(avatarUrl2); + getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2); + expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2); + expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop"); + }); +}); From ec606c342a5726c7b1fb30e7bb8b34593d1e3bf4 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 27 Jun 2022 12:16:13 +0200 Subject: [PATCH 44/73] Add doMaybeLocalRoomAction test --- src/utils/local-room.ts | 13 ++++++ test/utils/local-room-test.ts | 81 +++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 test/utils/local-room-test.ts diff --git a/src/utils/local-room.ts b/src/utils/local-room.ts index d2a8aa14896..e920822d18a 100644 --- a/src/utils/local-room.ts +++ b/src/utils/local-room.ts @@ -20,6 +20,19 @@ import defaultDispatcher from "../dispatcher/dispatcher"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; +/** + * Does a room action: + * For non-local rooms it calls fn directly. + * For local rooms it adds the callback function to the room's afterCreateCallbacks and + * dispatches a "local_room_event". + * + * @async + * @template T + * @param {string} roomId Room ID of the target room + * @param {(actualRoomId: string) => Promise} fn Callback to be called directly or collected at the local room + * @param {MatrixClient} [client] + * @returns {Promise} Promise that gets resolved after the callback has finished + */ export async function doMaybeLocalRoomAction( roomId: string, fn: (actualRoomId: string) => Promise, diff --git a/test/utils/local-room-test.ts b/test/utils/local-room-test.ts new file mode 100644 index 00000000000..9c3e7210269 --- /dev/null +++ b/test/utils/local-room-test.ts @@ -0,0 +1,81 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom"; +import { doMaybeLocalRoomAction } from "../../src/utils/local-room"; +import defaultDispatcher from "../../src/dispatcher/dispatcher"; + +jest.mock("../../src/dispatcher/dispatcher", () => ({ + dispatch: jest.fn(), +})); + +describe("doMaybeLocalRoomAction", () => { + let callback: jest.Mock; + let localRoom: LocalRoom; + let client: MatrixClient; + + beforeEach(() => { + callback = jest.fn(); + callback.mockReturnValue(Promise.resolve()); + client = { + getRoom: jest.fn(), + } as unknown as MatrixClient; + localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, "@test:example.com"); + localRoom.realRoomId = "@new:example.com"; + mocked(client.getRoom).mockImplementation((roomId: string) => { + if (roomId === localRoom.roomId) { + return localRoom; + } + return null; + }); + }); + + it("should invoke the callback for a non-local room", () => { + doMaybeLocalRoomAction("!room:example.com", callback, client); + expect(callback).toHaveBeenCalled(); + }); + + it("should invoke the callback with the new room ID for a created room", () => { + localRoom.state = LocalRoomState.CREATED; + doMaybeLocalRoomAction(localRoom.roomId, callback, client); + expect(callback).toHaveBeenCalledWith(localRoom.realRoomId); + }); + + describe("for a local room", () => { + let prom; + + beforeEach(() => { + prom = doMaybeLocalRoomAction(localRoom.roomId, callback, client); + }); + + it("dispatch a local_room_event", () => { + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "local_room_event", + roomId: localRoom.roomId, + }); + }); + + it("should resolve the promise after invoking the callback", async () => { + localRoom.afterCreateCallbacks.forEach((callback) => { + callback(localRoom.realRoomId); + }); + await prom; + }); + }); +}); From 6e9a96814ac5fa11cc3c0176cff1a21285352627 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 27 Jun 2022 13:50:29 +0200 Subject: [PATCH 45/73] Implement ContentMessages test --- src/ContentMessages.ts | 3 +- test/ContentMessages-test.ts | 66 ++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 test/ContentMessages-test.ts diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 24651abb00b..345f4c33ca0 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -356,7 +356,8 @@ export default class ContentMessages { roomId, (actualRoomId: string) => matrixClient.sendStickerMessage(actualRoomId, threadId, url, info, text), matrixClient, - ).catch((e) => { + ); + prom.catch((e) => { logger.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); throw e; }); diff --git a/test/ContentMessages-test.ts b/test/ContentMessages-test.ts new file mode 100644 index 00000000000..1d438fa7133 --- /dev/null +++ b/test/ContentMessages-test.ts @@ -0,0 +1,66 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { IImageInfo, ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import ContentMessages from "../src/ContentMessages"; +import { doMaybeLocalRoomAction } from "../src/utils/local-room"; + +jest.mock("../src/utils/local-room", () => ({ + doMaybeLocalRoomAction: jest.fn(), +})); + +describe("ContentMessages", () => { + const stickerUrl = "https://example.com/sticker"; + const roomId = "!room:example.com"; + const imageInfo = {} as unknown as IImageInfo; + const text = "test sticker"; + let client: MatrixClient; + let contentMessages: ContentMessages; + let prom: Promise; + + beforeEach(() => { + client = { + sendStickerMessage: jest.fn(), + } as unknown as MatrixClient; + contentMessages = new ContentMessages(); + prom = Promise.resolve(null); + }); + + describe("sendStickerContentToRoom", () => { + it("should forward the call to doMaybeLocalRoomAction", () => { + mocked(client.sendStickerMessage).mockReturnValue(prom); + mocked(doMaybeLocalRoomAction).mockImplementation(( + roomId: string, + fn: (actualRoomId: string) => Promise, + client?: MatrixClient, + ) => { + return fn(roomId); + }); + const returnProm = contentMessages.sendStickerContentToRoom( + stickerUrl, + roomId, + null, + imageInfo, + text, + client, + ); + expect(returnProm).toBe(prom); + expect(client.sendStickerMessage).toHaveBeenCalledWith(roomId, null, stickerUrl, imageInfo, text); + }); + }); +}); From e7234b57444b7473be33a7ff1623f3987fee1ca5 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 27 Jun 2022 14:14:47 +0200 Subject: [PATCH 46/73] Add docs, RoomView refactoring --- .../11-room-directory/room-directory.spec.ts | 2 +- src/components/structures/RoomView.tsx | 64 ++++++++++++------- src/models/LocalRoom.ts | 8 ++- src/utils/direct-messages.ts | 10 +-- src/utils/local-room.ts | 2 +- test/utils/local-room-test.ts | 6 +- 6 files changed, 56 insertions(+), 36 deletions(-) diff --git a/cypress/integration/11-room-directory/room-directory.spec.ts b/cypress/integration/11-room-directory/room-directory.spec.ts index e7e3c5c9c86..18464e20712 100644 --- a/cypress/integration/11-room-directory/room-directory.spec.ts +++ b/cypress/integration/11-room-directory/room-directory.spec.ts @@ -27,7 +27,7 @@ describe("Room Directory", () => { synapse = data; cy.initTestUser(synapse, "Ray"); - cy.getBot(synapse, "Paul").as("bot"); + cy.getBot(synapse, { displayName: "Paul" }).as("bot"); }); }); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 47a100763c9..921968aa483 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -235,7 +235,13 @@ interface ILocalRoomViewProps { onFileDrop: (dataTransfer: DataTransfer) => Promise; } -function LocalRoomView(props: ILocalRoomViewProps) { +/** + * Local room view. Uses only the bits necessary to display a local room view like room header or composer. + * + * @param {ILocalRoomViewProps} props Room view props + * @returns {ReactElement} + */ +function LocalRoomView(props: ILocalRoomViewProps): ReactElement { const context = useContext(RoomContext); const room = context.room as LocalRoom; const encryptionEvent = context.room.currentState.getStateEvents(EventType.RoomEncryption)[0]; @@ -318,7 +324,13 @@ interface ILocalRoomCreateLoaderProps { resizeNotifier: ResizeNotifier; } -function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps) { +/** + * Room create loader view displaying a message and a spinner. + * + * @param {ILocalRoomCreateLoaderProps} props Room view props + * @return {ReactElement} + */ +function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement { const context = useContext(RoomContext); const text = _t("We're creating a room with %(names)s", { names: props.names }); return ( @@ -1907,30 +1919,34 @@ export class RoomView extends React.Component { return this.getPermalinkCreatorForRoom(this.state.room); } - render() { - if (this.state.room instanceof LocalRoom && this.state.room.state === LocalRoomState.CREATING) { - const names = this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()); - return ( - - - - ); - } + private renderLocalRoomCreateLoader(): ReactElement { + const names = this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()); + return + + ; + } + private renderLocalRoomView(): ReactElement { + return + + ; + } + + render() { if (this.state.room instanceof LocalRoom) { - return ( - - - - ); + if (this.state.room.state === LocalRoomState.CREATING) { + return this.renderLocalRoomCreateLoader(); + } + + return this.renderLocalRoomView(); } if (!this.state.room) { diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index 7dbdbefd34f..b6cc8c57f74 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -28,13 +28,17 @@ export enum LocalRoomState { } /** - * A local room that only exists on the client side. + * A local room that only exists client side. * Its main purpose is to be used for temporary rooms when creating a DM. */ export class LocalRoom extends Room { + /** Whether the actual room should be encrypted. */ encrypted: boolean; - realRoomId: string; + /** If the actual room has been created, this holds its ID. */ + actualRoomId: string; + /** DM chat partner */ targets: Member[]; + /** Callbacks that should be invoked after the actual room has been created. */ afterCreateCallbacks: Function[] = []; state: LocalRoomState = LocalRoomState.NEW; diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index c9698707754..524629c608a 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -232,10 +232,10 @@ function isRoomReady( client: MatrixClient, localRoom: LocalRoom, ): boolean { - // not ready if no real room id exists - if (!localRoom.realRoomId) return false; + // not ready if no actual room id exists + if (!localRoom.actualRoomId) return false; - const room = client.getRoom(localRoom.realRoomId); + const room = client.getRoom(localRoom.actualRoomId); // not ready if the room does not exist if (!room) return false; @@ -275,7 +275,7 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L if (checkRoomStateInterval) clearInterval(checkRoomStateInterval); if (stopgapTimeoutHandle) clearTimeout(stopgapTimeoutHandle); - applyAfterCreateCallbacks(localRoom, localRoom.realRoomId).then(() => { + applyAfterCreateCallbacks(localRoom, localRoom.actualRoomId).then(() => { localRoom.state = LocalRoomState.CREATED; resolve(); }); @@ -283,7 +283,7 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L startDm(client, localRoom.targets).then( (roomId) => { - localRoom.realRoomId = roomId; + localRoom.actualRoomId = roomId; if (isRoomReady(client, localRoom)) finish(); stopgapTimeoutHandle = setTimeout(stopgapFinish, 5000); // polling the room state is not as beautiful as listening on the events, but it is more reliable diff --git a/src/utils/local-room.ts b/src/utils/local-room.ts index e920822d18a..fb388f08e17 100644 --- a/src/utils/local-room.ts +++ b/src/utils/local-room.ts @@ -43,7 +43,7 @@ export async function doMaybeLocalRoomAction( const room = client.getRoom(roomId) as LocalRoom; if (room.isCreated) { - return fn(room.realRoomId); + return fn(room.actualRoomId); } return new Promise((resolve, reject) => { diff --git a/test/utils/local-room-test.ts b/test/utils/local-room-test.ts index 9c3e7210269..03aafc42ddd 100644 --- a/test/utils/local-room-test.ts +++ b/test/utils/local-room-test.ts @@ -37,7 +37,7 @@ describe("doMaybeLocalRoomAction", () => { getRoom: jest.fn(), } as unknown as MatrixClient; localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, "@test:example.com"); - localRoom.realRoomId = "@new:example.com"; + localRoom.actualRoomId = "@new:example.com"; mocked(client.getRoom).mockImplementation((roomId: string) => { if (roomId === localRoom.roomId) { return localRoom; @@ -54,7 +54,7 @@ describe("doMaybeLocalRoomAction", () => { it("should invoke the callback with the new room ID for a created room", () => { localRoom.state = LocalRoomState.CREATED; doMaybeLocalRoomAction(localRoom.roomId, callback, client); - expect(callback).toHaveBeenCalledWith(localRoom.realRoomId); + expect(callback).toHaveBeenCalledWith(localRoom.actualRoomId); }); describe("for a local room", () => { @@ -73,7 +73,7 @@ describe("doMaybeLocalRoomAction", () => { it("should resolve the promise after invoking the callback", async () => { localRoom.afterCreateCallbacks.forEach((callback) => { - callback(localRoom.realRoomId); + callback(localRoom.actualRoomId); }); await prom; }); From 2efc74405ca08df864458e3860275ebcc0b0c69f Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 27 Jun 2022 14:41:20 +0200 Subject: [PATCH 47/73] Implement shareLocation test --- test/ContentMessages-test.ts | 5 +- .../views/location/shareLocation-test.ts | 65 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 test/components/views/location/shareLocation-test.ts diff --git a/test/ContentMessages-test.ts b/test/ContentMessages-test.ts index 1d438fa7133..7007258b021 100644 --- a/test/ContentMessages-test.ts +++ b/test/ContentMessages-test.ts @@ -42,7 +42,7 @@ describe("ContentMessages", () => { }); describe("sendStickerContentToRoom", () => { - it("should forward the call to doMaybeLocalRoomAction", () => { + beforeEach(() => { mocked(client.sendStickerMessage).mockReturnValue(prom); mocked(doMaybeLocalRoomAction).mockImplementation(( roomId: string, @@ -51,6 +51,9 @@ describe("ContentMessages", () => { ) => { return fn(roomId); }); + }); + + it("should forward the call to doMaybeLocalRoomAction", () => { const returnProm = contentMessages.sendStickerContentToRoom( stickerUrl, roomId, diff --git a/test/components/views/location/shareLocation-test.ts b/test/components/views/location/shareLocation-test.ts new file mode 100644 index 00000000000..c96d74a80f2 --- /dev/null +++ b/test/components/views/location/shareLocation-test.ts @@ -0,0 +1,65 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { makeLocationContent } from "matrix-js-sdk/src/content-helpers"; +import { LegacyLocationEventContent, MLocationEventContent } from "matrix-js-sdk/src/@types/location"; + +import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room"; +import { + LocationShareType, + shareLocation, + ShareLocationFn, +} from "../../../../src/components/views/location/shareLocation"; + +jest.mock("../../../../src/utils/local-room", () => ({ + doMaybeLocalRoomAction: jest.fn(), +})); + +jest.mock("matrix-js-sdk/src/content-helpers", () => ({ + makeLocationContent: jest.fn(), +})); + +describe("shareLocation", () => { + const roomId = "!room:example.com"; + const shareType = LocationShareType.Pin; + const content = { test: "location content" } as unknown as LegacyLocationEventContent & MLocationEventContent; + let client: MatrixClient; + let shareLocationFn: ShareLocationFn; + + beforeEach(() => { + client = { + sendMessage: jest.fn(), + } as unknown as MatrixClient; + + mocked(makeLocationContent).mockReturnValue(content); + mocked(doMaybeLocalRoomAction).mockImplementation(( + roomId: string, + fn: (actualRoomId: string) => Promise, + client?: MatrixClient, + ) => { + return fn(roomId); + }); + + shareLocationFn = shareLocation(client, roomId, shareType, null, () => {}); + }); + + it("should forward the call to doMaybeLocalRoomAction", () => { + shareLocationFn({ uri: "https://example.com/" }); + expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, content); + }); +}); From 25a1f40cfd594e99623df2f8677fd173f9c2a296 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 27 Jun 2022 15:36:40 +0200 Subject: [PATCH 48/73] Add SendMessageComposer test --- .../views/rooms/SendMessageComposer-test.tsx | 36 ++++++++++++++++++- test/test-utils/test-utils.ts | 4 ++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index 014f5af66ea..c901b7e06e1 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -17,8 +17,9 @@ limitations under the License. import React from "react"; import { act } from "react-dom/test-utils"; import { sleep } from "matrix-js-sdk/src/utils"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { ISendEventResponse, MatrixClient, MsgType } from "matrix-js-sdk/src/matrix"; import { mount } from 'enzyme'; +import { mocked } from "jest-mock"; import SendMessageComposer, { createMessageContent, @@ -37,6 +38,11 @@ import { Layout } from '../../../../src/settings/enums/Layout'; import { IRoomState } from "../../../../src/components/structures/RoomView"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { mockPlatformPeg } from "../../../test-utils/platform"; +import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room"; + +jest.mock("../../../../src/utils/local-room", () => ({ + doMaybeLocalRoomAction: jest.fn(), +})); const WrapWithProviders: React.FC<{ roomContext: IRoomState; @@ -306,6 +312,34 @@ describe('', () => { const key = instance.editorStateKey; expect(key).toEqual('mx_cider_state_myfakeroom_myFakeThreadId'); }); + + it("correctly sends a message", () => { + mocked(doMaybeLocalRoomAction).mockImplementation(( + roomId: string, + fn: (actualRoomId: string) => Promise, + client?: MatrixClient, + ) => { + return fn(roomId); + }); + + mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) }); + const wrapper = getComponent(); + + addTextToComposer(wrapper, "test message"); + act(() => { + wrapper.find(".mx_SendMessageComposer").simulate("keydown", { key: "Enter" }); + wrapper.update(); + }); + + expect(mockClient.sendMessage).toHaveBeenCalledWith( + "myfakeroom", + null, + { + "body": "test message", + "msgtype": MsgType.Text, + }, + ); + }); }); describe("isQuickReaction", () => { diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 892142b98eb..5cfa94509b4 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -71,6 +71,8 @@ export function stubClient() { */ export function createTestClient(): MatrixClient { const eventEmitter = new EventEmitter(); + const sendMessage = jest.fn(); + sendMessage.mockResolvedValue({}); return { getHomeserverUrl: jest.fn(), @@ -124,7 +126,7 @@ export function createTestClient(): MatrixClient { setRoomAccountData: jest.fn(), setRoomTopic: jest.fn(), sendTyping: jest.fn().mockResolvedValue({}), - sendMessage: () => jest.fn().mockResolvedValue({}), + sendMessage, sendStateEvent: jest.fn().mockResolvedValue(undefined), getSyncState: () => "SYNCING", generateClientSecret: () => "t35tcl1Ent5ECr3T", From 2dbb04abe70cc790de6346053ee8d22bd9310bf8 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 27 Jun 2022 17:21:11 +0200 Subject: [PATCH 49/73] Add VoiceRecordComposerTile test --- .../rooms/VoiceRecordComposerTile-test.tsx | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 test/components/views/rooms/VoiceRecordComposerTile-test.tsx diff --git a/test/components/views/rooms/VoiceRecordComposerTile-test.tsx b/test/components/views/rooms/VoiceRecordComposerTile-test.tsx new file mode 100644 index 00000000000..a1645db097b --- /dev/null +++ b/test/components/views/rooms/VoiceRecordComposerTile-test.tsx @@ -0,0 +1,104 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { mount, ReactWrapper } from "enzyme"; +import { ISendEventResponse, MatrixClient, MsgType, Room } from "matrix-js-sdk/src/matrix"; +import { mocked } from "jest-mock"; + +import VoiceRecordComposerTile from "../../../../src/components/views/rooms/VoiceRecordComposerTile"; +import { IUpload, VoiceRecording } from "../../../../src/audio/VoiceRecording"; +import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; + +jest.mock("../../../../src/utils/local-room", () => ({ + doMaybeLocalRoomAction: jest.fn(), +})); + +describe("", () => { + let voiceRecordComposerTile: ReactWrapper; + let mockRecorder: VoiceRecording; + let mockUpload: IUpload; + let mockClient: MatrixClient; + const roomId = "!room:example.com"; + + beforeEach(() => { + mockClient = { + sendMessage: jest.fn(), + } as unknown as MatrixClient; + MatrixClientPeg.get = () => mockClient; + + const props = { + room: { + roomId, + } as unknown as Room, + }; + mockUpload = { + mxc: "mxc://example.com/voice", + }; + mockRecorder = { + stop: jest.fn(), + upload: () => Promise.resolve(mockUpload), + durationSeconds: 1337, + contentType: "audio/ogg", + getPlayback: () => ({ + thumbnailWaveform: [], + }), + } as unknown as VoiceRecording; + voiceRecordComposerTile = mount(); + voiceRecordComposerTile.setState({ + recorder: mockRecorder, + }); + + mocked(doMaybeLocalRoomAction).mockImplementation(( + roomId: string, + fn: (actualRoomId: string) => Promise, + _client?: MatrixClient, + ) => { + return fn(roomId); + }); + }); + + describe("send", () => { + it("should send the voice recording", async () => { + await (voiceRecordComposerTile.instance() as VoiceRecordComposerTile).send(); + expect(mockClient.sendMessage).toHaveBeenCalledWith(roomId, { + "body": "Voice message", + "file": undefined, + "info": { + "duration": 1337000, + "mimetype": "audio/ogg", + "size": undefined, + }, + "msgtype": MsgType.Audio, + "org.matrix.msc1767.audio": { + "duration": 1337000, + "waveform": [], + }, + "org.matrix.msc1767.file": { + "file": undefined, + "mimetype": "audio/ogg", + "name": "Voice message.ogg", + "size": undefined, + "url": "mxc://example.com/voice", + }, + "org.matrix.msc1767.text": "Voice message", + "org.matrix.msc3245.voice": {}, + "url": "mxc://example.com/voice", + }); + }); + }); +}); From 74bff65c870e97a6991c4c9f12c5e76137a68509 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 28 Jun 2022 14:15:38 +0200 Subject: [PATCH 50/73] Implement VisibilityProvider test --- .../filters/VisibilityProvider-test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 test/stores/room-list/filters/VisibilityProvider-test.ts diff --git a/test/stores/room-list/filters/VisibilityProvider-test.ts b/test/stores/room-list/filters/VisibilityProvider-test.ts new file mode 100644 index 00000000000..5a5bbfb7f90 --- /dev/null +++ b/test/stores/room-list/filters/VisibilityProvider-test.ts @@ -0,0 +1,123 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { VisibilityProvider } from "../../../../src/stores/room-list/filters/VisibilityProvider"; +import CallHandler from "../../../../src/CallHandler"; +import VoipUserMapper from "../../../../src/VoipUserMapper"; +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoom"; +import { RoomListCustomisations } from "../../../../src/customisations/RoomList"; + +jest.mock("../../../../src/VoipUserMapper", () => ({ + sharedInstance: jest.fn(), +})); + +jest.mock("../../../../src/CallHandler", () => ({ + instance: { + getSupportsVirtualRooms: jest.fn(), + }, +})); + +jest.mock("../../../../src/customisations/RoomList", () => ({ + RoomListCustomisations: { + isRoomVisible: jest.fn(), + }, +})); + +const createRoom = (isSpaceRoom = false): Room => { + return { + isSpaceRoom: () => isSpaceRoom, + } as unknown as Room; +}; + +const createLocalRoom = (): LocalRoom => { + const room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", {} as unknown as MatrixClient, "@test:example.com"); + room.isSpaceRoom = () => false; + return room; +}; + +describe("VisibilityProvider", () => { + let mockVoipUserMapper: VoipUserMapper; + + beforeEach(() => { + mockVoipUserMapper = { + onNewInvitedRoom: jest.fn(), + isVirtualRoom: jest.fn(), + } as unknown as VoipUserMapper; + mocked(VoipUserMapper.sharedInstance).mockReturnValue(mockVoipUserMapper); + }); + + describe("instance", () => { + it("should return an instance", () => { + const visibilityProvider = VisibilityProvider.instance; + expect(visibilityProvider).toBeInstanceOf(VisibilityProvider); + expect(VisibilityProvider.instance).toBe(visibilityProvider); + }); + }); + + describe("onNewInvitedRoom", () => { + it("should call onNewInvitedRoom on VoipUserMapper.sharedInstance", async () => { + const room = {} as unknown as Room; + await VisibilityProvider.instance.onNewInvitedRoom(room); + expect(mockVoipUserMapper.onNewInvitedRoom).toHaveBeenCalledWith(room); + }); + }); + + describe("isRoomVisible", () => { + describe("for a virtual room", () => { + beforeEach(() => { + mocked(CallHandler.instance.getSupportsVirtualRooms).mockReturnValue(true); + mocked(mockVoipUserMapper.isVirtualRoom).mockReturnValue(true); + }); + + it("should return return false", () => { + const room = createRoom(); + expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(false); + expect(mockVoipUserMapper.isVirtualRoom).toHaveBeenCalledWith(room); + }); + }); + + it("should return false without room", () => { + expect(VisibilityProvider.instance.isRoomVisible()).toBe(false); + }); + + it("should return false for a space room", () => { + const room = createRoom(true); + expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(false); + }); + + it("should return false for a local room", () => { + const room = createLocalRoom(); + expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(false); + }); + + it("should return false if visibility customisation returns false", () => { + mocked(RoomListCustomisations.isRoomVisible).mockReturnValue(false); + const room = createRoom(); + expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(false); + expect(RoomListCustomisations.isRoomVisible).toHaveBeenCalledWith(room); + }); + + it("should return true if visibility customisation returns true", () => { + mocked(RoomListCustomisations.isRoomVisible).mockReturnValue(true); + const room = createRoom(); + expect(VisibilityProvider.instance.isRoomVisible(room)).toBe(true); + expect(RoomListCustomisations.isRoomVisible).toHaveBeenCalledWith(room); + }); + }); +}); From 631f32f5ff7c485b279f0c9ce88f4d5a0b3d886a Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 28 Jun 2022 14:43:39 +0200 Subject: [PATCH 51/73] Add TypingStore test --- test/stores/TypingStore-test.ts | 88 +++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 test/stores/TypingStore-test.ts diff --git a/test/stores/TypingStore-test.ts b/test/stores/TypingStore-test.ts new file mode 100644 index 00000000000..98ddfca3c40 --- /dev/null +++ b/test/stores/TypingStore-test.ts @@ -0,0 +1,88 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +import TypingStore from "../../src/stores/TypingStore"; +import { LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom"; +import SettingsStore from "../../src/settings/SettingsStore"; + +jest.mock("../../src/settings/SettingsStore", () => ({ + getValue: jest.fn(), +})); + +describe("TypingStore", () => { + let typingStore: TypingStore; + let mockClient: MatrixClient; + const settings = { + "sendTypingNotifications": true, + "feature_thread": false, + }; + const roomId = "!test:example.com"; + const localRoomId = LOCAL_ROOM_ID_PREFIX + "test"; + + beforeEach(() => { + typingStore = new TypingStore(); + mockClient = { + sendTyping: jest.fn(), + } as unknown as MatrixClient; + MatrixClientPeg.get = () => mockClient; + mocked(SettingsStore.getValue).mockImplementation((setting: string) => { + return settings[setting]; + }); + }); + + describe("setSelfTyping", () => { + it("shouldn't do anything for a local room", () => { + typingStore.setSelfTyping(localRoomId, null, true); + expect(mockClient.sendTyping).not.toHaveBeenCalled(); + }); + + describe("in typing state true", () => { + beforeEach(() => { + typingStore.setSelfTyping(roomId, null, true); + }); + + it("should change to false when setting false", () => { + typingStore.setSelfTyping(roomId, null, false); + expect(mockClient.sendTyping).toHaveBeenCalledWith(roomId, false, 30000); + }); + + it("should change to true when setting true", () => { + typingStore.setSelfTyping(roomId, null, true); + expect(mockClient.sendTyping).toHaveBeenCalledWith(roomId, true, 30000); + }); + }); + + describe("in typing state false", () => { + beforeEach(() => { + typingStore.setSelfTyping(roomId, null, false); + }); + + it("shouldn't change when setting false", () => { + typingStore.setSelfTyping(roomId, null, false); + expect(mockClient.sendTyping).not.toHaveBeenCalled(); + }); + + it("should change to true when setting true", () => { + typingStore.setSelfTyping(roomId, null, true); + expect(mockClient.sendTyping).toHaveBeenCalledWith(roomId, true, 30000); + }); + }); + }); +}); From 74db0b4bf1a3733d34cbaa931b775eda706e7d4d Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 28 Jun 2022 15:36:24 +0200 Subject: [PATCH 52/73] Fix cpyress tests (crypto will be re-added from develop) --- .../12-spotlight/spotlight.spec.ts | 4 +- cypress/integration/7-crypto/crypto.spec.ts | 98 ------------------- 2 files changed, 2 insertions(+), 100 deletions(-) delete mode 100644 cypress/integration/7-crypto/crypto.spec.ts diff --git a/cypress/integration/12-spotlight/spotlight.spec.ts b/cypress/integration/12-spotlight/spotlight.spec.ts index 5c0018b499c..5a76d17b4c9 100644 --- a/cypress/integration/12-spotlight/spotlight.spec.ts +++ b/cypress/integration/12-spotlight/spotlight.spec.ts @@ -128,11 +128,11 @@ describe("Spotlight", () => { cy.startSynapse("default").then(data => { synapse = data; cy.initTestUser(synapse, "Jim").then(() => - cy.getBot(synapse, bot1Name).then(_bot1 => { + cy.getBot(synapse, { displayName: bot1Name }).then(_bot1 => { bot1 = _bot1; }), ).then(() => - cy.getBot(synapse, bot2Name).then(_bot2 => { + cy.getBot(synapse, { displayName: bot2Name }).then(_bot2 => { // eslint-disable-next-line @typescript-eslint/no-unused-vars bot2 = _bot2; }), diff --git a/cypress/integration/7-crypto/crypto.spec.ts b/cypress/integration/7-crypto/crypto.spec.ts deleted file mode 100644 index 04b3dea044b..00000000000 --- a/cypress/integration/7-crypto/crypto.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* -Copyright 2022 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 { CryptoEvent } from "matrix-js-sdk/src/crypto"; -import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; - -import { SynapseInstance } from "../../plugins/synapsedocker"; -import { UserCredentials } from "../../support/login"; - -const waitForVerificationRequest = (cli: MatrixClient): Promise => { - return new Promise(resolve => { - const onVerificationRequestEvent = (request: VerificationRequest) => { - cli.off(CryptoEvent.VerificationRequest, onVerificationRequestEvent); - resolve(request); - }; - cli.on(CryptoEvent.VerificationRequest, onVerificationRequestEvent); - }); -}; - -describe("Starting a new DM", () => { - let credentials: UserCredentials; - let synapse: SynapseInstance; - let bob: MatrixClient; - - const startDMWithBob = () => { - cy.get('[data-test-id="create-chat-button"]').click(); - cy.get('[data-test-id="invite-dialog-input"]').type(bob.getUserId()); - cy.contains(".mx_InviteDialog_roomTile_name", "Bob").click(); - cy.contains(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name", "Bob").should("exist"); - cy.get('[data-test-id="invite-dialog-go-button"]').click(); - cy.get('[data-test-id="basic-message-composer-input"]').should("have.focus").type("Hey!{enter}"); - cy.get(".mx_GenericEventListSummary_toggle").click(); - cy.contains(".mx_TextualEvent", "Alice invited Bob").should("exist"); - }; - - const checkEncryption = () => { - cy.contains(".mx_RoomView_body .mx_cryptoEvent", "Encryption enabled").should("exist"); - // @todo verify this message is really encrypted (e.g. by inspecting the message source) - cy.contains(".mx_EventTile_body", "Hey!") - .closest(".mx_EventTile_line") - .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); - }; - - const joinBob = () => { - cy.botJoinRoomByName(bob, "Alice").as("bobsRoom"); - cy.contains(".mx_TextualEvent", "Bob joined the room").should("exist"); - }; - - const verify = () => { - const bobsVerificationRequestPromise = waitForVerificationRequest(bob); - cy.get('[data-test-id="room-info-button"]').click(); - cy.get(".mx_RoomSummaryCard_icon_people").click(); - cy.contains(".mx_EntityTile_name", "Bob").click(); - cy.contains(".mx_UserInfo_verifyButton", "Verify").click(), - cy.wrap(bobsVerificationRequestPromise).then((verificationRequest: VerificationRequest) => { - // ↓ doesn't work - verificationRequest.accept(); - }); - }; - - beforeEach(() => { - cy.startSynapse("default").then(data => { - synapse = data; - cy.initTestUser(synapse, "Alice").then(_credentials => { - credentials = _credentials; - }); - cy.getBot(synapse, { displayName: "Bob", autoAcceptInvites: false }).then(_bob => { - bob = _bob; - }); - }); - }); - - afterEach(() => { - cy.stopSynapse(synapse); - }); - - it("should work, be e2e-encrypted, enable verification", () => { - cy.setupKeyBackup(credentials.password); - startDMWithBob(); - checkEncryption(); - joinBob(); - verify(); - }); -}); From 1b166f50aac714836a63e8e79498148f6842af0e Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 30 Jun 2022 14:26:42 +0200 Subject: [PATCH 53/73] Use KNOWN_SAFE_ROOM_VERSION from JS SDK --- src/utils/direct-messages.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 524629c608a..8558605a964 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -18,7 +18,7 @@ import { IInvite3PID } from "matrix-js-sdk/src/@types/requests"; import { ClientEvent, MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { EventType } from "matrix-js-sdk/src/matrix"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { Room } from "matrix-js-sdk/src/models/room"; +import { KNOWN_SAFE_ROOM_VERSION, Room } from "matrix-js-sdk/src/models/room"; import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; import { logger } from "matrix-js-sdk/src/logger"; @@ -118,8 +118,7 @@ export async function createDmLocalRoom( type: EventType.RoomCreate, content: { creator: userId, - // @todo MiW - room_version: "9", + room_version: KNOWN_SAFE_ROOM_VERSION, }, state_key: "", user_id: userId, From fa4c45b6b89565c789ab019cac6b4da5105e4439 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 30 Jun 2022 16:25:35 +0200 Subject: [PATCH 54/73] Adapt SpotlightDialog --- .../dialogs/spotlight/SpotlightDialog.tsx | 4 +-- src/utils/direct-messages.ts | 2 +- .../views/dialogs/SpotlightDialog-test.tsx | 35 +++++++++++++++++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index cb2950c6867..169bdf69875 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -68,7 +68,7 @@ import { RecentAlgorithm } from "../../../../stores/room-list/algorithms/tag-sor import { RoomViewStore } from "../../../../stores/RoomViewStore"; import { getMetaSpaceName } from "../../../../stores/spaces"; import SpaceStore from "../../../../stores/spaces/SpaceStore"; -import { DirectoryMember, Member, startDm } from "../../../../utils/direct-messages"; +import { DirectoryMember, Member, startDmOnFirstMessage } from "../../../../utils/direct-messages"; import DMRoomMap from "../../../../utils/DMRoomMap"; import { makeUserPermalink } from "../../../../utils/permalinks/Permalinks"; import { buildActivityScores, buildMemberScores, compareMembers } from "../../../../utils/SortMembers"; @@ -528,7 +528,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n id={`mx_SpotlightDialog_button_result_${result.member.userId}`} key={`${Section[result.section]}-${result.member.userId}`} onClick={() => { - startDm(cli, [result.member]); + startDmOnFirstMessage(cli, [result.member]); }} > diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 8558605a964..31d6c59b0e5 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -304,7 +304,7 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L * * @returns {Promise { +async function startDm(client: MatrixClient, targets: Member[]): Promise { const targetIds = targets.map(t => t.userId); // Check if there is already a DM with these people and reuse it if possible. diff --git a/test/components/views/dialogs/SpotlightDialog-test.tsx b/test/components/views/dialogs/SpotlightDialog-test.tsx index 7c8bbc6bc73..6c507ec1f65 100644 --- a/test/components/views/dialogs/SpotlightDialog-test.tsx +++ b/test/components/views/dialogs/SpotlightDialog-test.tsx @@ -23,8 +23,15 @@ import sanitizeHtml from "sanitize-html"; import SpotlightDialog, { Filter } from "../../../../src/components/views/dialogs/spotlight/SpotlightDialog"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { DirectoryMember, startDmOnFirstMessage } from "../../../../src/utils/direct-messages"; import { stubClient } from "../../../test-utils"; +jest.mock("../../../../src/utils/direct-messages", () => ({ + // @ts-ignore + ...jest.requireActual("../../../../src/utils/direct-messages"), + startDmOnFirstMessage: jest.fn(), +})); + interface IUserChunkMember { user_id: string; display_name?: string; @@ -110,10 +117,11 @@ describe("Spotlight Dialog", () => { guest_can_join: false, }; + let mockedClient: MatrixClient; + beforeEach(() => { - mockClient({ rooms: [testPublicRoom], users: [testPerson] }); + mockedClient = mockClient({ rooms: [testPublicRoom], users: [testPerson] }); }); - describe("should apply filters supplied via props", () => { it("without filter", async () => { const wrapper = mount( @@ -289,4 +297,27 @@ describe("Spotlight Dialog", () => { wrapper.unmount(); }); }); + + it("should start a DM when clicking a person", async () => { + const wrapper = mount( + null} />, + ); + + await act(async () => { + await sleep(200); + }); + wrapper.update(); + + const options = wrapper.find("div.mx_SpotlightDialog_option"); + expect(options.length).toBeGreaterThanOrEqual(1); + expect(options.first().text()).toContain(testPerson.display_name); + + options.first().simulate("click"); + expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockedClient, [new DirectoryMember(testPerson)]); + + wrapper.unmount(); + }); }); From 064356807644492083a55f7e7bd3c1a21aeffb79 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 30 Jun 2022 17:28:54 +0200 Subject: [PATCH 55/73] implement findDMForUser test --- test/utils/direct-message-test.ts | 113 ++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 test/utils/direct-message-test.ts diff --git a/test/utils/direct-message-test.ts b/test/utils/direct-message-test.ts new file mode 100644 index 00000000000..fc3a4f35e9d --- /dev/null +++ b/test/utils/direct-message-test.ts @@ -0,0 +1,113 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { findDMForUser } from "../../src/utils/direct-messages"; +import DMRoomMap from "../../src/utils/DMRoomMap"; +import { makeMembershipEvent } from "../test-utils"; + +jest.mock("../../src/utils/DMRoomMap", () => jest.fn()); + +describe("findDMForUser", () => { + const userId1 = "@user1:example.com"; + const userId2 = "@user2:example.com"; + let dmRoomMap: DMRoomMap; + let mockClient: MatrixClient; + + beforeEach(() => { + dmRoomMap = { + getDMRoomsForUserId: jest.fn(), + } as unknown as DMRoomMap; + DMRoomMap.shared = () => dmRoomMap; + mockClient = { + getRoom: jest.fn(), + } as unknown as MatrixClient; + }); + + describe("for an empty DM room list", () => { + beforeEach(() => { + mocked(dmRoomMap.getDMRoomsForUserId).mockReturnValue([]); + }); + + it("should return null", () => { + expect(findDMForUser(mockClient, userId1)).toBeUndefined(); + }); + }); + + describe("for some rooms", () => { + let room1: Room; + let room2: Room; + let room3: Room; + let room4: Room; + + beforeEach(() => { + room1 = new Room("!room1:example.com", mockClient, userId1); + room1.getMyMembership = () => "join"; + room1.currentState.setStateEvents([ + makeMembershipEvent(room1.roomId, userId1, "join"), + makeMembershipEvent(room1.roomId, userId2, "join"), + ]); + room2 = new Room("!room2:example.com", mockClient, userId1); + room2.getMyMembership = () => "join"; + room2.currentState.setStateEvents([ + makeMembershipEvent(room2.roomId, userId1, "join"), + makeMembershipEvent(room2.roomId, userId2, "join"), + ]); + // this room should not be a DM room because it has only one joined user + room3 = new Room("!room3:example.com", mockClient, userId1); + room3.getMyMembership = () => "join"; + room3.currentState.setStateEvents([ + makeMembershipEvent(room3.roomId, userId1, "invite"), + makeMembershipEvent(room3.roomId, userId2, "join"), + ]); + // this room should not be a DM room because it has no users + room4 = new Room("!room4:example.com", mockClient, userId1); + room4.getLastActiveTimestamp = () => 100; + + mocked(mockClient.getRoom).mockImplementation((roomId: string) => { + return { + [room1.roomId]: room1, + [room2.roomId]: room2, + [room3.roomId]: room3, + [room4.roomId]: room3, + }[roomId]; + }); + + mocked(dmRoomMap.getDMRoomsForUserId).mockReturnValue([ + room1.roomId, + room2.roomId, + room3.roomId, + room4.roomId, + ]); + }); + + it("should find a room ordered by last activity 1", () => { + room1.getLastActiveTimestamp = () => 2; + room2.getLastActiveTimestamp = () => 1; + + expect(findDMForUser(mockClient, userId1)).toBe(room1); + }); + + it("should find a room ordered by last activity 2", () => { + room1.getLastActiveTimestamp = () => 1; + room2.getLastActiveTimestamp = () => 2; + + expect(findDMForUser(mockClient, userId1)).toBe(room2); + }); + }); +}); From bd09b3c5519a2c21aa16a5d9cd1f139e681d7973 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 1 Jul 2022 15:06:29 +0200 Subject: [PATCH 56/73] Extend direct-messages tests --- src/utils/direct-messages.ts | 9 +- test/utils/direct-message-test.ts | 113 --------------------- test/utils/direct-messages-test.ts | 152 +++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 116 deletions(-) delete mode 100644 test/utils/direct-message-test.ts create mode 100644 test/utils/direct-messages-test.ts diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 31d6c59b0e5..273486fea4c 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -31,6 +31,7 @@ import { isJoinedOrNearlyJoined } from "./membership"; import dis from "../dispatcher/dispatcher"; import { privateShouldBeEncrypted } from "./rooms"; import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; +import * as thisModule from "./direct-messages"; export function findDMForUser(client: MatrixClient, userId: string): Room { const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); @@ -42,6 +43,8 @@ export function findDMForUser(client: MatrixClient, userId: string): Room { // that bots, assistants, etc will ruin a room's DM-ness, though this is a problem for // canonical DMs to solve. if (r && r.getMyMembership() === "join") { + if (r instanceof LocalRoom) return false; + const members = r.currentState.getMembers(); const joinedMembers = members.filter(m => isJoinedOrNearlyJoined(m.membership)); const otherMember = joinedMembers.find(m => m.userId === userId); @@ -57,15 +60,15 @@ export function findDMForUser(client: MatrixClient, userId: string): Room { } } -function findDMRoom(client: MatrixClient, targets: Member[]): Room | null { +export function findDMRoom(client: MatrixClient, targets: Member[]): Room | null { const targetIds = targets.map(t => t.userId); let existingRoom: Room; if (targetIds.length === 1) { - existingRoom = findDMForUser(client, targetIds[0]); + existingRoom = thisModule.findDMForUser(client, targetIds[0]); } else { existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); } - if (existingRoom && !(existingRoom instanceof LocalRoom)) { + if (existingRoom) { return existingRoom; } return null; diff --git a/test/utils/direct-message-test.ts b/test/utils/direct-message-test.ts deleted file mode 100644 index fc3a4f35e9d..00000000000 --- a/test/utils/direct-message-test.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* -Copyright 2022 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 { mocked } from "jest-mock"; -import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; - -import { findDMForUser } from "../../src/utils/direct-messages"; -import DMRoomMap from "../../src/utils/DMRoomMap"; -import { makeMembershipEvent } from "../test-utils"; - -jest.mock("../../src/utils/DMRoomMap", () => jest.fn()); - -describe("findDMForUser", () => { - const userId1 = "@user1:example.com"; - const userId2 = "@user2:example.com"; - let dmRoomMap: DMRoomMap; - let mockClient: MatrixClient; - - beforeEach(() => { - dmRoomMap = { - getDMRoomsForUserId: jest.fn(), - } as unknown as DMRoomMap; - DMRoomMap.shared = () => dmRoomMap; - mockClient = { - getRoom: jest.fn(), - } as unknown as MatrixClient; - }); - - describe("for an empty DM room list", () => { - beforeEach(() => { - mocked(dmRoomMap.getDMRoomsForUserId).mockReturnValue([]); - }); - - it("should return null", () => { - expect(findDMForUser(mockClient, userId1)).toBeUndefined(); - }); - }); - - describe("for some rooms", () => { - let room1: Room; - let room2: Room; - let room3: Room; - let room4: Room; - - beforeEach(() => { - room1 = new Room("!room1:example.com", mockClient, userId1); - room1.getMyMembership = () => "join"; - room1.currentState.setStateEvents([ - makeMembershipEvent(room1.roomId, userId1, "join"), - makeMembershipEvent(room1.roomId, userId2, "join"), - ]); - room2 = new Room("!room2:example.com", mockClient, userId1); - room2.getMyMembership = () => "join"; - room2.currentState.setStateEvents([ - makeMembershipEvent(room2.roomId, userId1, "join"), - makeMembershipEvent(room2.roomId, userId2, "join"), - ]); - // this room should not be a DM room because it has only one joined user - room3 = new Room("!room3:example.com", mockClient, userId1); - room3.getMyMembership = () => "join"; - room3.currentState.setStateEvents([ - makeMembershipEvent(room3.roomId, userId1, "invite"), - makeMembershipEvent(room3.roomId, userId2, "join"), - ]); - // this room should not be a DM room because it has no users - room4 = new Room("!room4:example.com", mockClient, userId1); - room4.getLastActiveTimestamp = () => 100; - - mocked(mockClient.getRoom).mockImplementation((roomId: string) => { - return { - [room1.roomId]: room1, - [room2.roomId]: room2, - [room3.roomId]: room3, - [room4.roomId]: room3, - }[roomId]; - }); - - mocked(dmRoomMap.getDMRoomsForUserId).mockReturnValue([ - room1.roomId, - room2.roomId, - room3.roomId, - room4.roomId, - ]); - }); - - it("should find a room ordered by last activity 1", () => { - room1.getLastActiveTimestamp = () => 2; - room2.getLastActiveTimestamp = () => 1; - - expect(findDMForUser(mockClient, userId1)).toBe(room1); - }); - - it("should find a room ordered by last activity 2", () => { - room1.getLastActiveTimestamp = () => 1; - room2.getLastActiveTimestamp = () => 2; - - expect(findDMForUser(mockClient, userId1)).toBe(room2); - }); - }); -}); diff --git a/test/utils/direct-messages-test.ts b/test/utils/direct-messages-test.ts new file mode 100644 index 00000000000..7e13cfdc978 --- /dev/null +++ b/test/utils/direct-messages-test.ts @@ -0,0 +1,152 @@ +/* +Copyright 2022 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 { mocked } from "jest-mock"; +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import DMRoomMap from "../../src/utils/DMRoomMap"; +import { makeMembershipEvent } from "../test-utils"; +import { LocalRoom } from "../../src/models/LocalRoom"; +import * as dmModule from "../../src/utils/direct-messages"; + +describe("direct-messages", () => { + const userId1 = "@user1:example.com"; + const member1 = new dmModule.DirectoryMember({ user_id: userId1 }); + const userId2 = "@user2:example.com"; + const member2 = new dmModule.DirectoryMember({ user_id: userId2 }); + let room1: Room; + let dmRoomMap: DMRoomMap; + let mockClient: MatrixClient; + + beforeEach(() => { + jest.restoreAllMocks(); + + room1 = new Room("!room1:example.com", mockClient, userId1); + room1.getMyMembership = () => "join"; + room1.currentState.setStateEvents([ + makeMembershipEvent(room1.roomId, userId1, "join"), + makeMembershipEvent(room1.roomId, userId2, "join"), + ]); + + mockClient = { + getRoom: jest.fn(), + } as unknown as MatrixClient; + + dmRoomMap = { + getDMRoomForIdentifiers: jest.fn(), + getDMRoomsForUserId: jest.fn(), + } as unknown as DMRoomMap; + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + }); + + describe("findDMForUser", () => { + describe("for an empty DM room list", () => { + beforeEach(() => { + mocked(dmRoomMap.getDMRoomsForUserId).mockReturnValue([]); + }); + + it("should return undefined", () => { + expect(dmModule.findDMForUser(mockClient, userId1)).toBeUndefined(); + }); + }); + + describe("for some rooms", () => { + let room2: LocalRoom; + let room3: Room; + let room4: Room; + let room5: Room; + + beforeEach(() => { + // this should not be a DM room because it is a local room + room2 = new LocalRoom("!room2:example.com", mockClient, userId1); + room2.getLastActiveTimestamp = () => 100; + + room3 = new Room("!room3:example.com", mockClient, userId1); + room3.getMyMembership = () => "join"; + room3.currentState.setStateEvents([ + makeMembershipEvent(room3.roomId, userId1, "join"), + makeMembershipEvent(room3.roomId, userId2, "join"), + ]); + + // this should not be a DM room because it has only one joined user + room4 = new Room("!room4:example.com", mockClient, userId1); + room4.getMyMembership = () => "join"; + room4.currentState.setStateEvents([ + makeMembershipEvent(room4.roomId, userId1, "invite"), + makeMembershipEvent(room4.roomId, userId2, "join"), + ]); + + // this should not be a DM room because it has no users + room5 = new Room("!room5:example.com", mockClient, userId1); + room5.getLastActiveTimestamp = () => 100; + + mocked(mockClient.getRoom).mockImplementation((roomId: string) => { + return { + [room1.roomId]: room1, + [room2.roomId]: room2, + [room3.roomId]: room3, + [room4.roomId]: room4, + [room5.roomId]: room5, + }[roomId]; + }); + + mocked(dmRoomMap.getDMRoomsForUserId).mockReturnValue([ + room1.roomId, + room2.roomId, + room3.roomId, + room4.roomId, + room5.roomId, + ]); + }); + + it("should find a room ordered by last activity 1", () => { + room1.getLastActiveTimestamp = () => 2; + room3.getLastActiveTimestamp = () => 1; + + expect(dmModule.findDMForUser(mockClient, userId1)).toBe(room1); + }); + + it("should find a room ordered by last activity 2", () => { + room1.getLastActiveTimestamp = () => 1; + room3.getLastActiveTimestamp = () => 2; + + expect(dmModule.findDMForUser(mockClient, userId1)).toBe(room3); + }); + }); + }); + + describe("findDMRoom", () => { + it("should return the room for a single target with a room", () => { + jest.spyOn(dmModule, "findDMForUser").mockReturnValue(room1); + expect(dmModule.findDMRoom(mockClient, [member1])).toBe(room1); + }); + + it("should return null for a single target without a room", () => { + jest.spyOn(dmModule, "findDMForUser").mockReturnValue(null); + expect(dmModule.findDMRoom(mockClient, [member1])).toBeNull(); + }); + + it("should return the room for 2 targets with a room", () => { + mocked(dmRoomMap.getDMRoomForIdentifiers).mockReturnValue(room1); + expect(dmModule.findDMRoom(mockClient, [member1, member2])).toBe(room1); + }); + + it("should return null for 2 targets without a room", () => { + mocked(dmRoomMap.getDMRoomForIdentifiers).mockReturnValue(null); + expect(dmModule.findDMRoom(mockClient, [member1, member2])).toBeNull(); + }); + }); +}); From f7064e72f026af0c12ea98a2a6850d4ee35b72bb Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 4 Jul 2022 18:38:02 +0200 Subject: [PATCH 57/73] Implement direct messages tests / add docs --- src/models/LocalRoom.ts | 2 +- src/utils/direct-messages.ts | 142 +++++++++--- test/MatrixClientPeg-test.ts | 4 +- test/test-utils/test-utils.ts | 9 +- test/utils/direct-messages-test.ts | 360 ++++++++++++++++++++++++++++- 5 files changed, 462 insertions(+), 55 deletions(-) diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index b6cc8c57f74..fd28cf4b8fa 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -33,7 +33,7 @@ export enum LocalRoomState { */ export class LocalRoom extends Room { /** Whether the actual room should be encrypted. */ - encrypted: boolean; + encrypted = false; /** If the actual room has been created, this holds its ID. */ actualRoomId: string; /** DM chat partner */ diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 273486fea4c..554e998b0c0 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -33,6 +33,13 @@ import { privateShouldBeEncrypted } from "./rooms"; import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; import * as thisModule from "./direct-messages"; +/** + * Tries to find a DM room with a specific user. + * + * @param {MatrixClient} client + * @param {string} userId ID of the user to find the DM for + * @returns {Room} Room if found + */ export function findDMForUser(client: MatrixClient, userId: string): Room { const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); const rooms = roomIds.map(id => client.getRoom(id)); @@ -60,6 +67,13 @@ export function findDMForUser(client: MatrixClient, userId: string): Room { } } +/** + * Tries to find a DM room with some other users. + * + * @param {MatrixClient} client + * @param {Member[]} targets The Members to try to find the room for + * @returns {Room | null} Resolved so the room if found, else null + */ export function findDMRoom(client: MatrixClient, targets: Member[]): Room | null { const targetIds = targets.map(t => t.userId); let existingRoom: Room; @@ -78,7 +92,7 @@ export async function startDmOnFirstMessage( client: MatrixClient, targets: Member[], ): Promise { - const existingRoom = findDMRoom(client, targets); + const existingRoom = thisModule.findDMRoom(client, targets); if (existingRoom) { dis.dispatch({ action: Action.ViewRoom, @@ -90,7 +104,7 @@ export async function startDmOnFirstMessage( return existingRoom; } - const room = await createDmLocalRoom(client, targets); + const room = await thisModule.createDmLocalRoom(client, targets); dis.dispatch({ action: Action.ViewRoom, room_id: room.roomId, @@ -100,10 +114,20 @@ export async function startDmOnFirstMessage( return room; } +/** + * Create a DM local room. This room will not be send to the server and only exists inside the client. + * It sets up the local room with some artificial state events + * so that can be used in most components instead of a „real“ room. + * + * @async + * @param {MatrixClient} client + * @param {Member[]} targets DM partners + * @returns {Promise} Resolves to the new local room + */ export async function createDmLocalRoom( client: MatrixClient, targets: Member[], -): Promise { +): Promise { const userId = client.getUserId(); const localRoom = new LocalRoom( @@ -127,7 +151,7 @@ export async function createDmLocalRoom( user_id: userId, sender: userId, room_id: localRoom.roomId, - origin_server_ts: new Date().getTime(), + origin_server_ts: Date.now(), })); if (await determineCreateRoomEncryptionOption(client, targets)) { @@ -199,6 +223,14 @@ export async function createDmLocalRoom( return localRoom; } +/** + * Detects whether a room should be encrypted. + * + * @async + * @param {MatrixClient} client + * @param {Member[]} targets The members to which run the check against + * @returns {Promise} + */ async function determineCreateRoomEncryptionOption(client: MatrixClient, targets: Member[]): Promise { if (privateShouldBeEncrypted()) { // Check whether all users have uploaded device keys before. @@ -216,10 +248,15 @@ async function determineCreateRoomEncryptionOption(client: MatrixClient, targets return false; } -async function applyAfterCreateCallbacks( - localRoom: LocalRoom, - roomId: string, -) { +/** + * Applies the after-create callback of a local room. + * + * @async + * @param {LocalRoom} localRoom + * @param {string} roomId + * @returns {Promise} Resolved after all callbacks have been called + */ +async function applyAfterCreateCallbacks(localRoom: LocalRoom, roomId: string): Promise { for (const afterCreateCallback of localRoom.afterCreateCallbacks) { await afterCreateCallback(roomId); } @@ -230,7 +267,7 @@ async function applyAfterCreateCallbacks( /** * Tests whether a room created based on a local room is ready. */ -function isRoomReady( +export function isRoomReady( client: MatrixClient, localRoom: LocalRoom, ): boolean { @@ -241,8 +278,8 @@ function isRoomReady( // not ready if the room does not exist if (!room) return false; - // not ready if not all targets have been invited - if (room.getInvitedMemberCount() !== localRoom.targets.length) return false; + // not ready if not all members joined/invited + if (room.getInvitedAndJoinedMemberCount() !== 1 + localRoom.targets?.length) return false; const roomHistoryVisibilityEvents = room.currentState.getStateEvents(EventType.RoomHistoryVisibility); // not ready if the room history has not been configured @@ -255,7 +292,15 @@ function isRoomReady( return true; } -export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: LocalRoom) { +/** + * Starts a DM for a new local room. + * + * @async + * @param {MatrixClient} client + * @param {LocalRoom} localRoom + * @returns {Promise} Resolves to the created room id + */ +export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: LocalRoom): Promise { if (!localRoom.isNew) { // This action only makes sense for new local rooms. return; @@ -264,41 +309,62 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L localRoom.state = LocalRoomState.CREATING; client.emit(ClientEvent.Room, localRoom); - return new Promise((resolve) => { - let checkRoomStateInterval: number; - let stopgapTimeoutHandle: number; + return thisModule.startDm(client, localRoom.targets).then( + (roomId) => { + localRoom.actualRoomId = roomId; + return thisModule.waitForRoomReadyAndApplyAfterCreateCallbacks(client, localRoom); + }, + () => { + logger.warn(`Error creating DM for local room ${localRoom.roomId}`); + localRoom.state = LocalRoomState.ERROR; + client.emit(ClientEvent.Room, localRoom); + }, + ); +} - const stopgapFinish = () => { - logger.warn(`Assuming local room ${localRoom.roomId} is ready after hitting timeout`); - finish(); - }; +/** + * Waits until a room is ready and then applies the after-create local room callbacks. + * Also implements a stopgap timeout after that a room is assumed to be ready. + * + * @see isRoomReady + * @async + * @param {MatrixClient} client + * @param {LocalRoom} localRoom + * @returns {Promise} Resolved to the actual room id + */ +export async function waitForRoomReadyAndApplyAfterCreateCallbacks( + client: MatrixClient, + localRoom: LocalRoom, +): Promise { + if (thisModule.isRoomReady(client, localRoom)) { + return applyAfterCreateCallbacks(localRoom, localRoom.actualRoomId).then(() => { + localRoom.state = LocalRoomState.CREATED; + client.emit(ClientEvent.Room, localRoom); + return Promise.resolve(localRoom.actualRoomId); + }); + } + return new Promise((resolve) => { const finish = () => { - if (checkRoomStateInterval) clearInterval(checkRoomStateInterval); + if (checkRoomStateIntervalHandle) clearInterval(checkRoomStateIntervalHandle); if (stopgapTimeoutHandle) clearTimeout(stopgapTimeoutHandle); applyAfterCreateCallbacks(localRoom, localRoom.actualRoomId).then(() => { localRoom.state = LocalRoomState.CREATED; - resolve(); + client.emit(ClientEvent.Room, localRoom); + resolve(localRoom.actualRoomId); }); }; - startDm(client, localRoom.targets).then( - (roomId) => { - localRoom.actualRoomId = roomId; - if (isRoomReady(client, localRoom)) finish(); - stopgapTimeoutHandle = setTimeout(stopgapFinish, 5000); - // polling the room state is not as beautiful as listening on the events, but it is more reliable - checkRoomStateInterval = setInterval(() => { - if (isRoomReady(client, localRoom)) finish(); - }, 500); - }, - () => { - logger.warn(`Error creating DM for local room ${localRoom.roomId}`); - localRoom.state = LocalRoomState.ERROR; - client.emit(ClientEvent.Room, localRoom); - }, - ); + const stopgapFinish = () => { + logger.warn(`Assuming local room ${localRoom.roomId} is ready after hitting timeout`); + finish(); + }; + + const checkRoomStateIntervalHandle = setInterval(() => { + if (thisModule.isRoomReady(client, localRoom)) finish(); + }, 500); + const stopgapTimeoutHandle = setTimeout(stopgapFinish, 5000); }); } @@ -307,7 +373,7 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L * * @returns {Promise { +export async function startDm(client: MatrixClient, targets: Member[]): Promise { const targetIds = targets.map(t => t.userId); // Check if there is already a DM with these people and reuse it if possible. diff --git a/test/MatrixClientPeg-test.ts b/test/MatrixClientPeg-test.ts index 13a298d5a56..9b7700410d9 100644 --- a/test/MatrixClientPeg-test.ts +++ b/test/MatrixClientPeg-test.ts @@ -28,8 +28,8 @@ describe("MatrixClientPeg", () => { it("setJustRegisteredUserId", () => { stubClient(); (peg as any).matrixClient = peg.get(); - peg.setJustRegisteredUserId("@userId:matrix.rog"); - expect(peg.get().credentials.userId).toBe("@userId:matrix.rog"); + peg.setJustRegisteredUserId("@userId:matrix.org"); + expect(peg.get().credentials.userId).toBe("@userId:matrix.org"); expect(peg.currentUserIsJustRegistered()).toBe(true); expect(peg.userRegisteredWithinLastHours(0)).toBe(false); expect(peg.userRegisteredWithinLastHours(1)).toBe(true); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 5cfa94509b4..5060adb01c1 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -73,20 +73,22 @@ export function createTestClient(): MatrixClient { const eventEmitter = new EventEmitter(); const sendMessage = jest.fn(); sendMessage.mockResolvedValue({}); + let txnId = 1; return { getHomeserverUrl: jest.fn(), getIdentityServerUrl: jest.fn(), - getDomain: jest.fn().mockReturnValue("matrix.rog"), - getUserId: jest.fn().mockReturnValue("@userId:matrix.rog"), + getDomain: jest.fn().mockReturnValue("matrix.org"), + getUserId: jest.fn().mockReturnValue("@userId:matrix.org"), getUser: jest.fn().mockReturnValue({ on: jest.fn() }), getDeviceId: jest.fn().mockReturnValue("ABCDEFGHI"), getDevices: jest.fn().mockResolvedValue({ devices: [{ device_id: "ABCDEFGHI" }] }), - credentials: { userId: "@userId:matrix.rog" }, + credentials: { userId: "@userId:matrix.org" }, store: { getPendingEvents: jest.fn().mockResolvedValue([]), setPendingEvents: jest.fn().mockResolvedValue(undefined), + storeRoom: jest.fn(), }, getPushActionsForEvent: jest.fn(), @@ -159,6 +161,7 @@ export function createTestClient(): MatrixClient { isInitialSyncComplete: jest.fn().mockReturnValue(true), downloadKeys: jest.fn(), fetchRoomEvent: jest.fn(), + makeTxnId: jest.fn().mockImplementation(() => `t${txnId++}`), } as unknown as MatrixClient; } diff --git a/test/utils/direct-messages-test.ts b/test/utils/direct-messages-test.ts index 7e13cfdc978..95d7974be96 100644 --- a/test/utils/direct-messages-test.ts +++ b/test/utils/direct-messages-test.ts @@ -15,41 +15,96 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { + ClientEvent, + EventType, + KNOWN_SAFE_ROOM_VERSION, + MatrixClient, + Room, +} from "matrix-js-sdk/src/matrix"; import DMRoomMap from "../../src/utils/DMRoomMap"; -import { makeMembershipEvent } from "../test-utils"; -import { LocalRoom } from "../../src/models/LocalRoom"; +import { createTestClient, makeMembershipEvent, mkEvent } from "../test-utils"; +import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom"; import * as dmModule from "../../src/utils/direct-messages"; +import dis from "../../src/dispatcher/dispatcher"; +import { Action } from "../../src/dispatcher/actions"; +import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +import { privateShouldBeEncrypted } from "../../src/utils/rooms"; +import { Member } from "../../src/utils/direct-messages"; +import { canEncryptToAllUsers } from "../../src/createRoom"; + +jest.mock("../../src/utils/rooms", () => ({ + ...(jest.requireActual("../../src/utils/rooms") as object), + privateShouldBeEncrypted: jest.fn(), +})); + +jest.mock("../../src/createRoom", () => ({ + ...(jest.requireActual("../../src/createRoom") as object), + canEncryptToAllUsers: jest.fn(), +})); + +function assertLocalRoom(room: LocalRoom, targets: Member[], encrypted: boolean) { + expect(room.roomId).toBe(LOCAL_ROOM_ID_PREFIX + "t1"); + expect(room.encrypted).toBe(encrypted); + expect(room.targets).toEqual(targets); + expect(room.getMyMembership()).toBe("join"); + + const roomCreateEvent = room.currentState.getStateEvents(EventType.RoomCreate)[0]; + expect(roomCreateEvent).toBeDefined(); + expect(roomCreateEvent.getContent()["room_version"]).toBe(KNOWN_SAFE_ROOM_VERSION); + + // check that the user and all targets are joined + expect(room.getMember("@userId:matrix.org").membership).toBe("join"); + targets.forEach((target: Member) => { + expect(room.getMember(target.userId).membership).toBe("join"); + }); + + if (encrypted) { + const encryptionEvent = room.currentState.getStateEvents(EventType.RoomEncryption)[0]; + expect(encryptionEvent).toBeDefined(); + } +} describe("direct-messages", () => { const userId1 = "@user1:example.com"; const member1 = new dmModule.DirectoryMember({ user_id: userId1 }); const userId2 = "@user2:example.com"; - const member2 = new dmModule.DirectoryMember({ user_id: userId2 }); + const member2 = new dmModule.ThreepidMember("user2"); let room1: Room; + let localRoom: LocalRoom; + let localRoomCallbackRoomId: string; let dmRoomMap: DMRoomMap; let mockClient: MatrixClient; + let roomEvents: Room[]; beforeEach(() => { jest.restoreAllMocks(); + mockClient = createTestClient(); + jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient); + roomEvents = []; + mockClient.on(ClientEvent.Room, (room: Room) => { + roomEvents.push(room); + }); + room1 = new Room("!room1:example.com", mockClient, userId1); room1.getMyMembership = () => "join"; - room1.currentState.setStateEvents([ - makeMembershipEvent(room1.roomId, userId1, "join"), - makeMembershipEvent(room1.roomId, userId2, "join"), - ]); - mockClient = { - getRoom: jest.fn(), - } as unknown as MatrixClient; + localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", mockClient, userId1); + localRoom.afterCreateCallbacks.push((roomId: string) => { + localRoomCallbackRoomId = roomId; + return Promise.resolve(); + }); dmRoomMap = { getDMRoomForIdentifiers: jest.fn(), getDMRoomsForUserId: jest.fn(), } as unknown as DMRoomMap; jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + jest.spyOn(dis, "dispatch"); + + jest.setSystemTime(new Date(2022, 7, 4, 11, 12, 30, 42)); }); describe("findDMForUser", () => { @@ -70,6 +125,11 @@ describe("direct-messages", () => { let room5: Room; beforeEach(() => { + room1.currentState.setStateEvents([ + makeMembershipEvent(room1.roomId, userId1, "join"), + makeMembershipEvent(room1.roomId, userId2, "join"), + ]); + // this should not be a DM room because it is a local room room2 = new LocalRoom("!room2:example.com", mockClient, userId1); room2.getLastActiveTimestamp = () => 100; @@ -149,4 +209,282 @@ describe("direct-messages", () => { expect(dmModule.findDMRoom(mockClient, [member1, member2])).toBeNull(); }); }); + + describe("startDmOnFirstMessage", () => { + describe("if no room exists", () => { + beforeEach(() => { + jest.spyOn(dmModule, "findDMRoom").mockReturnValue(null); + }); + + it("should create a local room and dispatch a view room event", async () => { + jest.spyOn(dmModule, "createDmLocalRoom").mockResolvedValue(localRoom); + const room = await dmModule.startDmOnFirstMessage(mockClient, [member1]); + expect(room).toBe(localRoom); + expect(dis.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + joining: false, + targets: [member1], + }); + }); + }); + + describe("if a room exists", () => { + beforeEach(() => { + jest.spyOn(dmModule, "findDMRoom").mockReturnValue(room1); + }); + + it("should return the room and dispatch a view room event", async () => { + const room = await dmModule.startDmOnFirstMessage(mockClient, [member1]); + expect(room).toBe(room1); + expect(dis.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room1.roomId, + should_peek: false, + joining: false, + metricsTrigger: "MessageUser", + }); + }); + }); + }); + + describe("createDmLocalRoom", () => { + describe("when rooms should be encrypted", () => { + beforeEach(() => { + mocked(privateShouldBeEncrypted).mockReturnValue(true); + }); + + it("should create an unencrypted room for 3PID targets", async () => { + const room = await dmModule.createDmLocalRoom(mockClient, [member2]); + expect(mockClient.store.storeRoom).toHaveBeenCalledWith(room); + assertLocalRoom(room, [member2], false); + }); + + describe("for MXID targets with encryption available", () => { + beforeEach(() => { + mocked(canEncryptToAllUsers).mockResolvedValue(true); + }); + + it("should create an encrypted room", async () => { + const room = await dmModule.createDmLocalRoom(mockClient, [member1]); + expect(mockClient.store.storeRoom).toHaveBeenCalledWith(room); + assertLocalRoom(room, [member1], true); + }); + }); + + describe("for MXID targets with encryption unavailable", () => { + beforeEach(() => { + mocked(canEncryptToAllUsers).mockResolvedValue(false); + }); + + it("should create an unencrypted room", async () => { + const room = await dmModule.createDmLocalRoom(mockClient, [member1]); + expect(mockClient.store.storeRoom).toHaveBeenCalledWith(room); + assertLocalRoom(room, [member1], false); + }); + }); + }); + + describe("if rooms should not be encrypted", () => { + beforeEach(() => { + mocked(privateShouldBeEncrypted).mockReturnValue(false); + }); + + it("should create an unencrypted room", async () => { + const room = await dmModule.createDmLocalRoom(mockClient, [member1]); + assertLocalRoom(room, [member1], false); + }); + }); + }); + + describe("isRoomReady", () => { + beforeEach(() => { + localRoom.targets = [member1]; + }); + + it("should return false if the room has no actual room id", () => { + expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(false); + }); + + describe("for a room with an actual room id", () => { + beforeEach(() => { + localRoom.actualRoomId = room1.roomId; + mocked(mockClient.getRoom).mockReturnValue(null); + }); + + it("it should return false", () => { + expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(false); + }); + + describe("and the room is known to the client", () => { + beforeEach(() => { + mocked(mockClient.getRoom).mockImplementation((roomId: string) => { + if (roomId === room1.roomId) return room1; + }); + }); + + it("it should return false", () => { + expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(false); + }); + + describe("and all members have been invited or joined", () => { + beforeEach(() => { + room1.currentState.setStateEvents([ + makeMembershipEvent(room1.roomId, userId1, "join"), + makeMembershipEvent(room1.roomId, userId2, "invite"), + ]); + }); + + it("it should return false", () => { + expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(false); + }); + + describe("and a RoomHistoryVisibility event", () => { + beforeEach(() => { + room1.currentState.setStateEvents([mkEvent({ + user: userId1, + event: true, + type: EventType.RoomHistoryVisibility, + room: room1.roomId, + content: {}, + })]); + }); + + it("it should return true", () => { + expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(true); + }); + + describe("and an encrypted room", () => { + beforeEach(() => { + localRoom.encrypted = true; + }); + + it("it should return false", () => { + expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(false); + }); + + describe("and a room encryption state event", () => { + beforeEach(() => { + room1.currentState.setStateEvents([mkEvent({ + user: userId1, + event: true, + type: EventType.RoomEncryption, + room: room1.roomId, + content: {}, + })]); + }); + + it("it should return true", () => { + expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(true); + }); + }); + }); + }); + }); + }); + }); + }); + + describe("createRoomFromLocalRoom", () => { + beforeEach(() => { + jest.spyOn(dmModule, "startDm"); + }); + + [LocalRoomState.CREATING, LocalRoomState.CREATED, LocalRoomState.ERROR].forEach((state: LocalRoomState) => { + it(`should do nothing for room in state ${state}`, async () => { + localRoom.state = state; + await dmModule.createRoomFromLocalRoom(mockClient, localRoom); + expect(localRoom.state).toBe(state); + expect(dmModule.startDm).not.toHaveBeenCalled(); + }); + }); + + describe("on startDm error", () => { + beforeEach(() => { + mocked(dmModule.startDm).mockRejectedValue(true); + }); + + it("should set the room state to error", async () => { + await dmModule.createRoomFromLocalRoom(mockClient, localRoom); + expect(localRoom.state).toBe(LocalRoomState.ERROR); + }); + }); + + describe("on startDm success", () => { + beforeEach(() => { + jest.spyOn(dmModule, "waitForRoomReadyAndApplyAfterCreateCallbacks").mockResolvedValue(room1.roomId); + mocked(dmModule.startDm).mockResolvedValue(room1.roomId); + }); + + it( + "should set the room into creating state and call waitForRoomReadyAndApplyAfterCreateCallbacks", + async () => { + const result = await dmModule.createRoomFromLocalRoom(mockClient, localRoom); + expect(result).toBe(room1.roomId); + expect(localRoom.state).toBe(LocalRoomState.CREATING); + expect(dmModule.waitForRoomReadyAndApplyAfterCreateCallbacks).toHaveBeenCalledWith( + mockClient, + localRoom, + ); + }, + ); + }); + }); + + describe("waitForRoomReadyAndApplyAfterCreateCallbacks", () => { + beforeEach(() => { + localRoom.actualRoomId = room1.roomId; + jest.useFakeTimers(); + }); + + describe("for an immediate ready room", () => { + beforeEach(() => { + jest.spyOn(dmModule, "isRoomReady").mockReturnValue(true); + }); + + it("should invoke the callbacks, set the room state to created and return the actual room id", async () => { + const result = await dmModule.waitForRoomReadyAndApplyAfterCreateCallbacks(mockClient, localRoom); + expect(localRoom.state).toBe(LocalRoomState.CREATED); + expect(localRoomCallbackRoomId).toBe(room1.roomId); + expect(result).toBe(room1.roomId); + }); + }); + + describe("for a room running into the create timeout", () => { + beforeEach(() => { + jest.spyOn(dmModule, "isRoomReady").mockReturnValue(false); + }); + + it("should invoke the callbacks, set the room state to created and return the actual room id", (done) => { + const prom = dmModule.waitForRoomReadyAndApplyAfterCreateCallbacks(mockClient, localRoom); + jest.advanceTimersByTime(5000); + prom.then((roomId: string) => { + expect(localRoom.state).toBe(LocalRoomState.CREATED); + expect(localRoomCallbackRoomId).toBe(room1.roomId); + expect(roomId).toBe(room1.roomId); + expect(jest.getTimerCount()).toBe(0); + done(); + }); + }); + }); + + describe("for a room that is ready after a while", () => { + beforeEach(() => { + jest.spyOn(dmModule, "isRoomReady").mockReturnValue(false); + }); + + it("should invoke the callbacks, set the room state to created and return the actual room id", (done) => { + const prom = dmModule.waitForRoomReadyAndApplyAfterCreateCallbacks(mockClient, localRoom); + jest.spyOn(dmModule, "isRoomReady").mockReturnValue(true); + jest.advanceTimersByTime(500); + prom.then((roomId: string) => { + expect(localRoom.state).toBe(LocalRoomState.CREATED); + expect(localRoomCallbackRoomId).toBe(room1.roomId); + expect(roomId).toBe(room1.roomId); + expect(jest.getTimerCount()).toBe(0); + done(); + }); + }); + }); + }); }); From bf568ad4d48bf442dfba174d4cbf4157d1be1837 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 5 Jul 2022 11:58:09 +0200 Subject: [PATCH 58/73] Adapt spotlight search for start DM --- .../12-spotlight/spotlight.spec.ts | 14 ++++- src/components/structures/RoomView.tsx | 5 ++ .../dialogs/spotlight/SpotlightDialog.tsx | 5 +- test/components/structures/RoomView-test.tsx | 16 ++++- .../views/dialogs/SpotlightDialog-test.tsx | 59 ++++++++++++++++++- test/test-utils/test-utils.ts | 3 + 6 files changed, 94 insertions(+), 8 deletions(-) diff --git a/cypress/integration/12-spotlight/spotlight.spec.ts b/cypress/integration/12-spotlight/spotlight.spec.ts index e5507af7e62..67cd63f5f55 100644 --- a/cypress/integration/12-spotlight/spotlight.spec.ts +++ b/cypress/integration/12-spotlight/spotlight.spec.ts @@ -276,11 +276,19 @@ describe("Spotlight", () => { cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).click(); - }).then(() => { - cy.roomHeaderName().should("contain", bot2Name); - cy.get(".mx_RoomSublist[aria-label=People]").should("contain", bot2Name); }); + // Send first message to actually start DM + cy.roomHeaderName().should("contain", bot2Name); + cy.get(".mx_BasicMessageComposer_input") + .click() + .should("have.focus") + .type("Hey!{enter}"); + + // Assert DM exists by checking for the first message and the room being in the room list + cy.contains(".mx_EventTile_body", "Hey!"); + cy.get(".mx_RoomSublist[aria-label=People]").should("contain", bot2Name); + // Invite BotBob into existing DM with ByteBot cy.getDmRooms(bot2.getUserId()).then(dmRooms => dmRooms[0]) .then(groupDmId => cy.inviteUser(groupDmId, bot1.getUserId())) diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 386fcce433b..5195a4bd46b 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -905,6 +905,11 @@ export class RoomView extends React.Component { for (const watcher of this.settingWatchers) { SettingsStore.unwatchSetting(watcher); } + + if (this.state.room instanceof LocalRoom) { + // clean up if this was a local room + this.props.mxClient.store.removeRoom(this.state.room.roomId); + } } private onRightPanelStoreUpdate = () => { diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 652e2679122..97fd73a8699 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -89,6 +89,7 @@ import { RoomResultDetails } from "./RoomResultDetails"; import { TooltipOption } from "./TooltipOption"; import LabelledCheckbox from "../../elements/LabelledCheckbox"; import { useFeatureEnabled } from "../../../../hooks/useSettings"; +import { LocalRoom } from "../../../../models/LocalRoom"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons @@ -241,6 +242,8 @@ export const useWebSearchMetrics = (numResults: number, queryLength: number, via const findVisibleRooms = (cli: MatrixClient) => { return cli.getVisibleRooms().filter(room => { + // Do not show local room + if (room instanceof LocalRoom) return false; // TODO we may want to put invites in their own list return room.getMyMembership() === "join" || room.getMyMembership() == "invite"; }); @@ -361,7 +364,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n possibleResults.forEach(entry => { if (isRoomResult(entry)) { - if (!entry.room.normalizedName.includes(normalizedQuery) && + if (!entry.room.normalizedName?.includes(normalizedQuery) && !entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) && !entry.query?.some(q => q.includes(lcQuery)) ) return; // bail, does not match query diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index 3b529ec7008..6d5d6a4dace 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -36,6 +36,7 @@ import DMRoomMap from "../../../src/utils/DMRoomMap"; import { NotificationState } from "../../../src/stores/notifications/NotificationState"; import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases"; +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../src/models/LocalRoom"; const RoomView = wrapInMatrixClientContext(_RoomView); @@ -50,7 +51,7 @@ describe("RoomView", () => { room = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); room.getPendingEvents = () => []; - cli.getRoom.mockReturnValue(room); + cli.getRoom.mockImplementation(() => room); // Re-emit certain events on the mocked client room.on(RoomEvent.Timeline, (...args) => cli.emit(RoomEvent.Timeline, ...args)); room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args)); @@ -163,4 +164,17 @@ describe("RoomView", () => { expect(RightPanelStore.instance.currentCard.phase).toEqual(RightPanelPhases.Timeline); }); }); + + describe("local rooms", () => { + beforeEach(() => { + room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", cli, cli.getUserId()); + cli.store.storeRoom(room); + }); + + it("should remove the room from the store on unmount", async () => { + const roomView = await mountRoomView(); + roomView.unmount(); + expect(cli.store.removeRoom).toHaveBeenCalledWith(room.roomId); + }); + }); }); diff --git a/test/components/views/dialogs/SpotlightDialog-test.tsx b/test/components/views/dialogs/SpotlightDialog-test.tsx index 6c507ec1f65..cfb754c753e 100644 --- a/test/components/views/dialogs/SpotlightDialog-test.tsx +++ b/test/components/views/dialogs/SpotlightDialog-test.tsx @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { mount } from "enzyme"; -import { IProtocol, IPublicRoomsChunkRoom, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix"; +import { mount, ReactWrapper } from "enzyme"; +import { mocked } from "jest-mock"; +import { IProtocol, IPublicRoomsChunkRoom, MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { sleep } from "matrix-js-sdk/src/utils"; import React from "react"; import { act } from "react-dom/test-utils"; @@ -23,8 +24,10 @@ import sanitizeHtml from "sanitize-html"; import SpotlightDialog, { Filter } from "../../../../src/components/views/dialogs/spotlight/SpotlightDialog"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoom"; import { DirectoryMember, startDmOnFirstMessage } from "../../../../src/utils/direct-messages"; -import { stubClient } from "../../../test-utils"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import { mkRoom, stubClient } from "../../../test-utils"; jest.mock("../../../../src/utils/direct-messages", () => ({ // @ts-ignore @@ -117,10 +120,22 @@ describe("Spotlight Dialog", () => { guest_can_join: false, }; + let testRoom: Room; + let testLocalRoom: LocalRoom; + let mockedClient: MatrixClient; beforeEach(() => { mockedClient = mockClient({ rooms: [testPublicRoom], users: [testPerson] }); + testRoom = mkRoom(mockedClient, "!test23:example.com"); + mocked(testRoom.getMyMembership).mockReturnValue("join"); + testLocalRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test23", mockedClient, mockedClient.getUserId()); + testLocalRoom.updateMyMembership("join"); + mocked(mockedClient.getVisibleRooms).mockReturnValue([testRoom, testLocalRoom]); + + jest.spyOn(DMRoomMap, "shared").mockReturnValue({ + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap); }); describe("should apply filters supplied via props", () => { it("without filter", async () => { @@ -298,6 +313,44 @@ describe("Spotlight Dialog", () => { }); }); + describe("searching for rooms", () => { + let wrapper: ReactWrapper; + let options: ReactWrapper; + + beforeAll(async () => { + wrapper = mount( + null} />, + ); + await act(async () => { + await sleep(200); + }); + wrapper.update(); + + const content = wrapper.find("#mx_SpotlightDialog_content"); + options = content.find("div.mx_SpotlightDialog_option"); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + it("should find Rooms", () => { + expect(options.length).toBe(3); + expect(options.first().text()).toContain(testRoom.name); + }); + + it("should not find LocalRooms", () => { + expect(options.length).toBe(3); + expect(options.first().text()).not.toContain(testLocalRoom.name); + }); + }); + + it("searching for rooms should display Rooms but not LocalRooms", async () => { + + }); + it("should start a DM when clicking a person", async () => { const wrapper = mount( 'mxc://avatar.url/room.png', getMxcAvatarUrl: () => 'mxc://avatar.url/room.png', isSpaceRoom: jest.fn().mockReturnValue(false), From 552a50beefea6ebd0d8bc9346fe045a7d905b1f3 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 5 Jul 2022 16:11:26 +0200 Subject: [PATCH 59/73] Add RoomView test --- src/components/views/rooms/NewRoomIntro.tsx | 3 +- src/models/LocalRoom.ts | 9 +- src/utils/direct-messages.ts | 12 +-- test/LocalRoom-test.ts | 14 +++- test/components/structures/RoomView-test.tsx | 83 +++++++++++++++++-- .../__snapshots__/RoomView-test.tsx.snap | 9 ++ .../filters/VisibilityProvider-test.ts | 5 +- test/utils/local-room-test.ts | 10 +-- 8 files changed, 115 insertions(+), 30 deletions(-) create mode 100644 test/components/structures/__snapshots__/RoomView-test.tsx.snap diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 4a665217fa4..2a68b84dcc7 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -43,6 +43,7 @@ import { LocalRoom } from "../../../models/LocalRoom"; function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean { const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId); const isPublic: boolean = room.getJoinRule() === "public"; + console.log(`isEncrypted: ${isEncrypted}`); return isPublic || !privateShouldBeEncrypted() || isEncrypted; } @@ -51,7 +52,7 @@ const NewRoomIntro = () => { const { room, roomId } = useContext(RoomContext); const dmPartner = room instanceof LocalRoom - ? room.targets[0].userId + ? room.targets[0]?.userId : DMRoomMap.shared().getUserIdForRoomId(roomId); let body; diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index fd28cf4b8fa..ee58dd408fa 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, Room, PendingEventOrdering } from "matrix-js-sdk/src/matrix"; import { Member } from "../utils/direct-messages"; @@ -37,11 +37,16 @@ export class LocalRoom extends Room { /** If the actual room has been created, this holds its ID. */ actualRoomId: string; /** DM chat partner */ - targets: Member[]; + targets: Member[] = []; /** Callbacks that should be invoked after the actual room has been created. */ afterCreateCallbacks: Function[] = []; state: LocalRoomState = LocalRoomState.NEW; + constructor(roomId: string, client: MatrixClient, myUserId: string) { + super(roomId, client, myUserId, { pendingEventOrdering: PendingEventOrdering.Detached }); + this.name = this.getDefaultRoomName(myUserId); + } + public get isNew(): boolean { return this.state === LocalRoomState.NEW; } diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 554e998b0c0..8c34325b6e2 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { IInvite3PID } from "matrix-js-sdk/src/@types/requests"; -import { ClientEvent, MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; import { EventType } from "matrix-js-sdk/src/matrix"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { KNOWN_SAFE_ROOM_VERSION, Room } from "matrix-js-sdk/src/models/room"; @@ -130,14 +130,7 @@ export async function createDmLocalRoom( ): Promise { const userId = client.getUserId(); - const localRoom = new LocalRoom( - LOCAL_ROOM_ID_PREFIX + client.makeTxnId(), - client, - userId, - { - pendingEventOrdering: PendingEventOrdering.Detached, - }, - ); + const localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + client.makeTxnId(), client, userId); const events = []; events.push(new MatrixEvent({ @@ -217,7 +210,6 @@ export async function createDmLocalRoom( localRoom.updateMyMembership("join"); localRoom.addLiveEvents(events); localRoom.currentState.setStateEvents(events); - localRoom.name = localRoom.getDefaultRoomName(userId); client.store.storeRoom(localRoom); return localRoom; diff --git a/test/LocalRoom-test.ts b/test/LocalRoom-test.ts index 2d456c547b7..4286f09e535 100644 --- a/test/LocalRoom-test.ts +++ b/test/LocalRoom-test.ts @@ -17,6 +17,7 @@ limitations under the License. import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../src/models/LocalRoom"; +import { createTestClient } from "./test-utils"; const stateTestData = [ { @@ -51,15 +52,26 @@ const stateTestData = [ describe("LocalRoom", () => { let room: LocalRoom; + let client: MatrixClient; beforeEach(() => { - room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", {} as unknown as MatrixClient, "@test:localhost"); + client = createTestClient(); + room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, "@test:localhost"); + }); + + it("should not raise an error on getPendingEvents (implicitly check for pendingEventOrdering: detached)", () => { + room.getPendingEvents(); + expect(true).toBe(true); }); it("should not have after create callbacks", () => { expect(room.afterCreateCallbacks).toHaveLength(0); }); + it("should have a name", () => { + expect(room.name).toBe("Empty room"); + }); + stateTestData.forEach((stateTestDatum) => { describe(`in state ${stateTestDatum.name}`, () => { beforeEach(() => { diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index 6d5d6a4dace..01f4f63e7f3 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -21,11 +21,13 @@ import { mocked, MockedObject } from "jest-mock"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { EventType } from "matrix-js-sdk/src/matrix"; +import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; import { stubClient, mockPlatformPeg, unmockPlatformPeg, wrapInMatrixClientContext } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { Action } from "../../../src/dispatcher/actions"; -import dis from "../../../src/dispatcher/dispatcher"; +import { defaultDispatcher } from "../../../src/dispatcher/dispatcher"; import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload"; import { RoomView as _RoomView } from "../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../src/utils/ResizeNotifier"; @@ -36,7 +38,8 @@ import DMRoomMap from "../../../src/utils/DMRoomMap"; import { NotificationState } from "../../../src/stores/notifications/NotificationState"; import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases"; -import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../src/models/LocalRoom"; +import { LocalRoom, LocalRoomState } from "../../../src/models/LocalRoom"; +import { createDmLocalRoom, DirectoryMember } from "../../../src/utils/direct-messages"; const RoomView = wrapInMatrixClientContext(_RoomView); @@ -44,6 +47,7 @@ describe("RoomView", () => { let cli: MockedObject; let room: Room; let roomCount = 0; + beforeEach(async () => { mockPlatformPeg({ reload: () => {} }); stubClient(); @@ -76,7 +80,7 @@ describe("RoomView", () => { }); }); - dis.dispatch({ + defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: room.roomId, metricsTrigger: null, @@ -165,16 +169,81 @@ describe("RoomView", () => { }); }); - describe("local rooms", () => { - beforeEach(() => { - room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", cli, cli.getUserId()); + describe("for a local room", () => { + let localRoom: LocalRoom; + let roomView: ReactWrapper; + + beforeEach(async () => { + localRoom = room = await createDmLocalRoom(cli, [new DirectoryMember({ user_id: "@user:example.com" })]); cli.store.storeRoom(room); }); it("should remove the room from the store on unmount", async () => { - const roomView = await mountRoomView(); + roomView = await mountRoomView(); roomView.unmount(); expect(cli.store.removeRoom).toHaveBeenCalledWith(room.roomId); }); + + describe("in state NEW", () => { + it("should match the snapshot", async () => { + roomView = await mountRoomView(); + expect(roomView.html()).toMatchSnapshot(); + }); + + describe("that is encrypted", () => { + beforeEach(() => { + mocked(cli.isRoomEncrypted).mockReturnValue(true); + localRoom.encrypted = true; + localRoom.currentState.setStateEvents([ + new MatrixEvent({ + event_id: `~${localRoom.roomId}:${cli.makeTxnId()}`, + type: EventType.RoomEncryption, + content: { + algorithm: MEGOLM_ALGORITHM, + }, + user_id: cli.getUserId(), + sender: cli.getUserId(), + state_key: "", + room_id: localRoom.roomId, + origin_server_ts: Date.now(), + }), + ]); + }); + + it("should match the snapshot", async () => { + const roomView = await mountRoomView(); + expect(roomView.html()).toMatchSnapshot(); + }); + }); + }); + + it("in state CREATING should match the snapshot", async () => { + localRoom.state = LocalRoomState.CREATING; + roomView = await mountRoomView(); + expect(roomView.html()).toMatchSnapshot(); + }); + + describe("in state ERROR", () => { + beforeEach(async () => { + localRoom.state = LocalRoomState.ERROR; + roomView = await mountRoomView(); + }); + + it("should match the snapshot", async () => { + expect(roomView.html()).toMatchSnapshot(); + }); + + it("clicking retry should set the room state to new dispatch a local room event", () => { + jest.spyOn(defaultDispatcher, "dispatch"); + roomView.findWhere((w: ReactWrapper) => { + return w.hasClass("mx_RoomStatusBar_unsentRetry") && w.text() === "Retry"; + }).first().simulate("click"); + expect(localRoom.state).toBe(LocalRoomState.NEW); + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "local_room_event", + roomId: room.roomId, + }); + }); + }); }); }); diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap new file mode 100644 index 00000000000..028ff47b178 --- /dev/null +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
E\\"\\"
Empty room
We're creating a room with @user:example.com
"`; + +exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
E\\"\\"
Empty room
    End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    E\\"\\"

    Empty room

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; + +exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
E\\"\\"
Empty room
    End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    E\\"\\"

    Empty room

    Send your first message to invite @user:example.com to chat


"`; + +exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
E\\"\\"
Empty room
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
    E\\"\\"

    Empty room

    Send your first message to invite @user:example.com to chat


"`; diff --git a/test/stores/room-list/filters/VisibilityProvider-test.ts b/test/stores/room-list/filters/VisibilityProvider-test.ts index 5a5bbfb7f90..596e815d364 100644 --- a/test/stores/room-list/filters/VisibilityProvider-test.ts +++ b/test/stores/room-list/filters/VisibilityProvider-test.ts @@ -15,13 +15,14 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { Room } from "matrix-js-sdk/src/matrix"; import { VisibilityProvider } from "../../../../src/stores/room-list/filters/VisibilityProvider"; import CallHandler from "../../../../src/CallHandler"; import VoipUserMapper from "../../../../src/VoipUserMapper"; import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoom"; import { RoomListCustomisations } from "../../../../src/customisations/RoomList"; +import { createTestClient } from "../../../test-utils"; jest.mock("../../../../src/VoipUserMapper", () => ({ sharedInstance: jest.fn(), @@ -46,7 +47,7 @@ const createRoom = (isSpaceRoom = false): Room => { }; const createLocalRoom = (): LocalRoom => { - const room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", {} as unknown as MatrixClient, "@test:example.com"); + const room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", createTestClient(), "@test:example.com"); room.isSpaceRoom = () => false; return room; }; diff --git a/test/utils/local-room-test.ts b/test/utils/local-room-test.ts index 03aafc42ddd..251d8fcf9f4 100644 --- a/test/utils/local-room-test.ts +++ b/test/utils/local-room-test.ts @@ -20,10 +20,7 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom"; import { doMaybeLocalRoomAction } from "../../src/utils/local-room"; import defaultDispatcher from "../../src/dispatcher/dispatcher"; - -jest.mock("../../src/dispatcher/dispatcher", () => ({ - dispatch: jest.fn(), -})); +import { createTestClient } from "../test-utils"; describe("doMaybeLocalRoomAction", () => { let callback: jest.Mock; @@ -33,9 +30,7 @@ describe("doMaybeLocalRoomAction", () => { beforeEach(() => { callback = jest.fn(); callback.mockReturnValue(Promise.resolve()); - client = { - getRoom: jest.fn(), - } as unknown as MatrixClient; + client = createTestClient(); localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, "@test:example.com"); localRoom.actualRoomId = "@new:example.com"; mocked(client.getRoom).mockImplementation((roomId: string) => { @@ -61,6 +56,7 @@ describe("doMaybeLocalRoomAction", () => { let prom; beforeEach(() => { + jest.spyOn(defaultDispatcher, "dispatch"); prom = doMaybeLocalRoomAction(localRoom.roomId, callback, client); }); From 2a7ef60a1c849d2c1ac5a30ae87f432dc1cc08e2 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 6 Jul 2022 13:10:27 +0200 Subject: [PATCH 60/73] Fix room name; reorganise code --- src/utils/direct-messages.ts | 94 +------- src/utils/local-room.ts | 96 +++++++- test/LocalRoom-test.ts | 4 - .../__snapshots__/RoomView-test.tsx.snap | 8 +- test/utils/direct-messages-test.ts | 162 +------------ test/utils/local-room-test.ts | 218 +++++++++++++++--- 6 files changed, 301 insertions(+), 281 deletions(-) diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 8c34325b6e2..97cd8374e5e 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -32,6 +32,7 @@ import dis from "../dispatcher/dispatcher"; import { privateShouldBeEncrypted } from "./rooms"; import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; import * as thisModule from "./direct-messages"; +import { waitForRoomReadyAndApplyAfterCreateCallbacks } from "./local-room"; /** * Tries to find a DM room with a specific user. @@ -210,6 +211,7 @@ export async function createDmLocalRoom( localRoom.updateMyMembership("join"); localRoom.addLiveEvents(events); localRoom.currentState.setStateEvents(events); + localRoom.name = localRoom.getDefaultRoomName(client.getUserId()); client.store.storeRoom(localRoom); return localRoom; @@ -240,50 +242,6 @@ async function determineCreateRoomEncryptionOption(client: MatrixClient, targets return false; } -/** - * Applies the after-create callback of a local room. - * - * @async - * @param {LocalRoom} localRoom - * @param {string} roomId - * @returns {Promise} Resolved after all callbacks have been called - */ -async function applyAfterCreateCallbacks(localRoom: LocalRoom, roomId: string): Promise { - for (const afterCreateCallback of localRoom.afterCreateCallbacks) { - await afterCreateCallback(roomId); - } - - localRoom.afterCreateCallbacks = []; -} - -/** - * Tests whether a room created based on a local room is ready. - */ -export function isRoomReady( - client: MatrixClient, - localRoom: LocalRoom, -): boolean { - // not ready if no actual room id exists - if (!localRoom.actualRoomId) return false; - - const room = client.getRoom(localRoom.actualRoomId); - // not ready if the room does not exist - if (!room) return false; - - // not ready if not all members joined/invited - if (room.getInvitedAndJoinedMemberCount() !== 1 + localRoom.targets?.length) return false; - - const roomHistoryVisibilityEvents = room.currentState.getStateEvents(EventType.RoomHistoryVisibility); - // not ready if the room history has not been configured - if (roomHistoryVisibilityEvents.length === 0) return false; - - const roomEncryptionEvents = room.currentState.getStateEvents(EventType.RoomEncryption); - // not ready if encryption has not been configured (applies only to encrypted rooms) - if (localRoom.encrypted === true && roomEncryptionEvents.length === 0) return false; - - return true; -} - /** * Starts a DM for a new local room. * @@ -304,7 +262,7 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L return thisModule.startDm(client, localRoom.targets).then( (roomId) => { localRoom.actualRoomId = roomId; - return thisModule.waitForRoomReadyAndApplyAfterCreateCallbacks(client, localRoom); + return waitForRoomReadyAndApplyAfterCreateCallbacks(client, localRoom); }, () => { logger.warn(`Error creating DM for local room ${localRoom.roomId}`); @@ -314,52 +272,6 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L ); } -/** - * Waits until a room is ready and then applies the after-create local room callbacks. - * Also implements a stopgap timeout after that a room is assumed to be ready. - * - * @see isRoomReady - * @async - * @param {MatrixClient} client - * @param {LocalRoom} localRoom - * @returns {Promise} Resolved to the actual room id - */ -export async function waitForRoomReadyAndApplyAfterCreateCallbacks( - client: MatrixClient, - localRoom: LocalRoom, -): Promise { - if (thisModule.isRoomReady(client, localRoom)) { - return applyAfterCreateCallbacks(localRoom, localRoom.actualRoomId).then(() => { - localRoom.state = LocalRoomState.CREATED; - client.emit(ClientEvent.Room, localRoom); - return Promise.resolve(localRoom.actualRoomId); - }); - } - - return new Promise((resolve) => { - const finish = () => { - if (checkRoomStateIntervalHandle) clearInterval(checkRoomStateIntervalHandle); - if (stopgapTimeoutHandle) clearTimeout(stopgapTimeoutHandle); - - applyAfterCreateCallbacks(localRoom, localRoom.actualRoomId).then(() => { - localRoom.state = LocalRoomState.CREATED; - client.emit(ClientEvent.Room, localRoom); - resolve(localRoom.actualRoomId); - }); - }; - - const stopgapFinish = () => { - logger.warn(`Assuming local room ${localRoom.roomId} is ready after hitting timeout`); - finish(); - }; - - const checkRoomStateIntervalHandle = setInterval(() => { - if (thisModule.isRoomReady(client, localRoom)) finish(); - }, 500); - const stopgapTimeoutHandle = setTimeout(stopgapFinish, 5000); - }); -} - /** * Start a DM. * diff --git a/src/utils/local-room.ts b/src/utils/local-room.ts index fb388f08e17..29fab7206d1 100644 --- a/src/utils/local-room.ts +++ b/src/utils/local-room.ts @@ -14,11 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { ClientEvent, EventType, MatrixClient } from "matrix-js-sdk/src/matrix"; import defaultDispatcher from "../dispatcher/dispatcher"; import { MatrixClientPeg } from "../MatrixClientPeg"; -import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; +import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; +import * as thisModule from "./local-room"; /** * Does a room action: @@ -59,3 +61,93 @@ export async function doMaybeLocalRoomAction( return fn(roomId); } + +/** + * Tests whether a room created based on a local room is ready. + */ +export function isRoomReady( + client: MatrixClient, + localRoom: LocalRoom, +): boolean { + // not ready if no actual room id exists + if (!localRoom.actualRoomId) return false; + + const room = client.getRoom(localRoom.actualRoomId); + // not ready if the room does not exist + if (!room) return false; + + // not ready if not all members joined/invited + if (room.getInvitedAndJoinedMemberCount() !== 1 + localRoom.targets?.length) return false; + + const roomHistoryVisibilityEvents = room.currentState.getStateEvents(EventType.RoomHistoryVisibility); + // not ready if the room history has not been configured + if (roomHistoryVisibilityEvents.length === 0) return false; + + const roomEncryptionEvents = room.currentState.getStateEvents(EventType.RoomEncryption); + // not ready if encryption has not been configured (applies only to encrypted rooms) + if (localRoom.encrypted === true && roomEncryptionEvents.length === 0) return false; + + return true; +} + +/** + * Waits until a room is ready and then applies the after-create local room callbacks. + * Also implements a stopgap timeout after that a room is assumed to be ready. + * + * @see isRoomReady + * @async + * @param {MatrixClient} client + * @param {LocalRoom} localRoom + * @returns {Promise} Resolved to the actual room id + */ +export async function waitForRoomReadyAndApplyAfterCreateCallbacks( + client: MatrixClient, + localRoom: LocalRoom, +): Promise { + if (thisModule.isRoomReady(client, localRoom)) { + return applyAfterCreateCallbacks(localRoom, localRoom.actualRoomId).then(() => { + localRoom.state = LocalRoomState.CREATED; + client.emit(ClientEvent.Room, localRoom); + return Promise.resolve(localRoom.actualRoomId); + }); + } + + return new Promise((resolve) => { + const finish = () => { + if (checkRoomStateIntervalHandle) clearInterval(checkRoomStateIntervalHandle); + if (stopgapTimeoutHandle) clearTimeout(stopgapTimeoutHandle); + + applyAfterCreateCallbacks(localRoom, localRoom.actualRoomId).then(() => { + localRoom.state = LocalRoomState.CREATED; + client.emit(ClientEvent.Room, localRoom); + resolve(localRoom.actualRoomId); + }); + }; + + const stopgapFinish = () => { + logger.warn(`Assuming local room ${localRoom.roomId} is ready after hitting timeout`); + finish(); + }; + + const checkRoomStateIntervalHandle = setInterval(() => { + if (thisModule.isRoomReady(client, localRoom)) finish(); + }, 500); + const stopgapTimeoutHandle = setTimeout(stopgapFinish, 5000); + }); +} + +/** + * Applies the after-create callback of a local room. + * + * @async + * @param {LocalRoom} localRoom + * @param {string} roomId + * @returns {Promise} Resolved after all callbacks have been called + */ +async function applyAfterCreateCallbacks(localRoom: LocalRoom, roomId: string): Promise { + for (const afterCreateCallback of localRoom.afterCreateCallbacks) { + await afterCreateCallback(roomId); + } + + localRoom.afterCreateCallbacks = []; +} diff --git a/test/LocalRoom-test.ts b/test/LocalRoom-test.ts index 4286f09e535..2ca05998c85 100644 --- a/test/LocalRoom-test.ts +++ b/test/LocalRoom-test.ts @@ -68,10 +68,6 @@ describe("LocalRoom", () => { expect(room.afterCreateCallbacks).toHaveLength(0); }); - it("should have a name", () => { - expect(room.name).toBe("Empty room"); - }); - stateTestData.forEach((stateTestDatum) => { describe(`in state ${stateTestDatum.name}`, () => { beforeEach(() => { diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index 028ff47b178..f648d7a1682 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
E\\"\\"
Empty room
We're creating a room with @user:example.com
"`; +exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
We're creating a room with @user:example.com
"`; -exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
E\\"\\"
Empty room
    End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    E\\"\\"

    Empty room

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; +exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
    End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; -exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
E\\"\\"
Empty room
    End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    E\\"\\"

    Empty room

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
    End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; -exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
E\\"\\"
Empty room
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
    E\\"\\"

    Empty room

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; diff --git a/test/utils/direct-messages-test.ts b/test/utils/direct-messages-test.ts index 95d7974be96..df8534829c3 100644 --- a/test/utils/direct-messages-test.ts +++ b/test/utils/direct-messages-test.ts @@ -24,7 +24,7 @@ import { } from "matrix-js-sdk/src/matrix"; import DMRoomMap from "../../src/utils/DMRoomMap"; -import { createTestClient, makeMembershipEvent, mkEvent } from "../test-utils"; +import { createTestClient, makeMembershipEvent } from "../test-utils"; import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom"; import * as dmModule from "../../src/utils/direct-messages"; import dis from "../../src/dispatcher/dispatcher"; @@ -33,6 +33,7 @@ import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import { privateShouldBeEncrypted } from "../../src/utils/rooms"; import { Member } from "../../src/utils/direct-messages"; import { canEncryptToAllUsers } from "../../src/createRoom"; +import { waitForRoomReadyAndApplyAfterCreateCallbacks } from "../../src/utils/local-room"; jest.mock("../../src/utils/rooms", () => ({ ...(jest.requireActual("../../src/utils/rooms") as object), @@ -44,8 +45,13 @@ jest.mock("../../src/createRoom", () => ({ canEncryptToAllUsers: jest.fn(), })); +jest.mock("../../src/utils/local-room", () => ({ + waitForRoomReadyAndApplyAfterCreateCallbacks: jest.fn(), +})); + function assertLocalRoom(room: LocalRoom, targets: Member[], encrypted: boolean) { expect(room.roomId).toBe(LOCAL_ROOM_ID_PREFIX + "t1"); + expect(room.name).toBe(targets.length ? targets[0].name : "Empty Room"); expect(room.encrypted).toBe(encrypted); expect(room.targets).toEqual(targets); expect(room.getMyMembership()).toBe("join"); @@ -73,7 +79,6 @@ describe("direct-messages", () => { const member2 = new dmModule.ThreepidMember("user2"); let room1: Room; let localRoom: LocalRoom; - let localRoomCallbackRoomId: string; let dmRoomMap: DMRoomMap; let mockClient: MatrixClient; let roomEvents: Room[]; @@ -92,10 +97,6 @@ describe("direct-messages", () => { room1.getMyMembership = () => "join"; localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", mockClient, userId1); - localRoom.afterCreateCallbacks.push((roomId: string) => { - localRoomCallbackRoomId = roomId; - return Promise.resolve(); - }); dmRoomMap = { getDMRoomForIdentifiers: jest.fn(), @@ -297,94 +298,6 @@ describe("direct-messages", () => { }); }); - describe("isRoomReady", () => { - beforeEach(() => { - localRoom.targets = [member1]; - }); - - it("should return false if the room has no actual room id", () => { - expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(false); - }); - - describe("for a room with an actual room id", () => { - beforeEach(() => { - localRoom.actualRoomId = room1.roomId; - mocked(mockClient.getRoom).mockReturnValue(null); - }); - - it("it should return false", () => { - expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(false); - }); - - describe("and the room is known to the client", () => { - beforeEach(() => { - mocked(mockClient.getRoom).mockImplementation((roomId: string) => { - if (roomId === room1.roomId) return room1; - }); - }); - - it("it should return false", () => { - expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(false); - }); - - describe("and all members have been invited or joined", () => { - beforeEach(() => { - room1.currentState.setStateEvents([ - makeMembershipEvent(room1.roomId, userId1, "join"), - makeMembershipEvent(room1.roomId, userId2, "invite"), - ]); - }); - - it("it should return false", () => { - expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(false); - }); - - describe("and a RoomHistoryVisibility event", () => { - beforeEach(() => { - room1.currentState.setStateEvents([mkEvent({ - user: userId1, - event: true, - type: EventType.RoomHistoryVisibility, - room: room1.roomId, - content: {}, - })]); - }); - - it("it should return true", () => { - expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(true); - }); - - describe("and an encrypted room", () => { - beforeEach(() => { - localRoom.encrypted = true; - }); - - it("it should return false", () => { - expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(false); - }); - - describe("and a room encryption state event", () => { - beforeEach(() => { - room1.currentState.setStateEvents([mkEvent({ - user: userId1, - event: true, - type: EventType.RoomEncryption, - room: room1.roomId, - content: {}, - })]); - }); - - it("it should return true", () => { - expect(dmModule.isRoomReady(mockClient, localRoom)).toBe(true); - }); - }); - }); - }); - }); - }); - }); - }); - describe("createRoomFromLocalRoom", () => { beforeEach(() => { jest.spyOn(dmModule, "startDm"); @@ -412,7 +325,7 @@ describe("direct-messages", () => { describe("on startDm success", () => { beforeEach(() => { - jest.spyOn(dmModule, "waitForRoomReadyAndApplyAfterCreateCallbacks").mockResolvedValue(room1.roomId); + mocked(waitForRoomReadyAndApplyAfterCreateCallbacks).mockResolvedValue(room1.roomId); mocked(dmModule.startDm).mockResolvedValue(room1.roomId); }); @@ -422,7 +335,7 @@ describe("direct-messages", () => { const result = await dmModule.createRoomFromLocalRoom(mockClient, localRoom); expect(result).toBe(room1.roomId); expect(localRoom.state).toBe(LocalRoomState.CREATING); - expect(dmModule.waitForRoomReadyAndApplyAfterCreateCallbacks).toHaveBeenCalledWith( + expect(waitForRoomReadyAndApplyAfterCreateCallbacks).toHaveBeenCalledWith( mockClient, localRoom, ); @@ -430,61 +343,4 @@ describe("direct-messages", () => { ); }); }); - - describe("waitForRoomReadyAndApplyAfterCreateCallbacks", () => { - beforeEach(() => { - localRoom.actualRoomId = room1.roomId; - jest.useFakeTimers(); - }); - - describe("for an immediate ready room", () => { - beforeEach(() => { - jest.spyOn(dmModule, "isRoomReady").mockReturnValue(true); - }); - - it("should invoke the callbacks, set the room state to created and return the actual room id", async () => { - const result = await dmModule.waitForRoomReadyAndApplyAfterCreateCallbacks(mockClient, localRoom); - expect(localRoom.state).toBe(LocalRoomState.CREATED); - expect(localRoomCallbackRoomId).toBe(room1.roomId); - expect(result).toBe(room1.roomId); - }); - }); - - describe("for a room running into the create timeout", () => { - beforeEach(() => { - jest.spyOn(dmModule, "isRoomReady").mockReturnValue(false); - }); - - it("should invoke the callbacks, set the room state to created and return the actual room id", (done) => { - const prom = dmModule.waitForRoomReadyAndApplyAfterCreateCallbacks(mockClient, localRoom); - jest.advanceTimersByTime(5000); - prom.then((roomId: string) => { - expect(localRoom.state).toBe(LocalRoomState.CREATED); - expect(localRoomCallbackRoomId).toBe(room1.roomId); - expect(roomId).toBe(room1.roomId); - expect(jest.getTimerCount()).toBe(0); - done(); - }); - }); - }); - - describe("for a room that is ready after a while", () => { - beforeEach(() => { - jest.spyOn(dmModule, "isRoomReady").mockReturnValue(false); - }); - - it("should invoke the callbacks, set the room state to created and return the actual room id", (done) => { - const prom = dmModule.waitForRoomReadyAndApplyAfterCreateCallbacks(mockClient, localRoom); - jest.spyOn(dmModule, "isRoomReady").mockReturnValue(true); - jest.advanceTimersByTime(500); - prom.then((roomId: string) => { - expect(localRoom.state).toBe(LocalRoomState.CREATED); - expect(localRoomCallbackRoomId).toBe(room1.roomId); - expect(roomId).toBe(room1.roomId); - expect(jest.getTimerCount()).toBe(0); - done(); - }); - }); - }); - }); }); diff --git a/test/utils/local-room-test.ts b/test/utils/local-room-test.ts index 251d8fcf9f4..de752694c80 100644 --- a/test/utils/local-room-test.ts +++ b/test/utils/local-room-test.ts @@ -15,24 +15,27 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom"; -import { doMaybeLocalRoomAction } from "../../src/utils/local-room"; +import * as localRoomModule from "../../src/utils/local-room"; import defaultDispatcher from "../../src/dispatcher/dispatcher"; -import { createTestClient } from "../test-utils"; +import { createTestClient, makeMembershipEvent, mkEvent } from "../test-utils"; +import { DirectoryMember } from "../../src/utils/direct-messages"; -describe("doMaybeLocalRoomAction", () => { - let callback: jest.Mock; +describe("local-room", () => { + const userId1 = "@user1:example.com"; + const member1 = new DirectoryMember({ user_id: userId1 }); + const userId2 = "@user2:example.com"; + let room1: Room; let localRoom: LocalRoom; let client: MatrixClient; beforeEach(() => { - callback = jest.fn(); - callback.mockReturnValue(Promise.resolve()); client = createTestClient(); + room1 = new Room("!room1:example.com", client, userId1); + room1.getMyMembership = () => "join"; localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, "@test:example.com"); - localRoom.actualRoomId = "@new:example.com"; mocked(client.getRoom).mockImplementation((roomId: string) => { if (roomId === localRoom.roomId) { return localRoom; @@ -41,37 +44,198 @@ describe("doMaybeLocalRoomAction", () => { }); }); - it("should invoke the callback for a non-local room", () => { - doMaybeLocalRoomAction("!room:example.com", callback, client); - expect(callback).toHaveBeenCalled(); + describe("doMaybeLocalRoomAction", () => { + let callback: jest.Mock; + + beforeEach(() => { + callback = jest.fn(); + callback.mockReturnValue(Promise.resolve()); + localRoom.actualRoomId = "@new:example.com"; + }); + + it("should invoke the callback for a non-local room", () => { + localRoomModule.doMaybeLocalRoomAction("!room:example.com", callback, client); + expect(callback).toHaveBeenCalled(); + }); + + it("should invoke the callback with the new room ID for a created room", () => { + localRoom.state = LocalRoomState.CREATED; + localRoomModule.doMaybeLocalRoomAction(localRoom.roomId, callback, client); + expect(callback).toHaveBeenCalledWith(localRoom.actualRoomId); + }); + + describe("for a local room", () => { + let prom; + + beforeEach(() => { + jest.spyOn(defaultDispatcher, "dispatch"); + prom = localRoomModule.doMaybeLocalRoomAction(localRoom.roomId, callback, client); + }); + + it("dispatch a local_room_event", () => { + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "local_room_event", + roomId: localRoom.roomId, + }); + }); + + it("should resolve the promise after invoking the callback", async () => { + localRoom.afterCreateCallbacks.forEach((callback) => { + callback(localRoom.actualRoomId); + }); + await prom; + }); + }); }); - it("should invoke the callback with the new room ID for a created room", () => { - localRoom.state = LocalRoomState.CREATED; - doMaybeLocalRoomAction(localRoom.roomId, callback, client); - expect(callback).toHaveBeenCalledWith(localRoom.actualRoomId); + describe("isRoomReady", () => { + beforeEach(() => { + localRoom.targets = [member1]; + }); + + it("should return false if the room has no actual room id", () => { + expect(localRoomModule.isRoomReady(client, localRoom)).toBe(false); + }); + + describe("for a room with an actual room id", () => { + beforeEach(() => { + localRoom.actualRoomId = room1.roomId; + mocked(client.getRoom).mockReturnValue(null); + }); + + it("it should return false", () => { + expect(localRoomModule.isRoomReady(client, localRoom)).toBe(false); + }); + + describe("and the room is known to the client", () => { + beforeEach(() => { + mocked(client.getRoom).mockImplementation((roomId: string) => { + if (roomId === room1.roomId) return room1; + }); + }); + + it("it should return false", () => { + expect(localRoomModule.isRoomReady(client, localRoom)).toBe(false); + }); + + describe("and all members have been invited or joined", () => { + beforeEach(() => { + room1.currentState.setStateEvents([ + makeMembershipEvent(room1.roomId, userId1, "join"), + makeMembershipEvent(room1.roomId, userId2, "invite"), + ]); + }); + + it("it should return false", () => { + expect(localRoomModule.isRoomReady(client, localRoom)).toBe(false); + }); + + describe("and a RoomHistoryVisibility event", () => { + beforeEach(() => { + room1.currentState.setStateEvents([mkEvent({ + user: userId1, + event: true, + type: EventType.RoomHistoryVisibility, + room: room1.roomId, + content: {}, + })]); + }); + + it("it should return true", () => { + expect(localRoomModule.isRoomReady(client, localRoom)).toBe(true); + }); + + describe("and an encrypted room", () => { + beforeEach(() => { + localRoom.encrypted = true; + }); + + it("it should return false", () => { + expect(localRoomModule.isRoomReady(client, localRoom)).toBe(false); + }); + + describe("and a room encryption state event", () => { + beforeEach(() => { + room1.currentState.setStateEvents([mkEvent({ + user: userId1, + event: true, + type: EventType.RoomEncryption, + room: room1.roomId, + content: {}, + })]); + }); + + it("it should return true", () => { + expect(localRoomModule.isRoomReady(client, localRoom)).toBe(true); + }); + }); + }); + }); + }); + }); + }); }); - describe("for a local room", () => { - let prom; + describe("waitForRoomReadyAndApplyAfterCreateCallbacks", () => { + let localRoomCallbackRoomId: string; beforeEach(() => { - jest.spyOn(defaultDispatcher, "dispatch"); - prom = doMaybeLocalRoomAction(localRoom.roomId, callback, client); + localRoom.actualRoomId = room1.roomId; + localRoom.afterCreateCallbacks.push((roomId: string) => { + localRoomCallbackRoomId = roomId; + return Promise.resolve(); + }); + jest.useFakeTimers(); }); - it("dispatch a local_room_event", () => { - expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ - action: "local_room_event", - roomId: localRoom.roomId, + describe("for an immediate ready room", () => { + beforeEach(() => { + jest.spyOn(localRoomModule, "isRoomReady").mockReturnValue(true); + }); + + it("should invoke the callbacks, set the room state to created and return the actual room id", async () => { + const result = await localRoomModule.waitForRoomReadyAndApplyAfterCreateCallbacks(client, localRoom); + expect(localRoom.state).toBe(LocalRoomState.CREATED); + expect(localRoomCallbackRoomId).toBe(room1.roomId); + expect(result).toBe(room1.roomId); }); }); - it("should resolve the promise after invoking the callback", async () => { - localRoom.afterCreateCallbacks.forEach((callback) => { - callback(localRoom.actualRoomId); + describe("for a room running into the create timeout", () => { + beforeEach(() => { + jest.spyOn(localRoomModule, "isRoomReady").mockReturnValue(false); + }); + + it("should invoke the callbacks, set the room state to created and return the actual room id", (done) => { + const prom = localRoomModule.waitForRoomReadyAndApplyAfterCreateCallbacks(client, localRoom); + jest.advanceTimersByTime(5000); + prom.then((roomId: string) => { + expect(localRoom.state).toBe(LocalRoomState.CREATED); + expect(localRoomCallbackRoomId).toBe(room1.roomId); + expect(roomId).toBe(room1.roomId); + expect(jest.getTimerCount()).toBe(0); + done(); + }); + }); + }); + + describe("for a room that is ready after a while", () => { + beforeEach(() => { + jest.spyOn(localRoomModule, "isRoomReady").mockReturnValue(false); + }); + + it("should invoke the callbacks, set the room state to created and return the actual room id", (done) => { + const prom = localRoomModule.waitForRoomReadyAndApplyAfterCreateCallbacks(client, localRoom); + jest.spyOn(localRoomModule, "isRoomReady").mockReturnValue(true); + jest.advanceTimersByTime(500); + prom.then((roomId: string) => { + expect(localRoom.state).toBe(LocalRoomState.CREATED); + expect(localRoomCallbackRoomId).toBe(room1.roomId); + expect(roomId).toBe(room1.roomId); + expect(jest.getTimerCount()).toBe(0); + done(); + }); }); - await prom; }); }); }); From 8ed4c62fb5c83c25994ee1ff6593e22c52c571f7 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 7 Jul 2022 10:38:08 +0200 Subject: [PATCH 61/73] Update spotlight test --- cypress/integration/12-spotlight/spotlight.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cypress/integration/12-spotlight/spotlight.spec.ts b/cypress/integration/12-spotlight/spotlight.spec.ts index 77182b1e17d..2f7d2b9a238 100644 --- a/cypress/integration/12-spotlight/spotlight.spec.ts +++ b/cypress/integration/12-spotlight/spotlight.spec.ts @@ -118,10 +118,13 @@ Cypress.Commands.add("startDM", (name: string) => { cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", name); cy.spotlightResults().eq(0).click(); - }).then(() => { - cy.roomHeaderName().should("contain", name); - cy.get(".mx_RoomSublist[aria-label=People]").should("contain", name); }); + // send first message to start DM + cy.get(".mx_BasicMessageComposer_input") + .should("have.focus") + .type("Hey!{enter}"); + cy.contains(".mx_EventTile_body", "Hey!"); + cy.get(".mx_RoomSublist[aria-label=People]").should("contain", name); }); describe("Spotlight", () => { From 52adaea5d8d125b39b0de78cf8894d5ef7a513a0 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 11 Jul 2022 08:52:33 +0200 Subject: [PATCH 62/73] Remove duplicate LocalRoom test --- test/LocalRoom-test.ts | 90 ------------------------------------------ 1 file changed, 90 deletions(-) delete mode 100644 test/LocalRoom-test.ts diff --git a/test/LocalRoom-test.ts b/test/LocalRoom-test.ts deleted file mode 100644 index 2ca05998c85..00000000000 --- a/test/LocalRoom-test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* -Copyright 2022 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 { MatrixClient } from "matrix-js-sdk/src/matrix"; - -import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../src/models/LocalRoom"; -import { createTestClient } from "./test-utils"; - -const stateTestData = [ - { - name: "NEW", - state: LocalRoomState.NEW, - isNew: true, - isCreated: false, - isError: false, - }, - { - name: "CREATING", - state: LocalRoomState.CREATING, - isNew: false, - isCreated: false, - isError: false, - }, - { - name: "CREATED", - state: LocalRoomState.CREATED, - isNew: false, - isCreated: true, - isError: false, - }, - { - name: "ERROR", - state: LocalRoomState.ERROR, - isNew: false, - isCreated: false, - isError: true, - }, -]; - -describe("LocalRoom", () => { - let room: LocalRoom; - let client: MatrixClient; - - beforeEach(() => { - client = createTestClient(); - room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, "@test:localhost"); - }); - - it("should not raise an error on getPendingEvents (implicitly check for pendingEventOrdering: detached)", () => { - room.getPendingEvents(); - expect(true).toBe(true); - }); - - it("should not have after create callbacks", () => { - expect(room.afterCreateCallbacks).toHaveLength(0); - }); - - stateTestData.forEach((stateTestDatum) => { - describe(`in state ${stateTestDatum.name}`, () => { - beforeEach(() => { - room.state = stateTestDatum.state; - }); - - it(`isNew should return ${stateTestDatum.isNew}`, () => { - expect(room.isNew).toBe(stateTestDatum.isNew); - }); - - it(`isCreated should return ${stateTestDatum.isCreated}`, () => { - expect(room.isCreated).toBe(stateTestDatum.isCreated); - }); - - it(`isError should return ${stateTestDatum.isError}`, () => { - expect(room.isError).toBe(stateTestDatum.isError); - }); - }); - }); -}); From 11b6227c6cc6798f864f2a224d8212249922c3a3 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 13 Jul 2022 08:38:17 +0200 Subject: [PATCH 63/73] Remove test IDs, unused cypress functions, replace all local rooms --- cypress/support/client.ts | 20 ------------------- .../security/CreateSecretStorageDialog.tsx | 1 - src/components/structures/MatrixChat.tsx | 5 +---- .../views/settings/SecureBackupPanel.tsx | 1 - 4 files changed, 1 insertion(+), 26 deletions(-) diff --git a/cypress/support/client.ts b/cypress/support/client.ts index c159b859314..8f9b14e851e 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -50,12 +50,6 @@ declare global { * @param userId the id of the user to invite */ inviteUser(roomId: string, userId: string): Chainable<{}>; - /** - * Sets up key backup - * @param password the user password - * @return recovery key - */ - setupKeyBackup(password: string): Chainable; /** * Sets account data for the user. * @param type The type of account data. @@ -134,20 +128,6 @@ declare global { } } -Cypress.Commands.add("setupKeyBackup", (password: string): Chainable => { - cy.get('[data-test-id="user-menu-button"]').click(); - cy.get('[data-test-id="user-menu-security-item"]').click(); - cy.get('[data-test-id="set-up-secure-backup-button"]').click(); - cy.get('[data-test-id="dialog-primary-button"]').click(); - cy.get('[data-test-id="copy-recovery-key-button"]').click(); - cy.get('[data-test-id="dialog-primary-button"]:not([disabled])').click(); - cy.get('#mx_Field_2').type(password); - cy.get('[data-test-id="submit-password-button"]:not([disabled])').click(); - cy.contains('.mx_Dialog_title', 'Setting up keys').should('exist'); - cy.contains('.mx_Dialog_title', 'Setting up keys').should('not.exist'); - return; -}); - Cypress.Commands.add("getClient", (): Chainable => { return cy.window({ log: false }).then(win => win.mxMatrixClientPeg.matrixClient); }); diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 702eef9e4e1..27c970430a9 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -731,7 +731,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.state.copied ? _t("Copied!") : _t("Copy") }
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 6a4e890d25e..c41cd8d941f 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -889,10 +889,7 @@ export default class MatrixChat extends React.PureComponent { // If we are redirecting to a Room Alias and it is for the room we already showing then replace history item let replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId; - if ( - !roomInfo.room_id?.startsWith(LOCAL_ROOM_ID_PREFIX) - && this.state.currentRoomId?.startsWith(LOCAL_ROOM_ID_PREFIX) - ) { + if (this.state.currentRoomId?.startsWith(LOCAL_ROOM_ID_PREFIX)) { // Replace local room history items replaceLast = true; } diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index e69db4672dd..55b83bf19a5 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -409,7 +409,6 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { key="setup" kind="primary" onClick={this.startNewBackup} - data-test-id="set-up-secure-backup-button" > { _t("Set up") } , From 56491d8c7b5ec2df758f306d2f298044c772614e Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 13 Jul 2022 11:07:44 +0200 Subject: [PATCH 64/73] Extract isLocalRoom --- src/Avatar.ts | 4 +- src/components/structures/MatrixChat.tsx | 4 +- src/components/structures/RoomView.tsx | 5 +- .../dialogs/spotlight/SpotlightDialog.tsx | 4 +- .../views/messages/EncryptionEvent.tsx | 4 +- src/components/views/rooms/EventTile.tsx | 4 +- src/components/views/rooms/NewRoomIntro.tsx | 5 +- src/stores/TypingStore.ts | 4 +- .../room-list/filters/VisibilityProvider.ts | 4 +- src/utils/direct-messages.ts | 5 +- src/utils/local-room.ts | 5 +- src/utils/localRoom/isLocalRoom.ts | 26 ++++++++++ test/utils/localRoom/isLocalRoom-test.ts | 52 +++++++++++++++++++ 13 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 src/utils/localRoom/isLocalRoom.ts create mode 100644 test/utils/localRoom/isLocalRoom-test.ts diff --git a/src/Avatar.ts b/src/Avatar.ts index af659df8898..0472e00b0d1 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -22,7 +22,7 @@ import { split } from "lodash"; import DMRoomMap from './utils/DMRoomMap'; import { mediaFromMxc } from "./customisations/Media"; -import { LocalRoom } from "./models/LocalRoom"; +import { isLocalRoom } from "./utils/localRoom/isLocalRoom"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already export function avatarUrlForMember( @@ -145,7 +145,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi // If the room is not a DM don't fallback to a member avatar if ( !DMRoomMap.shared().getUserIdForRoomId(room.roomId) - && !(room instanceof LocalRoom) + && !(isLocalRoom(room)) ) { return null; } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index c41cd8d941f..92bb010daa5 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -128,8 +128,8 @@ import { IConfigOptions } from "../../IConfigOptions"; import { SnakedObject } from "../../utils/SnakedObject"; import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; import VideoChannelStore from "../../stores/VideoChannelStore"; -import { LOCAL_ROOM_ID_PREFIX } from '../../models/LocalRoom'; import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators"; +import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; // legacy export export { default as Views } from "../../Views"; @@ -889,7 +889,7 @@ export default class MatrixChat extends React.PureComponent { // If we are redirecting to a Room Alias and it is for the room we already showing then replace history item let replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId; - if (this.state.currentRoomId?.startsWith(LOCAL_ROOM_ID_PREFIX)) { + if (isLocalRoom(this.state.currentRoomId)) { // Replace local room history items replaceLast = true; } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 5195a4bd46b..acf1b02d7d7 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -117,6 +117,7 @@ import NewRoomIntro from '../views/rooms/NewRoomIntro'; import EncryptionEvent from '../views/messages/EncryptionEvent'; import { UnsentMessagesRoomStatusBar } from './UnsentMessagesRoomStatusBar'; import { StaticNotificationState } from '../../stores/notifications/StaticNotificationState'; +import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -906,7 +907,7 @@ export class RoomView extends React.Component { SettingsStore.unwatchSetting(watcher); } - if (this.state.room instanceof LocalRoom) { + if (this.viewsLocalRoom) { // clean up if this was a local room this.props.mxClient.store.removeRoom(this.state.room.roomId); } @@ -1924,7 +1925,7 @@ export class RoomView extends React.Component { }; private get viewsLocalRoom(): boolean { - return this.state.room instanceof LocalRoom; + return isLocalRoom(this.state.room); } private get permalinkCreator(): RoomPermalinkCreator { diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 5b52782b1e8..8c0a94af4f3 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -91,7 +91,7 @@ import { PublicRoomResultDetails } from "./PublicRoomResultDetails"; import { RoomResultContextMenus } from "./RoomResultContextMenus"; import { RoomContextDetails } from "../../rooms/RoomContextDetails"; import { TooltipOption } from "./TooltipOption"; -import { LocalRoom } from "../../../../models/LocalRoom"; +import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons @@ -245,7 +245,7 @@ export const useWebSearchMetrics = (numResults: number, queryLength: number, via const findVisibleRooms = (cli: MatrixClient) => { return cli.getVisibleRooms().filter(room => { // Do not show local room - if (room instanceof LocalRoom) return false; + if (isLocalRoom(room)) return false; // TODO we may want to put invites in their own list return room.getMyMembership() === "join" || room.getMyMembership() == "invite"; }); diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx index dd1e5588229..dc4daaaf7f6 100644 --- a/src/components/views/messages/EncryptionEvent.tsx +++ b/src/components/views/messages/EncryptionEvent.tsx @@ -24,7 +24,7 @@ import EventTileBubble from "./EventTileBubble"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import DMRoomMap from "../../../utils/DMRoomMap"; import { objectHasDiff } from "../../../utils/objects"; -import { LocalRoom } from '../../../models/LocalRoom'; +import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; interface IProps { mxEvent: MatrixEvent; @@ -54,7 +54,7 @@ const EncryptionEvent = forwardRef(({ mxEvent, timestamp const displayName = room.getMember(dmPartner)?.rawDisplayName || dmPartner; subtitle = _t("Messages here are end-to-end encrypted. " + "Verify %(displayName)s in their profile - tap on their avatar.", { displayName }); - } else if (room instanceof LocalRoom) { + } else if (isLocalRoom(room)) { subtitle = _t("Messages in this chat will be end-to-end encrypted."); } else { subtitle = _t("Messages in this room are end-to-end encrypted. " + diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 86c007f1e95..887c52334e6 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -80,7 +80,7 @@ import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../event import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; import { ReadReceiptGroup } from './ReadReceiptGroup'; import { useTooltip } from "../../../utils/useTooltip"; -import { LOCAL_ROOM_ID_PREFIX } from '../../../models/LocalRoom'; +import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -768,7 +768,7 @@ export class UnwrappedEventTile extends React.Component { const ev = this.props.mxEvent; // no icon for local rooms - if (ev.getRoomId()?.startsWith(LOCAL_ROOM_ID_PREFIX)) { + if (isLocalRoom(ev.getRoomId())) { return; } diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 2a68b84dcc7..15784ef2167 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -39,6 +39,7 @@ import { shouldShowComponent } from "../../../customisations/helpers/UIComponent import { UIComponent } from "../../../settings/UIFeature"; import { privateShouldBeEncrypted } from "../../../utils/rooms"; import { LocalRoom } from "../../../models/LocalRoom"; +import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean { const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId); @@ -60,7 +61,7 @@ const NewRoomIntro = () => { let introMessage = "This is the beginning of your direct message history with ."; let caption; - if (room instanceof LocalRoom) { + if (isLocalRoom(room)) { introMessage = "Send your first message to invite to chat"; } else if ((room.getJoinedMemberCount() + room.getInvitedMemberCount()) === 2) { caption = _t("Only the two of you are in this conversation, unless either of you invites anyone to join."); @@ -211,7 +212,7 @@ const NewRoomIntro = () => { let subButton; if ( room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.get()) - && !(room instanceof LocalRoom) + && !(isLocalRoom(room)) ) { subButton = ( { _t("Enable encryption in settings.") } diff --git a/src/stores/TypingStore.ts b/src/stores/TypingStore.ts index 829822c9b0c..d642f3fea7f 100644 --- a/src/stores/TypingStore.ts +++ b/src/stores/TypingStore.ts @@ -15,8 +15,8 @@ limitations under the License. */ import { MatrixClientPeg } from "../MatrixClientPeg"; -import { LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; import SettingsStore from "../settings/SettingsStore"; +import { isLocalRoom } from "../utils/localRoom/isLocalRoom"; import Timer from "../utils/Timer"; const TYPING_USER_TIMEOUT = 10000; @@ -66,7 +66,7 @@ export default class TypingStore { */ public setSelfTyping(roomId: string, threadId: string | null, isTyping: boolean): void { // No typing notifications for local rooms - if (roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) return; + if (isLocalRoom(roomId)) return; if (!SettingsStore.getValue('sendTypingNotifications')) return; if (SettingsStore.getValue('lowBandwidth')) return; diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts index 26bfcd78ea9..ca377331065 100644 --- a/src/stores/room-list/filters/VisibilityProvider.ts +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import CallHandler from "../../../CallHandler"; import { RoomListCustomisations } from "../../../customisations/RoomList"; -import { LocalRoom } from "../../../models/LocalRoom"; +import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import VoipUserMapper from "../../../VoipUserMapper"; export class VisibilityProvider { @@ -55,7 +55,7 @@ export class VisibilityProvider { return false; } - if (room instanceof LocalRoom) { + if (isLocalRoom(room)) { // local rooms shouldn't show up anywhere return false; } diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index 97cd8374e5e..610774db73d 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -33,6 +33,7 @@ import { privateShouldBeEncrypted } from "./rooms"; import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; import * as thisModule from "./direct-messages"; import { waitForRoomReadyAndApplyAfterCreateCallbacks } from "./local-room"; +import { isLocalRoom } from "./localRoom/isLocalRoom"; /** * Tries to find a DM room with a specific user. @@ -51,7 +52,7 @@ export function findDMForUser(client: MatrixClient, userId: string): Room { // that bots, assistants, etc will ruin a room's DM-ness, though this is a problem for // canonical DMs to solve. if (r && r.getMyMembership() === "join") { - if (r instanceof LocalRoom) return false; + if (isLocalRoom(r)) return false; const members = r.currentState.getMembers(); const joinedMembers = members.filter(m => isJoinedOrNearlyJoined(m.membership)); @@ -287,7 +288,7 @@ export async function startDm(client: MatrixClient, targets: Member[]): Promise< } else { existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); } - if (existingRoom && !(existingRoom instanceof LocalRoom)) { + if (existingRoom && !isLocalRoom(existingRoom)) { dis.dispatch({ action: Action.ViewRoom, room_id: existingRoom.roomId, diff --git a/src/utils/local-room.ts b/src/utils/local-room.ts index 29fab7206d1..ac8dc18009d 100644 --- a/src/utils/local-room.ts +++ b/src/utils/local-room.ts @@ -19,8 +19,9 @@ import { ClientEvent, EventType, MatrixClient } from "matrix-js-sdk/src/matrix"; import defaultDispatcher from "../dispatcher/dispatcher"; import { MatrixClientPeg } from "../MatrixClientPeg"; -import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../models/LocalRoom"; +import { LocalRoom, LocalRoomState } from "../models/LocalRoom"; import * as thisModule from "./local-room"; +import { isLocalRoom } from "./localRoom/isLocalRoom"; /** * Does a room action: @@ -40,7 +41,7 @@ export async function doMaybeLocalRoomAction( fn: (actualRoomId: string) => Promise, client?: MatrixClient, ): Promise { - if (roomId.startsWith(LOCAL_ROOM_ID_PREFIX)) { + if (isLocalRoom(roomId)) { client = client ?? MatrixClientPeg.get(); const room = client.getRoom(roomId) as LocalRoom; diff --git a/src/utils/localRoom/isLocalRoom.ts b/src/utils/localRoom/isLocalRoom.ts new file mode 100644 index 00000000000..a31774ea5e4 --- /dev/null +++ b/src/utils/localRoom/isLocalRoom.ts @@ -0,0 +1,26 @@ +/* +Copyright 2022 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 { Room } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../models/LocalRoom"; + +export function isLocalRoom(roomOrID: Room|string): boolean { + if (typeof roomOrID === "string") { + return roomOrID.startsWith(LOCAL_ROOM_ID_PREFIX); + } + return roomOrID instanceof LocalRoom; +} diff --git a/test/utils/localRoom/isLocalRoom-test.ts b/test/utils/localRoom/isLocalRoom-test.ts new file mode 100644 index 00000000000..c94fd0608aa --- /dev/null +++ b/test/utils/localRoom/isLocalRoom-test.ts @@ -0,0 +1,52 @@ +/* +Copyright 2022 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 { Room } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../src/models/LocalRoom"; +import { isLocalRoom } from "../../../src/utils/localRoom/isLocalRoom"; +import { createTestClient } from "../../test-utils"; + +describe("isLocalRoom", () => { + let room: Room; + let localRoom: LocalRoom; + + beforeEach(() => { + const client = createTestClient(); + room = new Room("!room:example.com", client, client.getUserId()); + localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, client.getUserId()); + }); + + it("should return false for null", () => { + expect(isLocalRoom(null)).toBe(false); + }); + + it("should return false for a Room", () => { + expect(isLocalRoom(room)).toBe(false); + }); + + it("should return false for a non-local room ID", () => { + expect(isLocalRoom(room.roomId)).toBe(false); + }); + + it("should return true for LocalRoom", () => { + expect(isLocalRoom(localRoom)).toBe(true); + }); + + it("should return true for local room ID", () => { + expect(isLocalRoom(LOCAL_ROOM_ID_PREFIX + "test")).toBe(true); + }); +}); From d8318c0d9670eaf2ea7a41c2dbdf9df6613ff2fb Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 19 Jul 2022 18:43:13 +0200 Subject: [PATCH 65/73] Remove e2e-encryption scenario --- .../src/scenarios/e2e-encryption.ts | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 test/end-to-end-tests/src/scenarios/e2e-encryption.ts diff --git a/test/end-to-end-tests/src/scenarios/e2e-encryption.ts b/test/end-to-end-tests/src/scenarios/e2e-encryption.ts deleted file mode 100644 index 15b0877e19b..00000000000 --- a/test/end-to-end-tests/src/scenarios/e2e-encryption.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { strict as assert } from 'assert'; - -import { ElementSession } from "../session"; -import { sendMessage } from '../usecases/send-message'; -import { acceptInvite } from '../usecases/accept-invite'; -import { receiveMessage } from '../usecases/timeline'; -import { createDm } from '../usecases/create-room'; -import { checkRoomSettings } from '../usecases/room-settings'; -import { startSasVerification, acceptSasVerification } from '../usecases/verify'; -import { setupSecureBackup } from '../usecases/security'; -import { measureStart, measureStop } from '../util'; - -export async function e2eEncryptionScenarios(alice: ElementSession, bob: ElementSession) { - console.log(" creating an e2e encrypted DM and join through invite:"); - // to be replaced by Cypress crypto test - return; - await createDm(bob, ['@alice:localhost']); - await checkRoomSettings(bob, { encryption: true }); // for sanity, should be e2e-by-default - await acceptInvite(alice, 'bob'); - // do sas verification - bob.log.step(`starts SAS verification with ${alice.username}`); - await measureStart(bob, "mx_VerifyE2EEUser"); - const bobSasPromise = startSasVerification(bob, alice.username); - const aliceSasPromise = acceptSasVerification(alice, bob.username); - // wait in parallel, so they don't deadlock on each other - // the logs get a bit messy here, but that's fine enough for debugging (hopefully) - const [bobSas, aliceSas] = await Promise.all([bobSasPromise, aliceSasPromise]); - assert.deepEqual(bobSas, aliceSas); - await measureStop(bob, "mx_VerifyE2EEUser"); - bob.log.done(`done (match for ${bobSas.join(", ")})`); - const aliceMessage = "Guess what I just heard?!"; - await sendMessage(alice, aliceMessage); - await receiveMessage(bob, { sender: "alice", body: aliceMessage, encrypted: true }); - const bobMessage = "You've got to tell me!"; - await sendMessage(bob, bobMessage); - await receiveMessage(alice, { sender: "bob", body: bobMessage, encrypted: true }); - await setupSecureBackup(alice); -} From d950a820a838a32a8d13246c8d09fd80a3745739 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 20 Jul 2022 09:56:40 +0200 Subject: [PATCH 66/73] Remove test-ids --- src/components/structures/UserMenu.tsx | 2 -- src/components/views/auth/InteractiveAuthEntryComponents.tsx | 1 - src/components/views/dialogs/InviteDialog.tsx | 1 - src/components/views/right_panel/RoomHeaderButtons.tsx | 1 - src/components/views/rooms/BasicMessageComposer.tsx | 1 - src/components/views/rooms/RoomList.tsx | 1 - .../structures/__snapshots__/RoomView-test.tsx.snap | 4 ++-- 7 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 4c9753c46da..fdb380c94dc 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -333,7 +333,6 @@ export default class UserMenu extends React.Component { iconClassName="mx_UserMenu_iconLock" label={_t("Security & Privacy")} onClick={(e) => this.onSettingsOpen(e, UserTab.Security)} - data-test-id="user-menu-security-item" /> { label={_t("User menu")} isExpanded={!!this.state.contextMenuPosition} onContextMenu={this.onContextMenu} - data-test-id="user-menu-button" >
); } diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 664ae220a82..89902c5c849 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -1194,7 +1194,6 @@ export default class InviteDialog extends React.PureComponent { buttonText } ; diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index b78fb32f188..4b5889bc820 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -263,7 +263,6 @@ export default class RoomHeaderButtons extends HeaderButtons { title={_t('Room Info')} isHighlighted={this.isPhase(ROOM_INFO_PHASES)} onClick={this.onRoomSummaryClicked} - data-test-id="room-info-button" />, ); diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index e02df10f347..fd3f5eed3dc 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -789,7 +789,6 @@ export default class BasicMessageEditor extends React.Component aria-activedescendant={activeDescendant} dir="auto" aria-disabled={this.props.disabled} - data-test-id="basic-message-composer-input" />
); } diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 3ca158d61f8..fb90941ee41 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -185,7 +185,6 @@ const DmAuxButton = ({ tabIndex, dispatcher = defaultDispatcher }: IAuxButtonPro tooltipClassName="mx_RoomSublist_addRoomTooltip" aria-label={_t("Start chat")} title={_t("Start chat")} - data-test-id="create-chat-button" />; } diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index f648d7a1682..db8559008a5 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -4,6 +4,6 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1 exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
    End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; -exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
    End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
    End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; -exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; From 7b9a190e1b063a6922cf1d0d02c40b75f424014c Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 20 Jul 2022 09:59:21 +0200 Subject: [PATCH 67/73] Remove console.log; revert import re-ordering --- src/components/structures/MatrixChat.tsx | 2 +- src/components/views/rooms/NewRoomIntro.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 66326242d98..534d82036cc 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -130,9 +130,9 @@ import { SnakedObject } from "../../utils/SnakedObject"; import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; import VideoChannelStore from "../../stores/VideoChannelStore"; import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators"; -import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; import { UseCaseSelection } from '../views/elements/UseCaseSelection'; import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig'; +import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; // legacy export export { default as Views } from "../../Views"; diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 15784ef2167..5bb0c7e0bbd 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -44,7 +44,6 @@ import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean { const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId); const isPublic: boolean = room.getJoinRule() === "public"; - console.log(`isEncrypted: ${isEncrypted}`); return isPublic || !privateShouldBeEncrypted() || isEncrypted; } From 53dc06a42e0d2d34941f0f70a22c8cffc333be02 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 20 Jul 2022 10:11:05 +0200 Subject: [PATCH 68/73] Revert more changes --- src/components/views/settings/SecureBackupPanel.tsx | 6 +----- test/components/views/rooms/SendMessageComposer-test.tsx | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index 55b83bf19a5..e39d1970cd9 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -405,11 +405,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {

{ _t("Back up your keys before signing out to avoid losing them.") }

; actions.push( - + { _t("Set up") } , ); diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index c901b7e06e1..c46a76852e5 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -317,7 +317,7 @@ describe('', () => { mocked(doMaybeLocalRoomAction).mockImplementation(( roomId: string, fn: (actualRoomId: string) => Promise, - client?: MatrixClient, + _client?: MatrixClient, ) => { return fn(roomId); }); From f174f8f681488da2ec2de4859bbb0f9d9a6d3b39 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 25 Jul 2022 14:44:22 +0200 Subject: [PATCH 69/73] Remove duplicated code after merge --- src/components/structures/RoomView.tsx | 4 +- src/components/structures/RoomView.tsx~ | 2449 +++++++++++++++++ .../UnsentMessagesRoomStatusBar.tsx | 55 - 3 files changed, 2451 insertions(+), 57 deletions(-) create mode 100644 src/components/structures/RoomView.tsx~ delete mode 100644 src/components/structures/UnsentMessagesRoomStatusBar.tsx diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 0b646a23c31..acd6d1e5209 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -114,10 +114,10 @@ import { LocalRoom, LocalRoomState } from '../../models/LocalRoom'; import { createRoomFromLocalRoom } from '../../utils/direct-messages'; import NewRoomIntro from '../views/rooms/NewRoomIntro'; import EncryptionEvent from '../views/messages/EncryptionEvent'; -import { UnsentMessagesRoomStatusBar } from './UnsentMessagesRoomStatusBar'; import { StaticNotificationState } from '../../stores/notifications/StaticNotificationState'; import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; +import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -270,7 +270,7 @@ function LocalRoomView(props: ILocalRoomViewProps): ReactElement { ); - statusBar = ; + searchHighlights?: string[]; + searchInProgress?: boolean; + callState?: CallState; + canPeek: boolean; + canSelfRedact: boolean; + showApps: boolean; + isPeeking: boolean; + showRightPanel: boolean; + // error object, as from the matrix client/server API + // If we failed to load information about the room, + // store the error here. + roomLoadError?: MatrixError; + // Have we sent a request to join the room that we're waiting to complete? + joining: boolean; + // this is true if we are fully scrolled-down, and are looking at + // the end of the live timeline. It has the effect of hiding the + // 'scroll to bottom' knob, among a couple of other things. + atEndOfLiveTimeline?: boolean; + showTopUnreadMessagesBar: boolean; + statusBarVisible: boolean; + // We load this later by asking the js-sdk to suggest a version for us. + // This object is the result of Room#getRecommendedVersion() + + upgradeRecommendation?: IRecommendedVersion; + canReact: boolean; + canSendMessages: boolean; + tombstone?: MatrixEvent; + resizing: boolean; + layout: Layout; + lowBandwidth: boolean; + alwaysShowTimestamps: boolean; + showTwelveHourTimestamps: boolean; + readMarkerInViewThresholdMs: number; + readMarkerOutOfViewThresholdMs: number; + showHiddenEvents: boolean; + showReadReceipts: boolean; + showRedactions: boolean; + showJoinLeaves: boolean; + showAvatarChanges: boolean; + showDisplaynameChanges: boolean; + matrixClientIsReady: boolean; + showUrlPreview?: boolean; + e2eStatus?: E2EStatus; + rejecting?: boolean; + rejectError?: Error; + hasPinnedWidgets?: boolean; + mainSplitContentType?: MainSplitContentType; + // whether or not a spaces context switch brought us here, + // if it did we don't want the room to be marked as read as soon as it is loaded. + wasContextSwitch?: boolean; + editState?: EditorStateTransfer; + timelineRenderingType: TimelineRenderingType; + threadId?: string; + liveTimeline?: EventTimeline; + narrow: boolean; +} + +interface ILocalRoomViewProps { + resizeNotifier: ResizeNotifier; + permalinkCreator: RoomPermalinkCreator; + roomView: RefObject; + onFileDrop: (dataTransfer: DataTransfer) => Promise; +} + +/** + * Local room view. Uses only the bits necessary to display a local room view like room header or composer. + * + * @param {ILocalRoomViewProps} props Room view props + * @returns {ReactElement} + */ +function LocalRoomView(props: ILocalRoomViewProps): ReactElement { + const context = useContext(RoomContext); + const room = context.room as LocalRoom; + const encryptionEvent = context.room.currentState.getStateEvents(EventType.RoomEncryption)[0]; + let encryptionTile: ReactNode; + + if (encryptionEvent) { + encryptionTile = ; + } + + const onRetryClicked = () => { + room.state = LocalRoomState.NEW; + defaultDispatcher.dispatch({ + action: "local_room_event", + roomId: room.roomId, + }); + }; + + let statusBar: ReactElement; + let composer: ReactElement; + + if (room.isError) { + const buttons = ( + + { _t("Retry") } + + ); + + statusBar = ; + } else { + composer = ; + } + + return ( +
+ + +
+ +
+ + { encryptionTile } + + +
+ { statusBar } + { composer } +
+
+
+ ); +} + +interface ILocalRoomCreateLoaderProps { + names: string; + resizeNotifier: ResizeNotifier; +} + +/** + * Room create loader view displaying a message and a spinner. + * + * @param {ILocalRoomCreateLoaderProps} props Room view props + * @return {ReactElement} + */ +function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement { + const context = useContext(RoomContext); + const text = _t("We're creating a room with %(names)s", { names: props.names }); + return ( +
+ + +
+
+ +
+ { text } +
+
+
+
+
+ ); +} + +export class RoomView extends React.Component { + private readonly dispatcherRef: string; + private readonly roomStoreToken: EventSubscription; + private settingWatchers: string[]; + + private unmounted = false; + private permalinkCreators: Record = {}; + private searchId: number; + + private roomView = createRef(); + private searchResultsPanel = createRef(); + private messagePanel: TimelinePanel; + private roomViewBody = createRef(); + + static contextType = MatrixClientContext; + public context!: React.ContextType; + + constructor(props: IRoomProps, context: React.ContextType) { + super(props, context); + + const llMembers = context.hasLazyLoadMembersEnabled(); + this.state = { + roomId: null, + roomLoading: true, + peekLoading: false, + shouldPeek: true, + membersLoaded: !llMembers, + numUnreadMessages: 0, + searchResults: null, + callState: null, + canPeek: false, + canSelfRedact: false, + showApps: false, + isPeeking: false, + showRightPanel: false, + joining: false, + showTopUnreadMessagesBar: false, + statusBarVisible: false, + canReact: false, + canSendMessages: false, + resizing: false, + layout: SettingsStore.getValue("layout"), + lowBandwidth: SettingsStore.getValue("lowBandwidth"), + alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"), + showTwelveHourTimestamps: SettingsStore.getValue("showTwelveHourTimestamps"), + readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"), + readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"), + showHiddenEvents: SettingsStore.getValue("showHiddenEventsInTimeline"), + showReadReceipts: true, + showRedactions: true, + showJoinLeaves: true, + showAvatarChanges: true, + showDisplaynameChanges: true, + matrixClientIsReady: context?.isInitialSyncComplete(), + mainSplitContentType: MainSplitContentType.Timeline, + timelineRenderingType: TimelineRenderingType.Room, + liveTimeline: undefined, + narrow: false, + }; + + this.dispatcherRef = dis.register(this.onAction); + context.on(ClientEvent.Room, this.onRoom); + context.on(RoomEvent.Timeline, this.onRoomTimeline); + context.on(RoomEvent.TimelineReset, this.onRoomTimelineReset); + context.on(RoomEvent.Name, this.onRoomName); + context.on(RoomStateEvent.Events, this.onRoomStateEvents); + context.on(RoomStateEvent.Update, this.onRoomStateUpdate); + context.on(RoomEvent.MyMembership, this.onMyMembership); + context.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); + context.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); + context.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); + context.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); + context.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + // Start listening for RoomViewStore updates + this.roomStoreToken = RoomViewStore.instance.addListener(this.onRoomViewStoreUpdate); + + RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); + + WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); + WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + + this.props.resizeNotifier.on("isResizing", this.onIsResizing); + + this.settingWatchers = [ + SettingsStore.watchSetting("layout", null, (...[,,, value]) => + this.setState({ layout: value as Layout }), + ), + SettingsStore.watchSetting("lowBandwidth", null, (...[,,, value]) => + this.setState({ lowBandwidth: value as boolean }), + ), + SettingsStore.watchSetting("alwaysShowTimestamps", null, (...[,,, value]) => + this.setState({ alwaysShowTimestamps: value as boolean }), + ), + SettingsStore.watchSetting("showTwelveHourTimestamps", null, (...[,,, value]) => + this.setState({ showTwelveHourTimestamps: value as boolean }), + ), + SettingsStore.watchSetting("readMarkerInViewThresholdMs", null, (...[,,, value]) => + this.setState({ readMarkerInViewThresholdMs: value as number }), + ), + SettingsStore.watchSetting("readMarkerOutOfViewThresholdMs", null, (...[,,, value]) => + this.setState({ readMarkerOutOfViewThresholdMs: value as number }), + ), + SettingsStore.watchSetting("showHiddenEventsInTimeline", null, (...[,,, value]) => + this.setState({ showHiddenEvents: value as boolean }), + ), + SettingsStore.watchSetting("urlPreviewsEnabled", null, this.onUrlPreviewsEnabledChange), + SettingsStore.watchSetting("urlPreviewsEnabled_e2ee", null, this.onUrlPreviewsEnabledChange), + ]; + } + + private onIsResizing = (resizing: boolean) => { + this.setState({ resizing }); + }; + + private onWidgetStoreUpdate = () => { + if (!this.state.room) return; + this.checkWidgets(this.state.room); + }; + + private onWidgetEchoStoreUpdate = () => { + if (!this.state.room) return; + this.checkWidgets(this.state.room); + }; + + private onWidgetLayoutChange = () => { + if (!this.state.room) return; + dis.dispatch({ + action: "appsDrawer", + show: true, + }); + if (WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)) { + // Show chat in right panel when a widget is maximised + RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }); + } else if ( + RightPanelStore.instance.isOpen && + RightPanelStore.instance.roomPhaseHistory.some(card => (card.phase === RightPanelPhases.Timeline)) + ) { + // hide chat in right panel when the widget is minimized + RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }); + RightPanelStore.instance.togglePanel(this.state.roomId); + } + this.checkWidgets(this.state.room); + }; + + private checkWidgets = (room: Room): void => { + this.setState({ + hasPinnedWidgets: WidgetLayoutStore.instance.hasPinnedWidgets(room), + mainSplitContentType: this.getMainSplitContentType(room), + showApps: this.shouldShowApps(room), + }); + }; + + private getMainSplitContentType = (room: Room) => { + if (SettingsStore.getValue("feature_video_rooms") && room.isElementVideoRoom()) { + return MainSplitContentType.Video; + } + if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { + return MainSplitContentType.MaximisedWidget; + } + return MainSplitContentType.Timeline; + }; + + private onRoomViewStoreUpdate = async (initial?: boolean): Promise => { + if (this.unmounted) { + return; + } + + if (!initial && this.state.roomId !== RoomViewStore.instance.getRoomId()) { + // RoomView explicitly does not support changing what room + // is being viewed: instead it should just be re-mounted when + // switching rooms. Therefore, if the room ID changes, we + // ignore this. We either need to do this or add code to handle + // saving the scroll position (otherwise we end up saving the + // scroll position against the wrong room). + + // Given that doing the setState here would cause a bunch of + // unnecessary work, we just ignore the change since we know + // that if the current room ID has changed from what we thought + // it was, it means we're about to be unmounted. + return; + } + + const roomId = RoomViewStore.instance.getRoomId(); + + // This convoluted type signature ensures we get IntelliSense *and* correct typing + const newState: Partial & Pick = { + roomId, + roomAlias: RoomViewStore.instance.getRoomAlias(), + roomLoading: RoomViewStore.instance.isRoomLoading(), + roomLoadError: RoomViewStore.instance.getRoomLoadError(), + joining: RoomViewStore.instance.isJoining(), + replyToEvent: RoomViewStore.instance.getQuotingEvent(), + // we should only peek once we have a ready client + shouldPeek: this.state.matrixClientIsReady && RoomViewStore.instance.shouldPeek(), + showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), + showRedactions: SettingsStore.getValue("showRedactions", roomId), + showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), + showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), + showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), + wasContextSwitch: RoomViewStore.instance.getWasContextSwitch(), + initialEventId: null, // default to clearing this, will get set later in the method if needed + showRightPanel: RightPanelStore.instance.isOpenForRoom(roomId), + }; + + const initialEventId = RoomViewStore.instance.getInitialEventId(); + if (initialEventId) { + const room = this.context.getRoom(roomId); + let initialEvent = room?.findEventById(initialEventId); + // The event does not exist in the current sync data + // We need to fetch it to know whether to route this request + // to the main timeline or to a threaded one + // In the current state, if a thread does not exist in the sync data + // We will only display the event targeted by the `matrix.to` link + // and the root event. + // The rest will be lost for now, until the aggregation API on the server + // becomes available to fetch a whole thread + if (!initialEvent) { + initialEvent = await fetchInitialEvent( + this.context, + roomId, + initialEventId, + ); + } + + // If we have an initial event, we want to reset the event pixel offset to ensure it ends up + // visible + newState.initialEventPixelOffset = null; + + const thread = initialEvent?.getThread(); + if (thread && !initialEvent?.isThreadRoot) { + dis.dispatch({ + action: Action.ShowThread, + rootEvent: thread.rootEvent, + initialEvent, + highlighted: RoomViewStore.instance.isInitialEventHighlighted(), + scroll_into_view: RoomViewStore.instance.initialEventScrollIntoView(), + }); + } else { + newState.initialEventId = initialEventId; + newState.isInitialEventHighlighted = RoomViewStore.instance.isInitialEventHighlighted(); + newState.initialEventScrollIntoView = RoomViewStore.instance.initialEventScrollIntoView(); + + if (thread && initialEvent?.isThreadRoot) { + dis.dispatch({ + action: Action.ShowThread, + rootEvent: thread.rootEvent, + initialEvent, + highlighted: RoomViewStore.instance.isInitialEventHighlighted(), + scroll_into_view: RoomViewStore.instance.initialEventScrollIntoView(), + }); + } + } + } + + // Add watchers for each of the settings we just looked up + this.settingWatchers = this.settingWatchers.concat([ + SettingsStore.watchSetting("showReadReceipts", roomId, (...[,,, value]) => + this.setState({ showReadReceipts: value as boolean }), + ), + SettingsStore.watchSetting("showRedactions", roomId, (...[,,, value]) => + this.setState({ showRedactions: value as boolean }), + ), + SettingsStore.watchSetting("showJoinLeaves", roomId, (...[,,, value]) => + this.setState({ showJoinLeaves: value as boolean }), + ), + SettingsStore.watchSetting("showAvatarChanges", roomId, (...[,,, value]) => + this.setState({ showAvatarChanges: value as boolean }), + ), + SettingsStore.watchSetting("showDisplaynameChanges", roomId, (...[,,, value]) => + this.setState({ showDisplaynameChanges: value as boolean }), + ), + ]); + + if (!initial && this.state.shouldPeek && !newState.shouldPeek) { + // Stop peeking because we have joined this room now + this.context.stopPeeking(); + } + + // Temporary logging to diagnose https://github.com/vector-im/element-web/issues/4307 + logger.log( + 'RVS update:', + newState.roomId, + newState.roomAlias, + 'loading?', newState.roomLoading, + 'joining?', newState.joining, + 'initial?', initial, + 'shouldPeek?', newState.shouldPeek, + ); + + // NB: This does assume that the roomID will not change for the lifetime of + // the RoomView instance + if (initial) { + newState.room = this.context.getRoom(newState.roomId); + if (newState.room) { + newState.showApps = this.shouldShowApps(newState.room); + this.onRoomLoaded(newState.room); + } + } + + if (this.state.roomId === null && newState.roomId !== null) { + // Get the scroll state for the new room + + // If an event ID wasn't specified, default to the one saved for this room + // in the scroll state store. Assume initialEventPixelOffset should be set. + if (!newState.initialEventId) { + const roomScrollState = RoomScrollStateStore.getScrollState(newState.roomId); + if (roomScrollState) { + newState.initialEventId = roomScrollState.focussedEvent; + newState.initialEventPixelOffset = roomScrollState.pixelOffset; + } + } + } + + // Clear the search results when clicking a search result (which changes the + // currently scrolled to event, this.state.initialEventId). + if (this.state.initialEventId !== newState.initialEventId) { + newState.searchResults = null; + } + + this.setState(newState); + // At this point, newState.roomId could be null (e.g. the alias might not + // have been resolved yet) so anything called here must handle this case. + + // We pass the new state into this function for it to read: it needs to + // observe the new state but we don't want to put it in the setState + // callback because this would prevent the setStates from being batched, + // ie. cause it to render RoomView twice rather than the once that is necessary. + if (initial) { + this.setupRoom(newState.room, newState.roomId, newState.joining, newState.shouldPeek); + } + }; + + private getRoomId = () => { + // According to `onRoomViewStoreUpdate`, `state.roomId` can be null + // if we have a room alias we haven't resolved yet. To work around this, + // first we'll try the room object if it's there, and then fallback to + // the bare room ID. (We may want to update `state.roomId` after + // resolving aliases, so we could always trust it.) + return this.state.room ? this.state.room.roomId : this.state.roomId; + }; + + private getPermalinkCreatorForRoom(room: Room) { + if (this.permalinkCreators[room.roomId]) return this.permalinkCreators[room.roomId]; + + this.permalinkCreators[room.roomId] = new RoomPermalinkCreator(room); + if (this.state.room && room.roomId === this.state.room.roomId) { + // We want to watch for changes in the creator for the primary room in the view, but + // don't need to do so for search results. + this.permalinkCreators[room.roomId].start(); + } else { + this.permalinkCreators[room.roomId].load(); + } + return this.permalinkCreators[room.roomId]; + } + + private stopAllPermalinkCreators() { + if (!this.permalinkCreators) return; + for (const roomId of Object.keys(this.permalinkCreators)) { + this.permalinkCreators[roomId].stop(); + } + } + + private setupRoom(room: Room, roomId: string, joining: boolean, shouldPeek: boolean) { + // if this is an unknown room then we're in one of three states: + // - This is a room we can peek into (search engine) (we can /peek) + // - This is a room we can publicly join or were invited to. (we can /join) + // - This is a room we cannot join at all. (no action can help us) + // We can't try to /join because this may implicitly accept invites (!) + // We can /peek though. If it fails then we present the join UI. If it + // succeeds then great, show the preview (but we still may be able to /join!). + // Note that peeking works by room ID and room ID only, as opposed to joining + // which must be by alias or invite wherever possible (peeking currently does + // not work over federation). + + // NB. We peek if we have never seen the room before (i.e. js-sdk does not know + // about it). We don't peek in the historical case where we were joined but are + // now not joined because the js-sdk peeking API will clobber our historical room, + // making it impossible to indicate a newly joined room. + if (!joining && roomId) { + if (!room && shouldPeek) { + logger.info("Attempting to peek into room %s", roomId); + this.setState({ + peekLoading: true, + isPeeking: true, // this will change to false if peeking fails + }); + this.context.peekInRoom(roomId).then((room) => { + if (this.unmounted) { + return; + } + this.setState({ + room: room, + peekLoading: false, + }); + this.onRoomLoaded(room); + }).catch((err) => { + if (this.unmounted) { + return; + } + + // Stop peeking if anything went wrong + this.setState({ + isPeeking: false, + }); + + // This won't necessarily be a MatrixError, but we duck-type + // here and say if it's got an 'errcode' key with the right value, + // it means we can't peek. + if (err.errcode === "M_GUEST_ACCESS_FORBIDDEN" || err.errcode === 'M_FORBIDDEN') { + // This is fine: the room just isn't peekable (we assume). + this.setState({ + peekLoading: false, + }); + } else { + throw err; + } + }); + } else if (room) { + // Stop peeking because we have joined this room previously + this.context.stopPeeking(); + this.setState({ isPeeking: false }); + } + } + } + + private shouldShowApps(room: Room) { + if (!BROWSER_SUPPORTS_SANDBOX || !room) return false; + + // Check if user has previously chosen to hide the app drawer for this + // room. If so, do not show apps + const hideWidgetKey = room.roomId + "_hide_widget_drawer"; + const hideWidgetDrawer = localStorage.getItem(hideWidgetKey); + + // If unset show the Tray + // Otherwise (in case the user set hideWidgetDrawer by clicking the button) follow the parameter. + const isManuallyShown = hideWidgetDrawer ? hideWidgetDrawer === "false": true; + + const widgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top); + return isManuallyShown && widgets.length > 0; + } + + componentDidMount() { + this.onRoomViewStoreUpdate(true); + + const call = this.getCallForRoom(); + const callState = call ? call.state : null; + this.setState({ + callState: callState, + }); + + CallHandler.instance.on(CallHandlerEvent.CallState, this.onCallState); + window.addEventListener('beforeunload', this.onPageUnload); + } + + shouldComponentUpdate(nextProps, nextState) { + const hasPropsDiff = objectHasDiff(this.props, nextProps); + + const { upgradeRecommendation, ...state } = this.state; + const { upgradeRecommendation: newUpgradeRecommendation, ...newState } = nextState; + + const hasStateDiff = + newUpgradeRecommendation?.needsUpgrade !== upgradeRecommendation?.needsUpgrade || + objectHasDiff(state, newState); + + return hasPropsDiff || hasStateDiff; + } + + componentDidUpdate() { + // Note: We check the ref here with a flag because componentDidMount, despite + // documentation, does not define our messagePanel ref. It looks like our spinner + // in render() prevents the ref from being set on first mount, so we try and + // catch the messagePanel when it does mount. Because we only want the ref once, + // we use a boolean flag to avoid duplicate work. + if (this.messagePanel && this.state.atEndOfLiveTimeline === undefined) { + this.setState({ + atEndOfLiveTimeline: this.messagePanel.isAtEndOfLiveTimeline(), + }); + } + } + + componentWillUnmount() { + // set a boolean to say we've been unmounted, which any pending + // promises can use to throw away their results. + // + // (We could use isMounted, but facebook have deprecated that.) + this.unmounted = true; + + CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.onCallState); + + // update the scroll map before we get unmounted + if (this.state.roomId) { + RoomScrollStateStore.setScrollState(this.state.roomId, this.getScrollState()); + } + + if (this.state.shouldPeek) { + this.context.stopPeeking(); + } + + // stop tracking room changes to format permalinks + this.stopAllPermalinkCreators(); + + dis.unregister(this.dispatcherRef); + if (this.context) { + this.context.removeListener(ClientEvent.Room, this.onRoom); + this.context.removeListener(RoomEvent.Timeline, this.onRoomTimeline); + this.context.removeListener(RoomEvent.Name, this.onRoomName); + this.context.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); + this.context.removeListener(RoomEvent.MyMembership, this.onMyMembership); + this.context.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate); + this.context.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); + this.context.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); + this.context.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); + this.context.removeListener(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); + this.context.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); + } + + window.removeEventListener('beforeunload', this.onPageUnload); + + // Remove RoomStore listener + if (this.roomStoreToken) { + this.roomStoreToken.remove(); + } + + RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); + WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); + WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); + + this.props.resizeNotifier.off("isResizing", this.onIsResizing); + + if (this.state.room) { + WidgetLayoutStore.instance.off( + WidgetLayoutStore.emissionForRoom(this.state.room), + this.onWidgetLayoutChange, + ); + } + + CallHandler.instance.off(CallHandlerEvent.CallState, this.onCallState); + + // cancel any pending calls to the throttled updated + this.updateRoomMembers.cancel(); + + for (const watcher of this.settingWatchers) { + SettingsStore.unwatchSetting(watcher); + } + + if (this.viewsLocalRoom) { + // clean up if this was a local room + this.props.mxClient.store.removeRoom(this.state.room.roomId); + } + } + + private onRightPanelStoreUpdate = () => { + this.setState({ + showRightPanel: RightPanelStore.instance.isOpenForRoom(this.state.roomId), + }); + }; + + private onPageUnload = event => { + if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { + return event.returnValue = + _t("You seem to be uploading files, are you sure you want to quit?"); + } else if (this.getCallForRoom() && this.state.callState !== 'ended') { + return event.returnValue = + _t("You seem to be in a call, are you sure you want to quit?"); + } + }; + + private onReactKeyDown = ev => { + let handled = false; + + const action = getKeyBindingsManager().getRoomAction(ev); + switch (action) { + case KeyBindingAction.DismissReadMarker: + this.messagePanel.forgetReadMarker(); + this.jumpToLiveTimeline(); + handled = true; + break; + case KeyBindingAction.JumpToOldestUnread: + this.jumpToReadMarker(); + handled = true; + break; + case KeyBindingAction.UploadFile: { + dis.dispatch({ + action: "upload_file", + context: TimelineRenderingType.Room, + }, true); + handled = true; + break; + } + } + + if (handled) { + ev.stopPropagation(); + ev.preventDefault(); + } + }; + + private onCallState = (roomId: string): void => { + // don't filter out payloads for room IDs other than props.room because + // we may be interested in the conf 1:1 room + + if (!roomId) return; + const call = this.getCallForRoom(); + this.setState({ callState: call ? call.state : null }); + }; + + private onAction = async (payload: ActionPayload): Promise => { + switch (payload.action) { + case 'message_sent': + this.checkDesktopNotifications(); + break; + case 'post_sticker_message': + this.injectSticker( + payload.data.content.url, + payload.data.content.info, + payload.data.description || payload.data.name, + payload.data.threadId); + break; + case 'picture_snapshot': + ContentMessages.sharedInstance().sendContentListToRoom( + [payload.file], this.state.room.roomId, null, this.context); + break; + case 'notifier_enabled': + case Action.UploadStarted: + case Action.UploadFinished: + case Action.UploadCanceled: + this.forceUpdate(); + break; + case 'appsDrawer': + this.setState({ + showApps: payload.show, + }); + break; + case 'reply_to_event': + if (!this.unmounted && + this.state.searchResults && + payload.event?.getRoomId() === this.state.roomId && + payload.context === TimelineRenderingType.Search + ) { + this.onCancelSearchClick(); + // we don't need to re-dispatch as RoomViewStore knows to persist with context=Search also + } + break; + case 'MatrixActions.sync': + if (!this.state.matrixClientIsReady) { + this.setState({ + matrixClientIsReady: this.context?.isInitialSyncComplete(), + }, () => { + // send another "initial" RVS update to trigger peeking if needed + this.onRoomViewStoreUpdate(true); + }); + } + break; + case 'focus_search': + this.onSearchClick(); + break; + + case 'local_room_event': + this.onLocalRoomEvent(payload.roomId); + break; + + case Action.EditEvent: { + // Quit early if we're trying to edit events in wrong rendering context + if (payload.timelineRenderingType !== this.state.timelineRenderingType) return; + const editState = payload.event ? new EditorStateTransfer(payload.event) : null; + this.setState({ editState }, () => { + if (payload.event) { + this.messagePanel?.scrollToEventIfNeeded(payload.event.getId()); + } + }); + break; + } + + case Action.ComposerInsert: { + if (payload.composerType) break; + + let timelineRenderingType: TimelineRenderingType = payload.timelineRenderingType; + // ThreadView handles Action.ComposerInsert itself due to it having its own editState + if (timelineRenderingType === TimelineRenderingType.Thread) break; + if (this.state.timelineRenderingType === TimelineRenderingType.Search && + payload.timelineRenderingType === TimelineRenderingType.Search + ) { + // we don't have the composer rendered in this state, so bring it back first + await this.onCancelSearchClick(); + timelineRenderingType = TimelineRenderingType.Room; + } + + // re-dispatch to the correct composer + dis.dispatch({ + ...(payload as ComposerInsertPayload), + timelineRenderingType, + composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send, + }); + break; + } + + case Action.FocusAComposer: { + dis.dispatch({ + ...(payload as FocusComposerPayload), + // re-dispatch to the correct composer + action: this.state.editState ? Action.FocusEditMessageComposer : Action.FocusSendMessageComposer, + }); + break; + } + + case "scroll_to_bottom": + if (payload.timelineRenderingType === TimelineRenderingType.Room) { + this.messagePanel?.jumpToLiveTimeline(); + } + break; + } + }; + + private onLocalRoomEvent(roomId: string) { + if (roomId !== this.state.room.roomId) return; + createRoomFromLocalRoom(this.props.mxClient, this.state.room as LocalRoom); + } + + private onRoomTimeline = (ev: MatrixEvent, room: Room | null, toStartOfTimeline: boolean, removed, data) => { + if (this.unmounted) return; + + // ignore events for other rooms or the notification timeline set + if (!room || room.roomId !== this.state.room?.roomId) return; + + // ignore events from filtered timelines + if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; + + if (ev.getType() === "org.matrix.room.preview_urls") { + this.updatePreviewUrlVisibility(room); + } + + if (ev.getType() === "m.room.encryption") { + this.updateE2EStatus(room); + this.updatePreviewUrlVisibility(room); + } + + // ignore anything but real-time updates at the end of the room: + // updates from pagination will happen when the paginate completes. + if (toStartOfTimeline || !data || !data.liveEvent) return; + + // no point handling anything while we're waiting for the join to finish: + // we'll only be showing a spinner. + if (this.state.joining) return; + + if (!ev.isBeingDecrypted() && !ev.isDecryptionFailure()) { + this.handleEffects(ev); + } + + if (ev.getSender() !== this.context.credentials.userId) { + // update unread count when scrolled up + if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { + // no change + } else if (!shouldHideEvent(ev, this.state)) { + this.setState((state, props) => { + return { numUnreadMessages: state.numUnreadMessages + 1 }; + }); + } + } + }; + + private onEventDecrypted = (ev: MatrixEvent) => { + if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all + if (ev.getRoomId() !== this.state.room.roomId) return; // not for us + if (ev.isDecryptionFailure()) return; + this.handleEffects(ev); + }; + + private handleEffects = (ev: MatrixEvent) => { + const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room); + if (!notifState.isUnread) return; + + CHAT_EFFECTS.forEach(effect => { + if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) { + // For initial threads launch, chat effects are disabled see #19731 + if (!SettingsStore.getValue("feature_thread") || !ev.isRelation(THREAD_RELATION_TYPE.name)) { + dis.dispatch({ action: `effects.${effect.command}` }); + } + } + }); + }; + + private onRoomName = (room: Room) => { + if (this.state.room && room.roomId == this.state.room.roomId) { + this.forceUpdate(); + } + }; + + private onKeyBackupStatus = () => { + // Key backup status changes affect whether the in-room recovery + // reminder is displayed. + this.forceUpdate(); + }; + + public canResetTimeline = () => { + if (!this.messagePanel) { + return true; + } + return this.messagePanel.canResetTimeline(); + }; + + // called when state.room is first initialised (either at initial load, + // after a successful peek, or after we join the room). + private onRoomLoaded = (room: Room) => { + if (this.unmounted) return; + // Attach a widget store listener only when we get a room + WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); + + this.calculatePeekRules(room); + this.updatePreviewUrlVisibility(room); + this.loadMembersIfJoined(room); + this.calculateRecommendedVersion(room); + this.updateE2EStatus(room); + this.updatePermissions(room); + this.checkWidgets(room); + + if ( + this.getMainSplitContentType(room) !== MainSplitContentType.Timeline + && RoomNotificationStateStore.instance.getRoomState(room).isUnread + ) { + // Automatically open the chat panel to make unread messages easier to discover + RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }, true, room.roomId); + } + + this.setState({ + tombstone: this.getRoomTombstone(room), + liveTimeline: room.getLiveTimeline(), + }); + }; + + private onRoomTimelineReset = (room: Room, timelineSet: EventTimelineSet) => { + if (!room || room.roomId !== this.state.room?.roomId) return; + logger.log(`Live timeline of ${room.roomId} was reset`); + this.setState({ liveTimeline: timelineSet.getLiveTimeline() }); + }; + + private getRoomTombstone(room = this.state.room) { + return room?.currentState.getStateEvents(EventType.RoomTombstone, ""); + } + + private async calculateRecommendedVersion(room: Room) { + const upgradeRecommendation = await room.getRecommendedVersion(); + if (this.unmounted) return; + this.setState({ upgradeRecommendation }); + } + + private async loadMembersIfJoined(room: Room) { + // lazy load members if enabled + if (this.context.hasLazyLoadMembersEnabled()) { + if (room && room.getMyMembership() === 'join') { + try { + await room.loadMembersIfNeeded(); + if (!this.unmounted) { + this.setState({ membersLoaded: true }); + } + } catch (err) { + const errorMessage = `Fetching room members for ${room.roomId} failed.` + + " Room members will appear incomplete."; + logger.error(errorMessage); + logger.error(err); + } + } + } + } + + private calculatePeekRules(room: Room) { + const historyVisibility = room.currentState.getStateEvents(EventType.RoomHistoryVisibility, ""); + this.setState({ + canPeek: historyVisibility?.getContent().history_visibility === HistoryVisibility.WorldReadable, + }); + } + + private updatePreviewUrlVisibility({ roomId }: Room) { + // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit + const key = this.context.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; + this.setState({ + showUrlPreview: SettingsStore.getValue(key, roomId), + }); + } + + private onRoom = (room: Room) => { + if (!room || room.roomId !== this.state.roomId) { + return; + } + + // Detach the listener if the room is changing for some reason + if (this.state.room) { + WidgetLayoutStore.instance.off( + WidgetLayoutStore.emissionForRoom(this.state.room), + this.onWidgetLayoutChange, + ); + } + + this.setState({ + room: room, + }, () => { + this.onRoomLoaded(room); + }); + }; + + private onDeviceVerificationChanged = (userId: string) => { + const room = this.state.room; + if (!room.currentState.getMember(userId)) { + return; + } + this.updateE2EStatus(room); + }; + + private onUserVerificationChanged = (userId: string) => { + const room = this.state.room; + if (!room || !room.currentState.getMember(userId)) { + return; + } + this.updateE2EStatus(room); + }; + + private onCrossSigningKeysChanged = () => { + const room = this.state.room; + if (room) { + this.updateE2EStatus(room); + } + }; + + private async updateE2EStatus(room: Room) { + if (!this.context.isRoomEncrypted(room.roomId)) return; + + // If crypto is not currently enabled, we aren't tracking devices at all, + // so we don't know what the answer is. Let's error on the safe side and show + // a warning for this case. + let e2eStatus = E2EStatus.Warning; + if (this.context.isCryptoEnabled()) { + /* At this point, the user has encryption on and cross-signing on */ + e2eStatus = await shieldStatusForRoom(this.context, room); + } + + if (this.unmounted) return; + this.setState({ e2eStatus }); + } + + private onUrlPreviewsEnabledChange = () => { + if (this.state.room) { + this.updatePreviewUrlVisibility(this.state.room); + } + }; + + private onRoomStateEvents = (ev: MatrixEvent, state: RoomState) => { + // ignore if we don't have a room yet + if (!this.state.room || this.state.room.roomId !== state.roomId) return; + + switch (ev.getType()) { + case EventType.RoomTombstone: + this.setState({ tombstone: this.getRoomTombstone() }); + break; + + default: + this.updatePermissions(this.state.room); + } + }; + + private onRoomStateUpdate = (state: RoomState) => { + // ignore members in other rooms + if (state.roomId !== this.state.room?.roomId) { + return; + } + + this.updateRoomMembers(); + }; + + private onMyMembership = (room: Room, membership: string, oldMembership: string) => { + if (room.roomId === this.state.roomId) { + this.forceUpdate(); + this.loadMembersIfJoined(room); + this.updatePermissions(room); + } + }; + + private updatePermissions(room: Room) { + if (room) { + const me = this.context.getUserId(); + const canReact = ( + room.getMyMembership() === "join" && + room.currentState.maySendEvent(EventType.Reaction, me) + ); + const canSendMessages = room.maySendMessage(); + const canSelfRedact = room.currentState.maySendEvent(EventType.RoomRedaction, me); + + this.setState({ canReact, canSendMessages, canSelfRedact }); + } + } + + // rate limited because a power level change will emit an event for every member in the room. + private updateRoomMembers = throttle(() => { + this.updateDMState(); + this.updateE2EStatus(this.state.room); + }, 500, { leading: true, trailing: true }); + + private checkDesktopNotifications() { + const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount(); + // if they are not alone prompt the user about notifications so they don't miss replies + if (memberCount > 1 && Notifier.shouldShowPrompt()) { + showNotificationsToast(true); + } + } + + private updateDMState() { + const room = this.state.room; + if (room.getMyMembership() != "join") { + return; + } + const dmInviter = room.getDMInviter(); + if (dmInviter) { + Rooms.setDMRoom(room.roomId, dmInviter); + } + } + + private onSearchResultsFillRequest = (backwards: boolean): Promise => { + if (!backwards) { + return Promise.resolve(false); + } + + if (this.state.searchResults.next_batch) { + debuglog("requesting more search results"); + const searchPromise = searchPagination(this.state.searchResults as ISearchResults); + return this.handleSearchResult(searchPromise); + } else { + debuglog("no more search results"); + return Promise.resolve(false); + } + }; + + private onInviteClick = () => { + // open the room inviter + dis.dispatch({ + action: 'view_invite', + roomId: this.state.room.roomId, + }); + }; + + private onJoinButtonClicked = () => { + // If the user is a ROU, allow them to transition to a PWLU + if (this.context?.isGuest()) { + // Join this room once the user has registered and logged in + // (If we failed to peek, we may not have a valid room object.) + dis.dispatch>({ + action: Action.DoAfterSyncPrepared, + deferred_action: { + action: Action.ViewRoom, + room_id: this.getRoomId(), + metricsTrigger: undefined, + }, + }); + dis.dispatch({ action: 'require_registration' }); + } else { + Promise.resolve().then(() => { + const signUrl = this.props.threepidInvite?.signUrl; + dis.dispatch({ + action: Action.JoinRoom, + roomId: this.getRoomId(), + opts: { inviteSignUrl: signUrl }, + metricsTrigger: this.state.room?.getMyMembership() === "invite" ? "Invite" : "RoomPreview", + }); + return Promise.resolve(); + }); + } + }; + + private onMessageListScroll = ev => { + if (this.messagePanel.isAtEndOfLiveTimeline()) { + this.setState({ + numUnreadMessages: 0, + atEndOfLiveTimeline: true, + }); + } else { + this.setState({ + atEndOfLiveTimeline: false, + }); + } + this.updateTopUnreadMessagesBar(); + }; + + private resetJumpToEvent = (eventId?: string) => { + if (this.state.initialEventId && this.state.initialEventScrollIntoView && + this.state.initialEventId === eventId) { + debuglog("Removing scroll_into_view flag from initial event"); + dis.dispatch({ + action: Action.ViewRoom, + room_id: this.state.room.roomId, + event_id: this.state.initialEventId, + highlighted: this.state.isInitialEventHighlighted, + scroll_into_view: false, + replyingToEvent: this.state.replyToEvent, + metricsTrigger: undefined, // room doesn't change + }); + } + }; + + private injectSticker(url: string, info: object, text: string, threadId: string | null) { + if (this.context.isGuest()) { + dis.dispatch({ action: 'require_registration' }); + return; + } + + ContentMessages.sharedInstance() + .sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context) + .then(undefined, (error) => { + if (error.name === "UnknownDeviceError") { + // Let the staus bar handle this + return; + } + }); + } + + private onSearch = (term: string, scope: SearchScope) => { + this.setState({ + searchTerm: term, + searchScope: scope, + searchResults: {}, + searchHighlights: [], + }); + + // if we already have a search panel, we need to tell it to forget + // about its scroll state. + if (this.searchResultsPanel.current) { + this.searchResultsPanel.current.resetScrollState(); + } + + // make sure that we don't end up showing results from + // an aborted search by keeping a unique id. + // + // todo: should cancel any previous search requests. + this.searchId = new Date().getTime(); + + let roomId; + if (scope === SearchScope.Room) roomId = this.state.room.roomId; + + debuglog("sending search request"); + const searchPromise = eventSearch(term, roomId); + this.handleSearchResult(searchPromise); + }; + + private handleSearchResult(searchPromise: Promise): Promise { + // keep a record of the current search id, so that if the search terms + // change before we get a response, we can ignore the results. + const localSearchId = this.searchId; + + this.setState({ + searchInProgress: true, + }); + + return searchPromise.then(async (results) => { + debuglog("search complete"); + if (this.unmounted || + this.state.timelineRenderingType !== TimelineRenderingType.Search || + this.searchId != localSearchId + ) { + logger.error("Discarding stale search results"); + return false; + } + + // postgres on synapse returns us precise details of the strings + // which actually got matched for highlighting. + // + // In either case, we want to highlight the literal search term + // whether it was used by the search engine or not. + + let highlights = results.highlights; + if (highlights.indexOf(this.state.searchTerm) < 0) { + highlights = highlights.concat(this.state.searchTerm); + } + + // For overlapping highlights, + // favour longer (more specific) terms first + highlights = highlights.sort(function(a, b) { + return b.length - a.length; + }); + + if (this.context.supportsExperimentalThreads()) { + // Process all thread roots returned in this batch of search results + // XXX: This won't work for results coming from Seshat which won't include the bundled relationship + for (const result of results.results) { + for (const event of result.context.getTimeline()) { + const bundledRelationship = event + .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); + if (!bundledRelationship || event.getThread()) continue; + const room = this.context.getRoom(event.getRoomId()); + const thread = room.findThreadForEvent(event); + if (thread) { + event.setThread(thread); + } else { + room.createThread(event.getId(), event, [], true); + } + } + } + } + + this.setState({ + searchHighlights: highlights, + searchResults: results, + }); + }, (error) => { + logger.error("Search failed", error); + Modal.createDialog(ErrorDialog, { + title: _t("Search failed"), + description: ((error && error.message) ? error.message : + _t("Server may be unavailable, overloaded, or search timed out :(")), + }); + return false; + }).finally(() => { + this.setState({ + searchInProgress: false, + }); + }); + } + + private getSearchResultTiles() { + // XXX: todo: merge overlapping results somehow? + // XXX: why doesn't searching on name work? + + const ret = []; + + if (this.state.searchInProgress) { + ret.push(
  • + +
  • ); + } + + if (!this.state.searchResults.next_batch) { + if (!this.state.searchResults?.results?.length) { + ret.push(
  • +

    { _t("No results") }

    +
  • , + ); + } else { + ret.push(
  • +

    { _t("No more results") }

    +
  • , + ); + } + } + + // once dynamic content in the search results load, make the scrollPanel check + // the scroll offsets. + const onHeightChanged = () => { + const scrollPanel = this.searchResultsPanel.current; + if (scrollPanel) { + scrollPanel.checkScroll(); + } + }; + + let lastRoomId; + + for (let i = (this.state.searchResults?.results?.length || 0) - 1; i >= 0; i--) { + const result = this.state.searchResults.results[i]; + + const mxEv = result.context.getEvent(); + const roomId = mxEv.getRoomId(); + const room = this.context.getRoom(roomId); + if (!room) { + // if we do not have the room in js-sdk stores then hide it as we cannot easily show it + // As per the spec, an all rooms search can create this condition, + // it happens with Seshat but not Synapse. + // It will make the result count not match the displayed count. + logger.log("Hiding search result from an unknown room", roomId); + continue; + } + + if (!haveRendererForEvent(mxEv, this.state.showHiddenEvents)) { + // XXX: can this ever happen? It will make the result count + // not match the displayed count. + continue; + } + + if (this.state.searchScope === 'All') { + if (roomId !== lastRoomId) { + ret.push(
  • +

    { _t("Room") }: { room.name }

    +
  • ); + lastRoomId = roomId; + } + } + + const resultLink = "#/room/"+roomId+"/"+mxEv.getId(); + + ret.push(); + } + return ret; + } + + private onCallPlaced = (type: CallType): void => { + CallHandler.instance.placeCall(this.state.room?.roomId, type); + }; + + private onAppsClick = () => { + dis.dispatch({ + action: "appsDrawer", + show: !this.state.showApps, + }); + }; + + private onForgetClick = () => { + dis.dispatch({ + action: 'forget_room', + room_id: this.state.room.roomId, + }); + }; + + private onRejectButtonClicked = () => { + this.setState({ + rejecting: true, + }); + this.context.leave(this.state.roomId).then(() => { + dis.dispatch({ action: Action.ViewHomePage }); + this.setState({ + rejecting: false, + }); + }, (error) => { + logger.error("Failed to reject invite: %s", error); + + const msg = error.message ? error.message : JSON.stringify(error); + Modal.createDialog(ErrorDialog, { + title: _t("Failed to reject invite"), + description: msg, + }); + + this.setState({ + rejecting: false, + rejectError: error, + }); + }); + }; + + private onRejectAndIgnoreClick = async () => { + this.setState({ + rejecting: true, + }); + + try { + const myMember = this.state.room.getMember(this.context.getUserId()); + const inviteEvent = myMember.events.member; + const ignoredUsers = this.context.getIgnoredUsers(); + ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk + await this.context.setIgnoredUsers(ignoredUsers); + + await this.context.leave(this.state.roomId); + dis.dispatch({ action: Action.ViewHomePage }); + this.setState({ + rejecting: false, + }); + } catch (error) { + logger.error("Failed to reject invite: %s", error); + + const msg = error.message ? error.message : JSON.stringify(error); + Modal.createDialog(ErrorDialog, { + title: _t("Failed to reject invite"), + description: msg, + }); + + this.setState({ + rejecting: false, + rejectError: error, + }); + } + }; + + private onRejectThreepidInviteButtonClicked = () => { + // We can reject 3pid invites in the same way that we accept them, + // using /leave rather than /join. In the short term though, we + // just ignore them. + // https://github.com/vector-im/vector-web/issues/1134 + dis.fire(Action.ViewRoomDirectory); + }; + + private onSearchClick = () => { + this.setState({ + timelineRenderingType: this.state.timelineRenderingType === TimelineRenderingType.Search + ? TimelineRenderingType.Room + : TimelineRenderingType.Search, + }); + }; + + private onCancelSearchClick = (): Promise => { + return new Promise(resolve => { + this.setState({ + timelineRenderingType: TimelineRenderingType.Room, + searchResults: null, + }, resolve); + }); + }; + + // jump down to the bottom of this room, where new events are arriving + private jumpToLiveTimeline = () => { + if (this.state.initialEventId && this.state.isInitialEventHighlighted) { + // If we were viewing a highlighted event, firing view_room without + // an event will take care of both clearing the URL fragment and + // jumping to the bottom + dis.dispatch({ + action: Action.ViewRoom, + room_id: this.state.room.roomId, + metricsTrigger: undefined, // room doesn't change + }); + } else { + // Otherwise we have to jump manually + this.messagePanel.jumpToLiveTimeline(); + dis.fire(Action.FocusSendMessageComposer); + } + }; + + // jump up to wherever our read marker is + private jumpToReadMarker = () => { + this.messagePanel.jumpToReadMarker(); + }; + + // update the read marker to match the read-receipt + private forgetReadMarker = ev => { + ev.stopPropagation(); + this.messagePanel.forgetReadMarker(); + }; + + // decide whether or not the top 'unread messages' bar should be shown + private updateTopUnreadMessagesBar = () => { + if (!this.messagePanel) { + return; + } + + const showBar = this.messagePanel.canJumpToReadMarker(); + if (this.state.showTopUnreadMessagesBar != showBar) { + this.setState({ showTopUnreadMessagesBar: showBar }); + } + }; + + // get the current scroll position of the room, so that it can be + // restored when we switch back to it. + // + private getScrollState(): ScrollState { + const messagePanel = this.messagePanel; + if (!messagePanel) return null; + + // if we're following the live timeline, we want to return null; that + // means that, if we switch back, we will jump to the read-up-to mark. + // + // That should be more intuitive than slavishly preserving the current + // scroll state, in the case where the room advances in the meantime + // (particularly in the case that the user reads some stuff on another + // device). + // + if (this.state.atEndOfLiveTimeline) { + return null; + } + + const scrollState = messagePanel.getScrollState(); + + // getScrollState on TimelinePanel *may* return null, so guard against that + if (!scrollState || scrollState.stuckAtBottom) { + // we don't really expect to be in this state, but it will + // occasionally happen when no scroll state has been set on the + // messagePanel (ie, we didn't have an initial event (so it's + // probably a new room), there has been no user-initiated scroll, and + // no read-receipts have arrived to update the scroll position). + // + // Return null, which will cause us to scroll to last unread on + // reload. + return null; + } + + return { + focussedEvent: scrollState.trackedScrollToken, + pixelOffset: scrollState.pixelOffset, + }; + } + + private onStatusBarVisible = () => { + if (this.unmounted || this.state.statusBarVisible) return; + this.setState({ statusBarVisible: true }); + }; + + private onStatusBarHidden = () => { + // This is currently not desired as it is annoying if it keeps expanding and collapsing + if (this.unmounted || !this.state.statusBarVisible) return; + this.setState({ statusBarVisible: false }); + }; + + /** + * called by the parent component when PageUp/Down/etc is pressed. + * + * We pass it down to the scroll panel. + */ + public handleScrollKey = ev => { + let panel: ScrollPanel | TimelinePanel; + if (this.searchResultsPanel.current) { + panel = this.searchResultsPanel.current; + } else if (this.messagePanel) { + panel = this.messagePanel; + } + + if (panel) { + panel.handleScrollKey(ev); + } + }; + + /** + * get any current call for this room + */ + private getCallForRoom(): MatrixCall { + if (!this.state.room) { + return null; + } + return CallHandler.instance.getCallForRoom(this.state.room.roomId); + } + + // this has to be a proper method rather than an unnamed function, + // otherwise react calls it with null on each update. + private gatherTimelinePanelRef = r => { + this.messagePanel = r; + }; + + private getOldRoom() { + const createEvent = this.state.room.currentState.getStateEvents(EventType.RoomCreate, ""); + if (!createEvent || !createEvent.getContent()['predecessor']) return null; + + return this.context.getRoom(createEvent.getContent()['predecessor']['room_id']); + } + + getHiddenHighlightCount() { + const oldRoom = this.getOldRoom(); + if (!oldRoom) return 0; + return oldRoom.getUnreadNotificationCount(NotificationCountType.Highlight); + } + + onHiddenHighlightsClick = () => { + const oldRoom = this.getOldRoom(); + if (!oldRoom) return; + dis.dispatch({ + action: Action.ViewRoom, + room_id: oldRoom.roomId, + metricsTrigger: "Predecessor", + }); + }; + + private get messagePanelClassNames(): string { + return classNames("mx_RoomView_messagePanel", { + mx_IRCLayout: this.state.layout === Layout.IRC, + }); + } + + private onFileDrop = (dataTransfer: DataTransfer) => ContentMessages.sharedInstance().sendContentListToRoom( + Array.from(dataTransfer.files), + this.state.room?.roomId ?? this.state.roomId, + null, + this.context, + TimelineRenderingType.Room, + ); + + private onMeasurement = (narrow: boolean): void => { + this.setState({ narrow }); + }; + + private get viewsLocalRoom(): boolean { + return isLocalRoom(this.state.room); + } + + private get permalinkCreator(): RoomPermalinkCreator { + return this.getPermalinkCreatorForRoom(this.state.room); + } + + private renderLocalRoomCreateLoader(): ReactElement { + const names = this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()); + return + + ; + } + + private renderLocalRoomView(): ReactElement { + return + + ; + } + + render() { + if (this.state.room instanceof LocalRoom) { + if (this.state.room.state === LocalRoomState.CREATING) { + return this.renderLocalRoomCreateLoader(); + } + + return this.renderLocalRoomView(); + } + + if (!this.state.room) { + const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading; + if (loading) { + // Assume preview loading if we don't have a ready client or a room ID (still resolving the alias) + const previewLoading = !this.state.matrixClientIsReady || !this.state.roomId || this.state.peekLoading; + return ( +
    + + + +
    + ); + } else { + let inviterName = undefined; + if (this.props.oobData) { + inviterName = this.props.oobData.inviterName; + } + const invitedEmail = this.props.threepidInvite?.toEmail; + + // We have no room object for this room, only the ID. + // We've got to this room by following a link, possibly a third party invite. + const roomAlias = this.state.roomAlias; + return ( +
    + + + +
    + ); + } + } + + const myMembership = this.state.room.getMyMembership(); + if ( + this.state.room.isElementVideoRoom() && + !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join") + ) { + return +
    + +
    ; +
    ; + } + + // SpaceRoomView handles invites itself + if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { + if (this.state.joining || this.state.rejecting) { + return ( + + + + ); + } else { + const myUserId = this.context.credentials.userId; + const myMember = this.state.room.getMember(myUserId); + const inviteEvent = myMember ? myMember.events.member : null; + let inviterName = _t("Unknown"); + if (inviteEvent) { + inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender(); + } + + // We deliberately don't try to peek into invites, even if we have permission to peek + // as they could be a spam vector. + // XXX: in future we could give the option of a 'Preview' button which lets them view anyway. + + // We have a regular invite for this room. + return ( +
    + + + +
    + ); + } + } + + // We have successfully loaded this room, and are not previewing. + // Display the "normal" room view. + + let activeCall = null; + { + // New block because this variable doesn't need to hang around for the rest of the function + const call = this.getCallForRoom(); + if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) { + activeCall = call; + } + } + + const scrollheaderClasses = classNames({ + mx_RoomView_scrollheader: true, + }); + + let statusBar; + let isStatusAreaExpanded = true; + + if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { + statusBar = ; + } else if (!this.state.searchResults) { + isStatusAreaExpanded = this.state.statusBarVisible; + statusBar = ; + } + + const statusBarAreaClass = classNames("mx_RoomView_statusArea", { + "mx_RoomView_statusArea_expanded": isStatusAreaExpanded, + }); + + // if statusBar does not exist then statusBarArea is blank and takes up unnecessary space on the screen + // show statusBarArea only if statusBar is present + const statusBarArea = statusBar &&
    +
    +
    + { statusBar } +
    +
    ; + + const roomVersionRecommendation = this.state.upgradeRecommendation; + const showRoomUpgradeBar = ( + roomVersionRecommendation && + roomVersionRecommendation.needsUpgrade && + this.state.room.userMayUpgradeRoom(this.context.credentials.userId) + ); + + const hiddenHighlightCount = this.getHiddenHighlightCount(); + + let aux = null; + let previewBar; + if (this.state.timelineRenderingType === TimelineRenderingType.Search) { + aux = ; + } else if (showRoomUpgradeBar) { + aux = ; + } else if (myMembership !== "join") { + // We do have a room object for this room, but we're not currently in it. + // We may have a 3rd party invite to it. + let inviterName = undefined; + if (this.props.oobData) { + inviterName = this.props.oobData.inviterName; + } + const invitedEmail = this.props.threepidInvite?.toEmail; + previewBar = ( + + ); + if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) { + return ( +
    + { previewBar } +
    + ); + } + } else if (hiddenHighlightCount > 0) { + aux = ( + + { _t( + "You have %(count)s unread notifications in a prior version of this room.", + { count: hiddenHighlightCount }, + ) } + + ); + } + + if (this.state.room?.isSpaceRoom() && !this.props.forceTimeline) { + return ; + } + + const auxPanel = ( + + { aux } + + ); + + let messageComposer; let searchInfo; + const showComposer = ( + // joined and not showing search results + myMembership === 'join' && !this.state.searchResults + ); + if (showComposer) { + messageComposer = + ; + } + + // TODO: Why aren't we storing the term/scope/count in this format + // in this.state if this is what RoomHeader desires? + if (this.state.searchResults) { + searchInfo = { + searchTerm: this.state.searchTerm, + searchScope: this.state.searchScope, + searchCount: this.state.searchResults.count, + }; + } + + // if we have search results, we keep the messagepanel (so that it preserves its + // scroll state), but hide it. + let searchResultsPanel; + let hideMessagePanel = false; + + if (this.state.searchResults) { + // show searching spinner + if (this.state.searchResults.count === undefined) { + searchResultsPanel = ( +
    + ); + } else { + searchResultsPanel = ( + +
  • + { this.getSearchResultTiles() } + + ); + } + hideMessagePanel = true; + } + + let highlightedEventId = null; + if (this.state.isInitialEventHighlighted) { + highlightedEventId = this.state.initialEventId; + } + + // console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview); + const messagePanel = ( +
  • - -
  • ); - } - - if (!this.state.searchResults.next_batch) { - if (!this.state.searchResults?.results?.length) { - ret.push(
  • -

    { _t("No results") }

    -
  • , - ); - } else { - ret.push(
  • -

    { _t("No more results") }

    -
  • , - ); - } - } - - // once dynamic content in the search results load, make the scrollPanel check - // the scroll offsets. - const onHeightChanged = () => { - const scrollPanel = this.searchResultsPanel.current; - if (scrollPanel) { - scrollPanel.checkScroll(); - } - }; - - let lastRoomId; - - for (let i = (this.state.searchResults?.results?.length || 0) - 1; i >= 0; i--) { - const result = this.state.searchResults.results[i]; - - const mxEv = result.context.getEvent(); - const roomId = mxEv.getRoomId(); - const room = this.context.getRoom(roomId); - if (!room) { - // if we do not have the room in js-sdk stores then hide it as we cannot easily show it - // As per the spec, an all rooms search can create this condition, - // it happens with Seshat but not Synapse. - // It will make the result count not match the displayed count. - logger.log("Hiding search result from an unknown room", roomId); - continue; - } - - if (!haveRendererForEvent(mxEv, this.state.showHiddenEvents)) { - // XXX: can this ever happen? It will make the result count - // not match the displayed count. - continue; - } - - if (this.state.searchScope === 'All') { - if (roomId !== lastRoomId) { - ret.push(
  • -

    { _t("Room") }: { room.name }

    -
  • ); - lastRoomId = roomId; - } - } - - const resultLink = "#/room/"+roomId+"/"+mxEv.getId(); - - ret.push(); - } - return ret; - } - - private onCallPlaced = (type: CallType): void => { - CallHandler.instance.placeCall(this.state.room?.roomId, type); - }; - - private onAppsClick = () => { - dis.dispatch({ - action: "appsDrawer", - show: !this.state.showApps, - }); - }; - - private onForgetClick = () => { - dis.dispatch({ - action: 'forget_room', - room_id: this.state.room.roomId, - }); - }; - - private onRejectButtonClicked = () => { - this.setState({ - rejecting: true, - }); - this.context.leave(this.state.roomId).then(() => { - dis.dispatch({ action: Action.ViewHomePage }); - this.setState({ - rejecting: false, - }); - }, (error) => { - logger.error("Failed to reject invite: %s", error); - - const msg = error.message ? error.message : JSON.stringify(error); - Modal.createDialog(ErrorDialog, { - title: _t("Failed to reject invite"), - description: msg, - }); - - this.setState({ - rejecting: false, - rejectError: error, - }); - }); - }; - - private onRejectAndIgnoreClick = async () => { - this.setState({ - rejecting: true, - }); - - try { - const myMember = this.state.room.getMember(this.context.getUserId()); - const inviteEvent = myMember.events.member; - const ignoredUsers = this.context.getIgnoredUsers(); - ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk - await this.context.setIgnoredUsers(ignoredUsers); - - await this.context.leave(this.state.roomId); - dis.dispatch({ action: Action.ViewHomePage }); - this.setState({ - rejecting: false, - }); - } catch (error) { - logger.error("Failed to reject invite: %s", error); - - const msg = error.message ? error.message : JSON.stringify(error); - Modal.createDialog(ErrorDialog, { - title: _t("Failed to reject invite"), - description: msg, - }); - - this.setState({ - rejecting: false, - rejectError: error, - }); - } - }; - - private onRejectThreepidInviteButtonClicked = () => { - // We can reject 3pid invites in the same way that we accept them, - // using /leave rather than /join. In the short term though, we - // just ignore them. - // https://github.com/vector-im/vector-web/issues/1134 - dis.fire(Action.ViewRoomDirectory); - }; - - private onSearchClick = () => { - this.setState({ - timelineRenderingType: this.state.timelineRenderingType === TimelineRenderingType.Search - ? TimelineRenderingType.Room - : TimelineRenderingType.Search, - }); - }; - - private onCancelSearchClick = (): Promise => { - return new Promise(resolve => { - this.setState({ - timelineRenderingType: TimelineRenderingType.Room, - searchResults: null, - }, resolve); - }); - }; - - // jump down to the bottom of this room, where new events are arriving - private jumpToLiveTimeline = () => { - if (this.state.initialEventId && this.state.isInitialEventHighlighted) { - // If we were viewing a highlighted event, firing view_room without - // an event will take care of both clearing the URL fragment and - // jumping to the bottom - dis.dispatch({ - action: Action.ViewRoom, - room_id: this.state.room.roomId, - metricsTrigger: undefined, // room doesn't change - }); - } else { - // Otherwise we have to jump manually - this.messagePanel.jumpToLiveTimeline(); - dis.fire(Action.FocusSendMessageComposer); - } - }; - - // jump up to wherever our read marker is - private jumpToReadMarker = () => { - this.messagePanel.jumpToReadMarker(); - }; - - // update the read marker to match the read-receipt - private forgetReadMarker = ev => { - ev.stopPropagation(); - this.messagePanel.forgetReadMarker(); - }; - - // decide whether or not the top 'unread messages' bar should be shown - private updateTopUnreadMessagesBar = () => { - if (!this.messagePanel) { - return; - } - - const showBar = this.messagePanel.canJumpToReadMarker(); - if (this.state.showTopUnreadMessagesBar != showBar) { - this.setState({ showTopUnreadMessagesBar: showBar }); - } - }; - - // get the current scroll position of the room, so that it can be - // restored when we switch back to it. - // - private getScrollState(): ScrollState { - const messagePanel = this.messagePanel; - if (!messagePanel) return null; - - // if we're following the live timeline, we want to return null; that - // means that, if we switch back, we will jump to the read-up-to mark. - // - // That should be more intuitive than slavishly preserving the current - // scroll state, in the case where the room advances in the meantime - // (particularly in the case that the user reads some stuff on another - // device). - // - if (this.state.atEndOfLiveTimeline) { - return null; - } - - const scrollState = messagePanel.getScrollState(); - - // getScrollState on TimelinePanel *may* return null, so guard against that - if (!scrollState || scrollState.stuckAtBottom) { - // we don't really expect to be in this state, but it will - // occasionally happen when no scroll state has been set on the - // messagePanel (ie, we didn't have an initial event (so it's - // probably a new room), there has been no user-initiated scroll, and - // no read-receipts have arrived to update the scroll position). - // - // Return null, which will cause us to scroll to last unread on - // reload. - return null; - } - - return { - focussedEvent: scrollState.trackedScrollToken, - pixelOffset: scrollState.pixelOffset, - }; - } - - private onStatusBarVisible = () => { - if (this.unmounted || this.state.statusBarVisible) return; - this.setState({ statusBarVisible: true }); - }; - - private onStatusBarHidden = () => { - // This is currently not desired as it is annoying if it keeps expanding and collapsing - if (this.unmounted || !this.state.statusBarVisible) return; - this.setState({ statusBarVisible: false }); - }; - - /** - * called by the parent component when PageUp/Down/etc is pressed. - * - * We pass it down to the scroll panel. - */ - public handleScrollKey = ev => { - let panel: ScrollPanel | TimelinePanel; - if (this.searchResultsPanel.current) { - panel = this.searchResultsPanel.current; - } else if (this.messagePanel) { - panel = this.messagePanel; - } - - if (panel) { - panel.handleScrollKey(ev); - } - }; - - /** - * get any current call for this room - */ - private getCallForRoom(): MatrixCall { - if (!this.state.room) { - return null; - } - return CallHandler.instance.getCallForRoom(this.state.room.roomId); - } - - // this has to be a proper method rather than an unnamed function, - // otherwise react calls it with null on each update. - private gatherTimelinePanelRef = r => { - this.messagePanel = r; - }; - - private getOldRoom() { - const createEvent = this.state.room.currentState.getStateEvents(EventType.RoomCreate, ""); - if (!createEvent || !createEvent.getContent()['predecessor']) return null; - - return this.context.getRoom(createEvent.getContent()['predecessor']['room_id']); - } - - getHiddenHighlightCount() { - const oldRoom = this.getOldRoom(); - if (!oldRoom) return 0; - return oldRoom.getUnreadNotificationCount(NotificationCountType.Highlight); - } - - onHiddenHighlightsClick = () => { - const oldRoom = this.getOldRoom(); - if (!oldRoom) return; - dis.dispatch({ - action: Action.ViewRoom, - room_id: oldRoom.roomId, - metricsTrigger: "Predecessor", - }); - }; - - private get messagePanelClassNames(): string { - return classNames("mx_RoomView_messagePanel", { - mx_IRCLayout: this.state.layout === Layout.IRC, - }); - } - - private onFileDrop = (dataTransfer: DataTransfer) => ContentMessages.sharedInstance().sendContentListToRoom( - Array.from(dataTransfer.files), - this.state.room?.roomId ?? this.state.roomId, - null, - this.context, - TimelineRenderingType.Room, - ); - - private onMeasurement = (narrow: boolean): void => { - this.setState({ narrow }); - }; - - private get viewsLocalRoom(): boolean { - return isLocalRoom(this.state.room); - } - - private get permalinkCreator(): RoomPermalinkCreator { - return this.getPermalinkCreatorForRoom(this.state.room); - } - - private renderLocalRoomCreateLoader(): ReactElement { - const names = this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()); - return - - ; - } - - private renderLocalRoomView(): ReactElement { - return - - ; - } - - render() { - if (this.state.room instanceof LocalRoom) { - if (this.state.room.state === LocalRoomState.CREATING) { - return this.renderLocalRoomCreateLoader(); - } - - return this.renderLocalRoomView(); - } - - if (!this.state.room) { - const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading; - if (loading) { - // Assume preview loading if we don't have a ready client or a room ID (still resolving the alias) - const previewLoading = !this.state.matrixClientIsReady || !this.state.roomId || this.state.peekLoading; - return ( -
    - - - -
    - ); - } else { - let inviterName = undefined; - if (this.props.oobData) { - inviterName = this.props.oobData.inviterName; - } - const invitedEmail = this.props.threepidInvite?.toEmail; - - // We have no room object for this room, only the ID. - // We've got to this room by following a link, possibly a third party invite. - const roomAlias = this.state.roomAlias; - return ( -
    - - - -
    - ); - } - } - - const myMembership = this.state.room.getMyMembership(); - if ( - this.state.room.isElementVideoRoom() && - !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join") - ) { - return -
    - -
    ; -
    ; - } - - // SpaceRoomView handles invites itself - if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { - if (this.state.joining || this.state.rejecting) { - return ( - - - - ); - } else { - const myUserId = this.context.credentials.userId; - const myMember = this.state.room.getMember(myUserId); - const inviteEvent = myMember ? myMember.events.member : null; - let inviterName = _t("Unknown"); - if (inviteEvent) { - inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender(); - } - - // We deliberately don't try to peek into invites, even if we have permission to peek - // as they could be a spam vector. - // XXX: in future we could give the option of a 'Preview' button which lets them view anyway. - - // We have a regular invite for this room. - return ( -
    - - - -
    - ); - } - } - - // We have successfully loaded this room, and are not previewing. - // Display the "normal" room view. - - let activeCall = null; - { - // New block because this variable doesn't need to hang around for the rest of the function - const call = this.getCallForRoom(); - if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) { - activeCall = call; - } - } - - const scrollheaderClasses = classNames({ - mx_RoomView_scrollheader: true, - }); - - let statusBar; - let isStatusAreaExpanded = true; - - if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { - statusBar = ; - } else if (!this.state.searchResults) { - isStatusAreaExpanded = this.state.statusBarVisible; - statusBar = ; - } - - const statusBarAreaClass = classNames("mx_RoomView_statusArea", { - "mx_RoomView_statusArea_expanded": isStatusAreaExpanded, - }); - - // if statusBar does not exist then statusBarArea is blank and takes up unnecessary space on the screen - // show statusBarArea only if statusBar is present - const statusBarArea = statusBar &&
    -
    -
    - { statusBar } -
    -
    ; - - const roomVersionRecommendation = this.state.upgradeRecommendation; - const showRoomUpgradeBar = ( - roomVersionRecommendation && - roomVersionRecommendation.needsUpgrade && - this.state.room.userMayUpgradeRoom(this.context.credentials.userId) - ); - - const hiddenHighlightCount = this.getHiddenHighlightCount(); - - let aux = null; - let previewBar; - if (this.state.timelineRenderingType === TimelineRenderingType.Search) { - aux = ; - } else if (showRoomUpgradeBar) { - aux = ; - } else if (myMembership !== "join") { - // We do have a room object for this room, but we're not currently in it. - // We may have a 3rd party invite to it. - let inviterName = undefined; - if (this.props.oobData) { - inviterName = this.props.oobData.inviterName; - } - const invitedEmail = this.props.threepidInvite?.toEmail; - previewBar = ( - - ); - if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) { - return ( -
    - { previewBar } -
    - ); - } - } else if (hiddenHighlightCount > 0) { - aux = ( - - { _t( - "You have %(count)s unread notifications in a prior version of this room.", - { count: hiddenHighlightCount }, - ) } - - ); - } - - if (this.state.room?.isSpaceRoom() && !this.props.forceTimeline) { - return ; - } - - const auxPanel = ( - - { aux } - - ); - - let messageComposer; let searchInfo; - const showComposer = ( - // joined and not showing search results - myMembership === 'join' && !this.state.searchResults - ); - if (showComposer) { - messageComposer = - ; - } - - // TODO: Why aren't we storing the term/scope/count in this format - // in this.state if this is what RoomHeader desires? - if (this.state.searchResults) { - searchInfo = { - searchTerm: this.state.searchTerm, - searchScope: this.state.searchScope, - searchCount: this.state.searchResults.count, - }; - } - - // if we have search results, we keep the messagepanel (so that it preserves its - // scroll state), but hide it. - let searchResultsPanel; - let hideMessagePanel = false; - - if (this.state.searchResults) { - // show searching spinner - if (this.state.searchResults.count === undefined) { - searchResultsPanel = ( -
    - ); - } else { - searchResultsPanel = ( - -
  • - { this.getSearchResultTiles() } - - ); - } - hideMessagePanel = true; - } - - let highlightedEventId = null; - if (this.state.isInitialEventHighlighted) { - highlightedEventId = this.state.initialEventId; - } - - // console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview); - const messagePanel = ( -
  • diff --git a/test/components/structures/LargeLoader-test.tsx b/test/components/structures/LargeLoader-test.tsx new file mode 100644 index 00000000000..539c8282ad1 --- /dev/null +++ b/test/components/structures/LargeLoader-test.tsx @@ -0,0 +1,32 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { LargeLoader } from "../../../src/components/structures/LargeLoader"; + +describe("LargeLoader", () => { + const text = "test loading text"; + + beforeEach(() => { + render(); + }); + + it("should render the text", () => { + screen.getByText(text); + }); +}); diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index db8559008a5..358db4dc7f6 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
    U\\"\\"
    @user:example.com
    We're creating a room with @user:example.com
    "`; +exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
    U\\"\\"
    @user:example.com
    We're creating a room with @user:example.com
    "`; exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
    U\\"\\"
    @user:example.com
      End-to-end encryption isn't enabled
      Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
      U\\"\\"

      @user:example.com

      Send your first message to invite @user:example.com to chat

    !
    Some of your messages have not been sent
    Retry
    "`; From 3bfa1f8a316225d61930936adeb927f11ab7ee48 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 1 Aug 2022 13:35:04 +0200 Subject: [PATCH 73/73] Update roomview test --- .../structures/__snapshots__/RoomView-test.tsx.snap | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index 358db4dc7f6..a0c3a277c90 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
    U\\"\\"
    @user:example.com
    We're creating a room with @user:example.com
    "`; +exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
    U\\"\\"
    @user:example.com
    We're creating a room with @user:example.com
    "`; -exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
    U\\"\\"
    @user:example.com
      End-to-end encryption isn't enabled
      Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
      U\\"\\"

      @user:example.com

      Send your first message to invite @user:example.com to chat

    !
    Some of your messages have not been sent
    Retry
    "`; +exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
    U\\"\\"
    @user:example.com
    1. End-to-end encryption isn't enabled
      Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
      U\\"\\"

      @user:example.com

      Send your first message to invite @user:example.com to chat

    !
    Some of your messages have not been sent
    Retry
    "`; -exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
    U\\"\\"
    @user:example.com
      End-to-end encryption isn't enabled
      Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
      U\\"\\"

      @user:example.com

      Send your first message to invite @user:example.com to chat


    "`; +exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
    U\\"\\"
    @user:example.com
    1. End-to-end encryption isn't enabled
      Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
      U\\"\\"

      @user:example.com

      Send your first message to invite @user:example.com to chat


    "`; -exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
    U\\"\\"
    @user:example.com
      Encryption enabled
      Messages in this chat will be end-to-end encrypted.
      U\\"\\"

      @user:example.com

      Send your first message to invite @user:example.com to chat


    "`; +exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
    U\\"\\"
    @user:example.com
      Encryption enabled
      Messages in this chat will be end-to-end encrypted.
    1. U\\"\\"

      @user:example.com

      Send your first message to invite @user:example.com to chat


    "`;