From c2b66d0dbeaa22991d4f3db9acb18401a6c44a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Tue, 6 Apr 2021 14:29:20 +0200 Subject: [PATCH 01/43] Fix inserting trailing colon after mention/pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/components/views/rooms/SendMessageComposer.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 75bc943146..ab080f32cd 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -502,9 +502,10 @@ export default class SendMessageComposer extends React.Component { member.rawDisplayName : userId; const caret = this._editorRef.getCaret(); const position = model.positionForOffset(caret.offset, caret.atNodeEnd); - // index is -1 if there are no parts but we only care for if this would be the part in position 0 - const insertIndex = position.index > 0 ? position.index : 0; - const parts = partCreator.createMentionParts(insertIndex, displayName, userId); + // createMentionParts() assumes that the mention already has it's own part + // which isn't true, therefore we increase the position.index by 1. This + // also solves the problem of the index being -1 when the composer is empty. + const parts = partCreator.createMentionParts(position.index + 1, displayName, userId); model.transform(() => { const addedLen = model.insert(parts, position); return model.positionForOffset(caret.offset + addedLen, true); From 715fff6f0cb95b3ba64ae0f0a6c183321d3161cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Wed, 7 Apr 2021 11:52:07 +0200 Subject: [PATCH 02/43] Redo and fix trailing characters in user pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This removes the handling of trailing chars from createMentionParts as we need to determine whether or not to insert the trailing char differently in different situations Signed-off-by: Šimon Brandner --- src/components/views/rooms/SendMessageComposer.js | 6 ++---- src/editor/autocomplete.ts | 6 ++---- src/editor/parts.ts | 4 ++-- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index ab080f32cd..3aedbd4d92 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -502,10 +502,8 @@ export default class SendMessageComposer extends React.Component { member.rawDisplayName : userId; const caret = this._editorRef.getCaret(); const position = model.positionForOffset(caret.offset, caret.atNodeEnd); - // createMentionParts() assumes that the mention already has it's own part - // which isn't true, therefore we increase the position.index by 1. This - // also solves the problem of the index being -1 when the composer is empty. - const parts = partCreator.createMentionParts(position.index + 1, displayName, userId); + // Insert suffix only if the caret is at the start of the composer + const parts = partCreator.createMentionParts(caret.offset === 0, displayName, userId); model.transform(() => { const addedLen = model.insert(parts, position); return model.positionForOffset(caret.offset + addedLen, true); diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts index 2f56494ea0..240ed2d96b 100644 --- a/src/editor/autocomplete.ts +++ b/src/editor/autocomplete.ts @@ -125,10 +125,8 @@ export default class AutocompleteWrapperModel { case "at-room": return [this.partCreator.atRoomPill(completionId), this.partCreator.plain(completion.suffix)]; case "user": - // not using suffix here, because we also need to calculate - // the suffix when clicking a display name to insert a mention, - // which happens in createMentionParts - return this.partCreator.createMentionParts(this.partIndex, text, completionId); + // Insert suffix only if the pill is the part with index 0 - we are at the start of the composer + return this.partCreator.createMentionParts(this.partIndex === 0, text, completionId); case "command": // command needs special handling for auto complete, but also renders as plain texts return [(this.partCreator as CommandPartCreator).command(text)]; diff --git a/src/editor/parts.ts b/src/editor/parts.ts index ccd90da3e2..9fd7e289ae 100644 --- a/src/editor/parts.ts +++ b/src/editor/parts.ts @@ -543,9 +543,9 @@ export class PartCreator { return new UserPillPart(userId, displayName, member); } - createMentionParts(partIndex: number, displayName: string, userId: string) { + createMentionParts(insertTrailingCharacter: boolean, displayName: string, userId: string) { const pill = this.userPill(displayName, userId); - const postfix = this.plain(partIndex === 0 ? ": " : " "); + const postfix = this.plain(insertTrailingCharacter ? ": " : " "); return [pill, postfix]; } } From 14b8b0f8da14493b685582b6108e8029a6c282ca Mon Sep 17 00:00:00 2001 From: "Sam A. Horvath-Hunt" Date: Tue, 13 Apr 2021 17:22:07 +0100 Subject: [PATCH 03/43] Render ignored users setting regardless of if there are any Signed-off-by: Sam A. Horvath-Hunt --- .../views/settings/tabs/user/SecurityUserSettingsTab.js | 7 +++---- src/i18n/strings/en_EN.json | 1 + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index 8a70811399..da41f2f0dc 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -255,10 +255,9 @@ export default class SecurityUserSettingsTab extends React.Component { _renderIgnoredUsers() { const {waitingUnignored, ignoredUserIds} = this.state; - if (!ignoredUserIds || ignoredUserIds.length === 0) return null; - - const userIds = ignoredUserIds - .map((u) => Date: Mon, 19 Apr 2021 20:30:51 +0100 Subject: [PATCH 04/43] Support MSC3086 asserted identity --- src/CallHandler.tsx | 58 +++++++++++++++++-- src/VoipUserMapper.ts | 6 +- src/components/views/voip/CallPreview.tsx | 2 + src/components/views/voip/CallView.tsx | 18 +++--- src/components/views/voip/CallViewForRoom.tsx | 2 + src/components/views/voip/IncomingCallBox.tsx | 6 +- src/dispatcher/actions.ts | 3 + 7 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index be687a4474..886c594c94 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -86,6 +86,9 @@ import { Action } from './dispatcher/actions'; import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring"; +import SdkConfig from './SdkConfig'; +import DMRoomMap from './utils/DMRoomMap'; +import { ensureDMExists, findDMForUser } from './createRoom'; export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; @@ -167,6 +170,11 @@ export default class CallHandler { private invitedRoomsAreVirtual = new Map(); private invitedRoomCheckInProgress = false; + // Map of the asserted identiy users after we've looked them up using the API. + // We need to be be able to determine the mapped room synchronously, so we + // do the async lookup when we get new information and then store these mappings here + private assertedIdentityNativeUsers = new Map(); + static sharedInstance() { if (!window.mxCallHandler) { window.mxCallHandler = new CallHandler() @@ -179,8 +187,17 @@ export default class CallHandler { * Gets the user-facing room associated with a call (call.roomId may be the call "virtual room" * if a voip_mxid_translate_pattern is set in the config) */ - public static roomIdForCall(call: MatrixCall): string { + public roomIdForCall(call: MatrixCall): string { if (!call) return null; + + if (SdkConfig.get()['voipObeyAssertedIdentity']) { + const nativeUser = this.assertedIdentityNativeUsers[call.callId]; + if (nativeUser) { + const room = findDMForUser(MatrixClientPeg.get(), nativeUser); + if (room) return room.roomId + } + } + return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId; } @@ -379,14 +396,14 @@ export default class CallHandler { // We don't allow placing more than one call per room, but that doesn't mean there // can't be more than one, eg. in a glare situation. This checks that the given call // is the call we consider 'the' call for its room. - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); const callForThisRoom = this.getCallForRoom(mappedRoomId); return callForThisRoom && call.callId === callForThisRoom.callId; } private setCallListeners(call: MatrixCall) { - const mappedRoomId = CallHandler.roomIdForCall(call); + let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); call.on(CallEvent.Error, (err: CallError) => { if (!this.matchesCallForThisRoom(call)) return; @@ -500,6 +517,37 @@ export default class CallHandler { this.setCallListeners(newCall); this.setCallState(newCall, newCall.state); }); + call.on(CallEvent.AssertedIdentityChanged, async () => { + if (!this.matchesCallForThisRoom(call)) return; + + console.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity()); + + const newAssertedIdentity = call.getRemoteAssertedIdentity().id; + let newNativeAssertedIdentity = newAssertedIdentity; + if (newAssertedIdentity) { + const response = await this.sipNativeLookup(newAssertedIdentity); + if (response.length) newNativeAssertedIdentity = response[0].userid; + } + console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`); + + if (newNativeAssertedIdentity) { + this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity; + + await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity); + + const newMappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); + console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`); + if (newMappedRoomId !== mappedRoomId) { + this.removeCallForRoom(mappedRoomId); + mappedRoomId = newMappedRoomId; + this.calls.set(mappedRoomId, call); + dis.dispatch({ + action: Action.CallChangeRoom, + call, + }); + } + } + }); } private async logCallStats(call: MatrixCall, mappedRoomId: string) { @@ -551,7 +599,7 @@ export default class CallHandler { } private setCallState(call: MatrixCall, status: CallState) { - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); console.log( `Call state in ${mappedRoomId} changed to ${status}`, @@ -772,7 +820,7 @@ export default class CallHandler { const call = payload.call as MatrixCall; - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); if (this.getCallForRoom(mappedRoomId)) { // ignore multiple incoming calls to the same room return; diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index 4f5613b4a8..e5bed2e812 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -57,7 +57,11 @@ export default class VoipUserMapper { if (!virtualRoom) return null; const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE); if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null; - return virtualRoomEvent.getContent()['native_room'] || null; + const nativeRoomID = virtualRoomEvent.getContent()['native_room']; + const nativeRoom = MatrixClientPeg.get().getRoom(nativeRoomID); + if (!nativeRoom || nativeRoom.getMyMembership() !== 'join') return null; + + return nativeRoomID; } public isVirtualRoom(room: Room): boolean { diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx index 29de068b0c..d31afddec9 100644 --- a/src/components/views/voip/CallPreview.tsx +++ b/src/components/views/voip/CallPreview.tsx @@ -27,6 +27,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Action } from '../../../dispatcher/actions'; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -142,6 +143,7 @@ export default class CallPreview extends React.Component { switch (payload.action) { // listen for call state changes to prod the render method, which // may hide the global CallView if the call it is tracking is dead + case Action.CallChangeRoom: case 'call_state': { const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls( CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId), diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 8a6ed75fee..6745713845 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -208,7 +208,7 @@ export default class CallView extends React.Component { }; private onExpandClick = () => { - const userFacingRoomId = CallHandler.roomIdForCall(this.props.call); + const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); dis.dispatch({ action: 'view_room', room_id: userFacingRoomId, @@ -337,7 +337,7 @@ export default class CallView extends React.Component { }; private onRoomAvatarClick = () => { - const userFacingRoomId = CallHandler.roomIdForCall(this.props.call); + const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); dis.dispatch({ action: 'view_room', room_id: userFacingRoomId, @@ -345,7 +345,7 @@ export default class CallView extends React.Component { } private onSecondaryRoomAvatarClick = () => { - const userFacingRoomId = CallHandler.roomIdForCall(this.props.secondaryCall); + const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall); dis.dispatch({ action: 'view_room', @@ -354,7 +354,7 @@ export default class CallView extends React.Component { } private onCallResumeClick = () => { - const userFacingRoomId = CallHandler.roomIdForCall(this.props.call); + const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId); } @@ -365,8 +365,8 @@ export default class CallView extends React.Component { public render() { const client = MatrixClientPeg.get(); - const callRoomId = CallHandler.roomIdForCall(this.props.call); - const secondaryCallRoomId = CallHandler.roomIdForCall(this.props.secondaryCall); + const callRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); + const secondaryCallRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall); const callRoom = client.getRoom(callRoomId); const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null; @@ -482,11 +482,13 @@ export default class CallView extends React.Component { const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold; let holdTransferContent; if (transfereeCall) { - const transferTargetRoom = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.props.call)); + const transferTargetRoom = MatrixClientPeg.get().getRoom( + CallHandler.sharedInstance().roomIdForCall(this.props.call), + ); const transferTargetName = transferTargetRoom ? transferTargetRoom.name : _t("unknown person"); const transfereeRoom = MatrixClientPeg.get().getRoom( - CallHandler.roomIdForCall(transfereeCall), + CallHandler.sharedInstance().roomIdForCall(transfereeCall), ); const transfereeName = transfereeRoom ? transfereeRoom.name : _t("unknown person"); diff --git a/src/components/views/voip/CallViewForRoom.tsx b/src/components/views/voip/CallViewForRoom.tsx index 878b6af20f..7540dbc8d9 100644 --- a/src/components/views/voip/CallViewForRoom.tsx +++ b/src/components/views/voip/CallViewForRoom.tsx @@ -22,6 +22,7 @@ import dis from '../../../dispatcher/dispatcher'; import {Resizable} from "re-resizable"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Action } from '../../../dispatcher/actions'; interface IProps { // What room we should display the call for @@ -62,6 +63,7 @@ export default class CallViewForRoom extends React.Component { private onAction = (payload) => { switch (payload.action) { + case Action.CallChangeRoom: case 'call_state': { const newCall = this.getCall(); if (newCall !== this.state.call) { diff --git a/src/components/views/voip/IncomingCallBox.tsx b/src/components/views/voip/IncomingCallBox.tsx index 0ca2a196c2..2abdc0641d 100644 --- a/src/components/views/voip/IncomingCallBox.tsx +++ b/src/components/views/voip/IncomingCallBox.tsx @@ -72,7 +72,7 @@ export default class IncomingCallBox extends React.Component { e.stopPropagation(); dis.dispatch({ action: 'answer', - room_id: CallHandler.roomIdForCall(this.state.incomingCall), + room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall), }); }; @@ -80,7 +80,7 @@ export default class IncomingCallBox extends React.Component { e.stopPropagation(); dis.dispatch({ action: 'reject', - room_id: CallHandler.roomIdForCall(this.state.incomingCall), + room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall), }); }; @@ -91,7 +91,7 @@ export default class IncomingCallBox extends React.Component { let room = null; if (this.state.incomingCall) { - room = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.state.incomingCall)); + room = MatrixClientPeg.get().getRoom(CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall)); } const caller = room ? room.name : _t("Unknown caller"); diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index cd32c3743f..46c962f160 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -114,6 +114,9 @@ export enum Action { */ VirtualRoomSupportUpdated = "virtual_room_support_updated", + // Probably would be better to have a VoIP states in a store and have the store emit changes + CallChangeRoom = "call_change_room", + /** * Fired when an upload has started. Should be used with UploadStartedPayload. */ From 10d056eb4170e917d12e7d7a6334b9b5f8a7a316 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 19 Apr 2021 20:34:48 +0100 Subject: [PATCH 05/43] unused import --- src/CallHandler.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 886c594c94..81172e2b46 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -87,7 +87,6 @@ import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring"; import SdkConfig from './SdkConfig'; -import DMRoomMap from './utils/DMRoomMap'; import { ensureDMExists, findDMForUser } from './createRoom'; export const PROTOCOL_PSTN = 'm.protocol.pstn'; From ee96201e33e15c3fdf2a659ef79944aa83fd60dd Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 19 Apr 2021 21:05:05 +0100 Subject: [PATCH 06/43] Comment room creation insanity --- src/CallHandler.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 81172e2b46..1686a671ed 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -532,6 +532,11 @@ export default class CallHandler { if (newNativeAssertedIdentity) { this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity; + // If we don't already have a room with this user, make one. This will be slightly odd + // if they called us because we'll be inviting them, but there's not much we can do about + // this if we want the actual, native room to exist (which we do). This is why it's + // important to only obey asserted identity in trusted environments, since anyone you're + // on a call with can cause you to send a room invite to someone. await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity); const newMappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); From f2d0a56c1ffaffc87f7dbb1c21f099e3845ef77b Mon Sep 17 00:00:00 2001 From: Jaiwanth Date: Thu, 22 Apr 2021 16:53:20 +0530 Subject: [PATCH 07/43] Handle encoded matrix urls Signed-off-by: Jaiwanth --- src/linkify-matrix.js | 2 +- src/utils/permalinks/Permalinks.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js index 1a40fde26f..84a131f23a 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -255,7 +255,7 @@ matrixLinkify.options = { target: function(href, type) { if (type === 'url') { const transformed = tryTransformPermalinkToLocalHref(href); - if (transformed !== href || href.match(matrixLinkify.ELEMENT_URL_PATTERN)) { + if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) { return null; } else { return '_blank'; diff --git a/src/utils/permalinks/Permalinks.js b/src/utils/permalinks/Permalinks.js index bcf4d87136..2db8b9f998 100644 --- a/src/utils/permalinks/Permalinks.js +++ b/src/utils/permalinks/Permalinks.js @@ -337,7 +337,7 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string { return permalink; } - const m = permalink.match(matrixLinkify.ELEMENT_URL_PATTERN); + const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN); if (m) { return m[1]; } @@ -402,8 +402,8 @@ function getPermalinkConstructor(): PermalinkConstructor { export function parsePermalink(fullUrl: string): PermalinkParts { const elementPrefix = SdkConfig.get()['permalinkPrefix']; - if (fullUrl.startsWith(matrixtoBaseUrl)) { - return new SpecPermalinkConstructor().parsePermalink(fullUrl); + if (decodeURIComponent(fullUrl).startsWith(matrixtoBaseUrl)) { + return new SpecPermalinkConstructor().parsePermalink(decodeURIComponent(fullUrl)); } else if (elementPrefix && fullUrl.startsWith(elementPrefix)) { return new ElementPermalinkConstructor(elementPrefix).parsePermalink(fullUrl); } From dc3d05bc889259d524ae801da4a71429719ff41c Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 23 Apr 2021 14:39:39 +0100 Subject: [PATCH 08/43] Test for asserted identity This is out first CallHandler test(!) Switches react-sdk to use createCall on the client object so we can stub this out in the test. Add a bunch more stubs to the test client. There's more stuff in this test that has scope to be used more widely, like waiting for a certain dispatch and mocking out rooms with particular sets of users in them: we could consider moving these out to test utils if we wanted. --- src/CallHandler.tsx | 7 +- src/utils/DMRoomMap.ts | 9 ++ test/CallHandler-test.ts | 214 +++++++++++++++++++++++++++++++++++++++ test/test-utils.js | 5 + 4 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 test/CallHandler-test.ts diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 1686a671ed..605e5a4a89 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -59,7 +59,6 @@ import {MatrixClientPeg} from './MatrixClientPeg'; import PlatformPeg from './PlatformPeg'; import Modal from './Modal'; import { _t } from './languageHandler'; -import { createNewMatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import WidgetEchoStore from './stores/WidgetEchoStore'; @@ -395,7 +394,7 @@ export default class CallHandler { // We don't allow placing more than one call per room, but that doesn't mean there // can't be more than one, eg. in a glare situation. This checks that the given call // is the call we consider 'the' call for its room. - const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); + const mappedRoomId = this.roomIdForCall(call); const callForThisRoom = this.getCallForRoom(mappedRoomId); return callForThisRoom && call.callId === callForThisRoom.callId; @@ -539,7 +538,7 @@ export default class CallHandler { // on a call with can cause you to send a room invite to someone. await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity); - const newMappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); + const newMappedRoomId = this.roomIdForCall(call); console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`); if (newMappedRoomId !== mappedRoomId) { this.removeCallForRoom(mappedRoomId); @@ -691,7 +690,7 @@ export default class CallHandler { const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now(); console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); - const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId); + const call = MatrixClientPeg.get().createCall(mappedRoomId); this.calls.set(roomId, call); if (transferee) { diff --git a/src/utils/DMRoomMap.ts b/src/utils/DMRoomMap.ts index e49b74c380..b166674043 100644 --- a/src/utils/DMRoomMap.ts +++ b/src/utils/DMRoomMap.ts @@ -55,6 +55,15 @@ export default class DMRoomMap { return DMRoomMap.sharedInstance; } + /** + * Set the shared instance to the instance supplied + * Used by tests + * @param inst the new shared instance + */ + public static setShared(inst: DMRoomMap) { + DMRoomMap.sharedInstance = inst; + } + /** * Returns a shared instance of the class * that uses the singleton matrix client diff --git a/test/CallHandler-test.ts b/test/CallHandler-test.ts new file mode 100644 index 0000000000..04dec8979f --- /dev/null +++ b/test/CallHandler-test.ts @@ -0,0 +1,214 @@ +/* +Copyright 2015-2021 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 './skinned-sdk'; + +import CallHandler, { PlaceCallType } from '../src/CallHandler'; +import { stubClient, mkStubRoom } from './test-utils'; +import { MatrixClientPeg } from '../src/MatrixClientPeg'; +import dis from '../src/dispatcher/dispatcher'; +import { CallEvent, CallState } from 'matrix-js-sdk/src/webrtc/call'; +import DMRoomMap from '../src/utils/DMRoomMap'; +import EventEmitter from 'events'; +import { Action } from '../src/dispatcher/actions'; +import SdkConfig from '../src/SdkConfig'; + +const REAL_ROOM_ID = '$room1:example.org'; +const MAPPED_ROOM_ID = '$room2:example.org'; +const MAPPED_ROOM_ID_2 = '$room3:example.org'; + +function mkStubDM(roomId, userId) { + const room = mkStubRoom(roomId); + room.getJoinedMembers = jest.fn().mockReturnValue([ + { + userId: '@me:example.org', + name: 'Member', + rawDisplayName: 'Member', + roomId: roomId, + membership: 'join', + getAvatarUrl: () => 'mxc://avatar.url/image.png', + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', + }, + { + userId: userId, + name: 'Member', + rawDisplayName: 'Member', + roomId: roomId, + membership: 'join', + getAvatarUrl: () => 'mxc://avatar.url/image.png', + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', + }, + ]); + room.currentState.getMembers = room.getJoinedMembers; + + return room; +} + +class FakeCall extends EventEmitter { + roomId: string; + callId = "fake call id"; + + constructor(roomId) { + super(); + + this.roomId = roomId; + } + + setRemoteOnHold() {} + setRemoteAudioElement() {} + + placeVoiceCall() { + this.emit(CallEvent.State, CallState.Connected, null); + } +} + +describe('CallHandler', () => { + let dmRoomMap; + let callHandler; + let audioElement; + let fakeCall; + + beforeEach(() => { + stubClient(); + MatrixClientPeg.get().createCall = roomId => { + if (fakeCall && fakeCall.roomId !== roomId) { + throw new Error("Only one call is supported!"); + } + fakeCall = new FakeCall(roomId); + return fakeCall; + }; + + callHandler = new CallHandler(); + callHandler.start(); + + dmRoomMap = { + getUserIdForRoomId: roomId => { + if (roomId === REAL_ROOM_ID) { + return '@user1:example.org'; + } else if (roomId === MAPPED_ROOM_ID) { + return '@user2:example.org'; + } else if (roomId === MAPPED_ROOM_ID_2) { + return '@user3:example.org'; + } else { + return null; + } + }, + getDMRoomsForUserId: userId => { + if (userId === '@user2:example.org') { + return [MAPPED_ROOM_ID]; + } else if (userId === '@user3:example.org') { + return [MAPPED_ROOM_ID_2]; + } else { + return []; + } + }, + }; + DMRoomMap.setShared(dmRoomMap); + + audioElement = document.createElement('audio'); + audioElement.id = "remoteAudio"; + document.body.appendChild(audioElement); + }); + + afterEach(() => { + callHandler.stop(); + DMRoomMap.setShared(null); + // @ts-ignore + window.mxCallHandler = null; + MatrixClientPeg.unset(); + + document.body.removeChild(audioElement); + SdkConfig.unset(); + }); + + it('should move calls between rooms when remote asserted identity changes', async () => { + const realRoom = mkStubDM(REAL_ROOM_ID, '@user1:example.org'); + const mappedRoom = mkStubDM(MAPPED_ROOM_ID, '@user2:example.org'); + const mappedRoom2 = mkStubDM(MAPPED_ROOM_ID_2, '@user3:example.org'); + + MatrixClientPeg.get().getRoom = roomId => { + switch (roomId) { + case REAL_ROOM_ID: + return realRoom; + case MAPPED_ROOM_ID: + return mappedRoom; + case MAPPED_ROOM_ID_2: + return mappedRoom2; + } + }; + + dis.dispatch({ + action: 'place_call', + type: PlaceCallType.Voice, + room_id: REAL_ROOM_ID, + }, true); + + let dispatchHandle; + // wait for the call to be set up + await new Promise(resolve => { + dispatchHandle = dis.register(payload => { + if (payload.action === 'call_state') { + resolve(); + } + }); + }); + dis.unregister(dispatchHandle); + + // should start off in the actual room ID it's in at the protocol level + expect(callHandler.getCallForRoom(REAL_ROOM_ID)).toBe(fakeCall); + + let callRoomChangeEventCount = 0; + const roomChangePromise = new Promise(resolve => { + dispatchHandle = dis.register(payload => { + if (payload.action === Action.CallChangeRoom) { + ++callRoomChangeEventCount; + resolve(); + } + }); + }); + + // Now emit an asserted identity for user2: this should be ignored + // because we haven't set the config option to obey asserted identity + fakeCall.getRemoteAssertedIdentity = jest.fn().mockReturnValue({ + id: "@user2:example.org", + }); + fakeCall.emit(CallEvent.AssertedIdentityChanged); + + // Now set the config option + SdkConfig.put({ + voipObeyAssertedIdentity: true, + }); + + // ...and send another asserted identity event for a different user + fakeCall.getRemoteAssertedIdentity = jest.fn().mockReturnValue({ + id: "@user3:example.org", + }); + fakeCall.emit(CallEvent.AssertedIdentityChanged); + + await roomChangePromise; + dis.unregister(dispatchHandle); + + // If everything's gone well, we should have seen only one room change + // event and the call should now be in user 3's room. + // If it's not obeying any, the call will still be in REAL_ROOM_ID. + // If it incorrectly obeyed both asserted identity changes, either it will + // have just processed one and the call will be in the wrong room, or we'll + // have seen two room change dispatches. + expect(callRoomChangeEventCount).toEqual(1); + expect(callHandler.getCallForRoom(REAL_ROOM_ID)).toBeNull(); + expect(callHandler.getCallForRoom(MAPPED_ROOM_ID_2)).toBe(fakeCall); + }); +}); diff --git a/test/test-utils.js b/test/test-utils.js index d259fcb95f..d9332580f7 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -64,6 +64,11 @@ export function createTestClient() { getRoomIdForAlias: jest.fn().mockResolvedValue(undefined), getRoomDirectoryVisibility: jest.fn().mockResolvedValue(undefined), getProfileInfo: jest.fn().mockResolvedValue({}), + getThirdpartyProtocols: jest.fn().mockResolvedValue({}), + getClientWellKnown: jest.fn().mockReturnValue(null), + supportsVoip: jest.fn().mockReturnValue(true), + getTurnServersExpiry: jest.fn().mockReturnValue(2^32), + getThirdpartyUser: jest.fn().mockResolvedValue([]), getAccountData: (type) => { return mkEvent({ type, From 8f1ea84eb66e4a3eb17fb55441ddb6ed328d5c8e Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Fri, 23 Apr 2021 17:11:45 +0100 Subject: [PATCH 09/43] Broadcast language change to ElectroPlatform --- src/BasePlatform.ts | 4 +++- .../views/settings/tabs/user/GeneralUserSettingsTab.js | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index b6012d7597..b7bc502dca 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -258,7 +258,9 @@ export default abstract class BasePlatform { return null; } - setLanguage(preferredLangs: string[]) {} + async setLanguage(language: string) { + throw new Error("Unimplemented"); + } setSpellCheckLanguages(preferredLangs: string[]) {} diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js index b1ad9f3d23..861e5cb1f8 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js @@ -192,7 +192,11 @@ export default class GeneralUserSettingsTab extends React.Component { SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage); this.setState({language: newLanguage}); - PlatformPeg.get().reload(); + const platform = PlatformPeg.get(); + if (platform) { + platform.reload(); + platform.setLanguage(newLanguage); + } }; _onSpellCheckLanguagesChange = (languages) => { From 9a11b346c97d0135cc8fd00a2383ed53c9674afd Mon Sep 17 00:00:00 2001 From: Ayush PS Date: Sat, 24 Apr 2021 18:09:34 +0530 Subject: [PATCH 10/43] To fix the Redaction error and a few improvements --- src/components/structures/MessagePanel.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 132d9ab4c3..3376e3988b 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -562,7 +562,7 @@ export default class MessagePanel extends React.Component { return ret; } - _getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) { + _getTilesForEvent(prevEvent, mxEv, last, isGrouped=false, nextEvent, nextEventWithTile) { const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary'); const EventTile = sdk.getComponent('rooms.EventTile'); const DateSeparator = sdk.getComponent('messages.DateSeparator'); @@ -582,7 +582,7 @@ export default class MessagePanel extends React.Component { // do we need a date separator since the last event? const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate); - if (wantsDateSeparator) { + if (wantsDateSeparator && !isGrouped) { const dateSeparator =
  • ; ret.push(dateSeparator); } @@ -966,7 +966,7 @@ class CreationGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - + const isGrouped=true; const panel = this.panel; const ret = []; const createEvent = this.createEvent; @@ -982,7 +982,7 @@ class CreationGrouper { // If this m.room.create event should be shown (room upgrade) then show it before the summary if (panel._shouldShowEvent(createEvent)) { // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered - ret.push(...panel._getTilesForEvent(createEvent, createEvent, false)); + ret.push(...panel._getTilesForEvent(createEvent, createEvent)); } for (const ejected of this.ejectedEvents) { @@ -1052,6 +1052,7 @@ class RedactionGrouper { this.lastShownEvent = lastShownEvent; this.nextEvent = nextEvent; this.nextEventTile = nextEventTile; + } shouldGroup(ev) { @@ -1081,7 +1082,7 @@ class RedactionGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - + const isGrouped=true; const panel = this.panel; const ret = []; const lastShownEvent = this.lastShownEvent; @@ -1098,10 +1099,11 @@ class RedactionGrouper { ); const senders = new Set(); + let eventTiles = this.events.map((e, i) => { senders.add(e.sender); const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; - return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile); + return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent,isGrouped, this.nextEvent, this.nextEventTile); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { @@ -1180,7 +1182,7 @@ class MemberGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); - + const isGrouped=true; const panel = this.panel; const lastShownEvent = this.lastShownEvent; const ret = []; @@ -1213,7 +1215,7 @@ class MemberGrouper { // of MemberEventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent); + return panel._getTilesForEvent(e, e, e === lastShownEvent,isGrouped); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { From e5f0119f66b4b1f0dff67392550c57877602f305 Mon Sep 17 00:00:00 2001 From: Aaron Raimist Date: Sun, 25 Apr 2021 23:18:09 -0500 Subject: [PATCH 11/43] Fix page up/page down scrolling only half a page Signed-off-by: Aaron Raimist --- src/components/structures/ScrollPanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 976734680c..1827eb6e6b 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -525,7 +525,7 @@ export default class ScrollPanel extends React.Component { */ scrollRelative = mult => { const scrollNode = this._getScrollNode(); - const delta = mult * scrollNode.clientHeight * 0.5; + const delta = mult * scrollNode.clientHeight; scrollNode.scrollBy(0, delta); this._saveScrollState(); }; From ac486c847e2acc10f3bd2fdfc952b6afe08de901 Mon Sep 17 00:00:00 2001 From: Aaron Raimist Date: Mon, 26 Apr 2021 01:10:00 -0500 Subject: [PATCH 12/43] Try 0.9 Signed-off-by: Aaron Raimist --- src/components/structures/ScrollPanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 1827eb6e6b..a014a6e4fe 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -525,7 +525,7 @@ export default class ScrollPanel extends React.Component { */ scrollRelative = mult => { const scrollNode = this._getScrollNode(); - const delta = mult * scrollNode.clientHeight; + const delta = mult * scrollNode.clientHeight * 0.9; scrollNode.scrollBy(0, delta); this._saveScrollState(); }; From 123482952a611ad9e560f6222b7d142d21e11024 Mon Sep 17 00:00:00 2001 From: Ayush PS Date: Mon, 26 Apr 2021 15:01:39 +0530 Subject: [PATCH 13/43] Fixed linting errors --- src/components/structures/MessagePanel.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 3376e3988b..adf1874ba0 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -966,7 +966,6 @@ class CreationGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - const isGrouped=true; const panel = this.panel; const ret = []; const createEvent = this.createEvent; @@ -1052,7 +1051,6 @@ class RedactionGrouper { this.lastShownEvent = lastShownEvent; this.nextEvent = nextEvent; this.nextEventTile = nextEventTile; - } shouldGroup(ev) { @@ -1099,11 +1097,11 @@ class RedactionGrouper { ); const senders = new Set(); - + let eventTiles = this.events.map((e, i) => { senders.add(e.sender); const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; - return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent,isGrouped, this.nextEvent, this.nextEventTile); + return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { @@ -1215,7 +1213,7 @@ class MemberGrouper { // of MemberEventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent,isGrouped); + return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { From 8656212eb93d2d25f8c74a3ec2e6227e8ae1fbae Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 26 Apr 2021 12:41:04 +0100 Subject: [PATCH 14/43] Space creation prompt user to add existing rooms instead of creating new ones --- res/css/structures/_SpaceRoomView.scss | 10 + .../dialogs/_AddExistingToSpaceDialog.scss | 118 +++++------ src/components/structures/SpaceRoomView.tsx | 71 ++++++- .../dialogs/AddExistingToSpaceDialog.tsx | 184 ++++++++++-------- src/i18n/strings/en_EN.json | 6 +- 5 files changed, 244 insertions(+), 145 deletions(-) diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 269f16beb7..553919d862 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -81,6 +81,16 @@ $SpaceRoomViewInnerWidth: 428px; color: $secondary-fg-color; margin-top: 12px; margin-bottom: 24px; + max-width: $SpaceRoomViewInnerWidth; + } + + .mx_AddExistingToSpace { + max-width: $SpaceRoomViewInnerWidth; + + .mx_AddExistingToSpace_content { + height: calc(100vh - 360px); + max-height: 400px; + } } .mx_SpaceRoomView_buttons { diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 247df52b4a..84f965e6bd 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -21,6 +21,66 @@ limitations under the License. } } +.mx_AddExistingToSpace { + .mx_SearchBox { + // To match the space around the title + margin: 0 0 15px 0; + flex-grow: 0; + } + + .mx_AddExistingToSpace_content { + flex-grow: 1; + } + + .mx_AddExistingToSpace_noResults { + display: block; + margin-top: 24px; + } + + .mx_AddExistingToSpace_section { + &:not(:first-child) { + margin-top: 24px; + } + + > h3 { + margin: 0; + color: $secondary-fg-color; + font-size: $font-12px; + font-weight: $font-semi-bold; + line-height: $font-15px; + } + + .mx_AddExistingToSpace_entry { + display: flex; + margin-top: 12px; + + .mx_BaseAvatar { + margin-right: 12px; + } + + .mx_AddExistingToSpace_entry_name { + font-size: $font-15px; + line-height: 30px; + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 12px; + } + + .mx_Checkbox { + align-items: center; + } + } + } + + .mx_AddExistingToSpace_section_spaces { + .mx_BaseAvatar_image { + border-radius: 8px; + } + } +} + .mx_AddExistingToSpaceDialog { width: 480px; color: $primary-fg-color; @@ -100,12 +160,6 @@ limitations under the License. } } - .mx_SearchBox { - // To match the space around the title - margin: 0 0 15px 0; - flex-grow: 0; - } - .mx_AddExistingToSpaceDialog_errorText { font-weight: $font-semi-bold; font-size: $font-12px; @@ -114,56 +168,8 @@ limitations under the License. margin-bottom: 28px; } - .mx_AddExistingToSpaceDialog_content { - flex-grow: 1; - - .mx_AddExistingToSpaceDialog_noResults { - display: block; - margin-top: 24px; - } - } - - .mx_AddExistingToSpaceDialog_section { - &:not(:first-child) { - margin-top: 24px; - } - - > h3 { - margin: 0; - color: $secondary-fg-color; - font-size: $font-12px; - font-weight: $font-semi-bold; - line-height: $font-15px; - } - - .mx_AddExistingToSpaceDialog_entry { - display: flex; - margin-top: 12px; - - .mx_BaseAvatar { - margin-right: 12px; - } - - .mx_AddExistingToSpaceDialog_entry_name { - font-size: $font-15px; - line-height: 30px; - flex-grow: 1; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - margin-right: 12px; - } - - .mx_Checkbox { - align-items: center; - } - } - } - - .mx_AddExistingToSpaceDialog_section_spaces { - .mx_BaseAvatar_image { - border-radius: 8px; - } + .mx_AddExistingToSpace { + display: contents; } .mx_AddExistingToSpaceDialog_footer { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 31358a3731..cdf9dc02d3 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -51,6 +51,9 @@ import MemberAvatar from "../views/avatars/MemberAvatar"; import {useStateToggle} from "../../hooks/useStateToggle"; import SpaceStore from "../../stores/SpaceStore"; import FacePile from "../views/elements/FacePile"; +import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog"; +import {allSettled} from "../../utils/promise"; +import {calculateRoomVia} from "../../utils/permalinks/Permalinks"; interface IProps { space: Room; @@ -354,7 +357,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { let buttonLabel = _t("Skip for now"); if (roomNames.some(name => name.trim())) { onClick = onNextClick; - buttonLabel = busy ? _t("Creating rooms...") : _t("Continue") + buttonLabel = busy ? _t("Creating rooms...") : _t("Continue"); } return
    @@ -376,6 +379,65 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
    ; }; +const SpaceAddExistingRooms = ({ space, onFinished }) => { + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + let onClick = onFinished; + let buttonLabel = _t("Skip for now"); + if (selectedToAdd.size > 0) { + onClick = async () => { + // TODO rate limiting + setBusy(true); + try { + await allSettled(Array.from(selectedToAdd).map((room) => + SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room)))); + onFinished(true); + } catch (e) { + console.error("Failed to add rooms to space", e); + setError(_t("Failed to add rooms to space")); + } + setBusy(false); + }; + buttonLabel = busy ? _t("Adding...") : _t("Add"); + } + + return
    +

    { _t("What do you want to organise?") }

    +
    + { _t("Pick rooms or conversations to add. This is just a space for you, " + + "no one will be informed. You can add more later.") } +
    + + { error &&
    { error }
    } + + { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + }} + /> + +
    + + { buttonLabel } + +
    +
    ; +}; + const SpaceSetupPublicShare = ({ space, onFinished }) => { return

    { _t("Share %(name)s", { name: space.name }) }

    @@ -659,7 +721,7 @@ export default class SpaceRoomView extends React.PureComponent { return { - this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms }); + this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms }); }} />; case Phase.PrivateInvite: @@ -675,6 +737,11 @@ export default class SpaceRoomView extends React.PureComponent { "You can add more later too, including already existing ones.")} onFinished={() => this.setState({ phase: Phase.Landing })} />; + case Phase.PrivateExistingRooms: + return this.setState({ phase: Phase.Landing })} + />; } } diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 0f58a624f3..e65a709eb0 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useState} from "react"; +import React, {useContext, useState} from "react"; import classNames from "classnames"; import {Room} from "matrix-js-sdk/src/models/room"; import {MatrixClient} from "matrix-js-sdk/src/client"; @@ -33,6 +33,7 @@ import {allSettled} from "../../../utils/promise"; import DMRoomMap from "../../../utils/DMRoomMap"; import {calculateRoomVia} from "../../../utils/permalinks/Permalinks"; import StyledCheckbox from "../elements/StyledCheckbox"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -41,31 +42,35 @@ interface IProps extends IDialogProps { } const Entry = ({ room, checked, onChange }) => { - return
    + return
    - { room.name } + { room.name } onChange(e.target.checked)} checked={checked} />
    ; }; -const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { +interface IAddExistingToSpaceProps { + space: Room; + selected: Set; + onChange(checked: boolean, room: Room): void; +} + +export const AddExistingToSpace: React.FC = ({ space, selected, onChange }) => { + const cli = useContext(MatrixClientContext); const [query, setQuery] = useState(""); const lcQuery = query.toLowerCase(); - const [selectedSpace, setSelectedSpace] = useState(space); - const [selectedToAdd, setSelectedToAdd] = useState(new Set()); - const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); const existingSubspacesSet = new Set(existingSubspaces); const existingRoomsSet = new Set(SpaceStore.instance.getChildRooms(space.roomId)); - const joinRule = selectedSpace.getJoinRule(); + const joinRule = space.getJoinRule(); const [spaces, rooms, dms] = cli.getVisibleRooms().reduce((arr, room) => { if (room.getMyMembership() !== "join") return arr; if (!room.name.toLowerCase().includes(lcQuery)) return arr; if (room.isSpaceRoom()) { - if (room !== space && room !== selectedSpace && !existingSubspacesSet.has(room)) { + if (room !== space && !existingSubspacesSet.has(room)) { arr[0].push(room); } } else if (!existingRoomsSet.has(room) && joinRule !== "public") { @@ -75,11 +80,79 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, return arr; }, [[], [], []]); + return
    + + + { rooms.length > 0 ? ( +
    +

    { _t("Rooms") }

    + { rooms.map(room => { + return { + onChange(checked, room); + }} + />; + }) } +
    + ) : undefined } + + { spaces.length > 0 ? ( +
    +

    { _t("Spaces") }

    + { spaces.map(space => { + return { + onChange(checked, space); + }} + />; + }) } +
    + ) : null } + + { dms.length > 0 ? ( +
    +

    { _t("Direct Messages") }

    + { dms.map(room => { + return { + onChange(checked, room); + }} + />; + }) } +
    + ) : null } + + { spaces.length + rooms.length + dms.length < 1 ? + { _t("No results") } + : undefined } +
    +
    ; +}; + +const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { + const [selectedSpace, setSelectedSpace] = useState(space); + const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); + const [busy, setBusy] = useState(false); const [error, setError] = useState(""); let spaceOptionSection; - if (existingSubspacesSet.size > 0) { + if (existingSubspaces.length > 0) { const options = [space, ...existingSubspaces].map((space) => { const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", { mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace, @@ -119,86 +192,26 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, return { error &&
    { error }
    } - - - { rooms.length > 0 ? ( -
    -

    { _t("Rooms") }

    - { rooms.map(room => { - return { - if (checked) { - selectedToAdd.add(room); - } else { - selectedToAdd.delete(room); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} - />; - }) } -
    - ) : undefined } - - { spaces.length > 0 ? ( -
    -

    { _t("Spaces") }

    - { spaces.map(space => { - return { - if (checked) { - selectedToAdd.add(space); - } else { - selectedToAdd.delete(space); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} - />; - }) } -
    - ) : null } - - { dms.length > 0 ? ( -
    -

    { _t("Direct Messages") }

    - { dms.map(space => { - return { - if (checked) { - selectedToAdd.add(space); - } else { - selectedToAdd.delete(space); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} - />; - }) } -
    - ) : null } - - { spaces.length + rooms.length + dms.length < 1 ? - { _t("No results") } - : undefined } -
    + + { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + }} + /> +
    @@ -212,6 +225,7 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, kind="primary" disabled={busy || selectedToAdd.size < 1} onClick={async () => { + // TODO rate limiting setBusy(true); try { await allSettled(Array.from(selectedToAdd).map((room) => diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f1b700540f..6c9d22cdeb 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2016,11 +2016,11 @@ "Add a new server...": "Add a new server...", "%(networkName)s rooms": "%(networkName)s rooms", "Matrix rooms": "Matrix rooms", - "Space selection": "Space selection", - "Add existing rooms": "Add existing rooms", "Filter your rooms and spaces": "Filter your rooms and spaces", "Spaces": "Spaces", "Direct Messages": "Direct Messages", + "Space selection": "Space selection", + "Add existing rooms": "Add existing rooms", "Don't want to add an existing room?": "Don't want to add an existing room?", "Create a new room": "Create a new room", "Failed to add rooms to space": "Failed to add rooms to space", @@ -2665,6 +2665,8 @@ "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.", "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", From a43ad8d8810ebf7bb5250092d3943e0631761747 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Mon, 26 Apr 2021 13:55:14 +0100 Subject: [PATCH 15/43] Allow for multiple locale and stabilise set language call --- src/BasePlatform.ts | 2 +- .../views/settings/tabs/user/GeneralUserSettingsTab.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index b7bc502dca..b10513b46b 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -258,7 +258,7 @@ export default abstract class BasePlatform { return null; } - async setLanguage(language: string) { + async setLanguage(preferredLangs: string[]) { throw new Error("Unimplemented"); } diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js index 861e5cb1f8..a216aeccbe 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js @@ -194,8 +194,8 @@ export default class GeneralUserSettingsTab extends React.Component { this.setState({language: newLanguage}); const platform = PlatformPeg.get(); if (platform) { - platform.reload(); platform.setLanguage(newLanguage); + platform.reload(); } }; From 4123406785d7171e627b886fc1a77fe206b3de85 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Mon, 26 Apr 2021 16:55:12 +0100 Subject: [PATCH 16/43] Move i18n utils to its own module --- package.json | 11 +- scripts/compare-file.js | 10 -- scripts/gen-i18n.js | 304 ---------------------------------------- scripts/prune-i18n.js | 68 --------- yarn.lock | 92 ++++++++++++ 5 files changed, 97 insertions(+), 388 deletions(-) delete mode 100644 scripts/compare-file.js delete mode 100755 scripts/gen-i18n.js delete mode 100755 scripts/prune-i18n.js diff --git a/package.json b/package.json index 7c190c68bf..a357678eaa 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,7 @@ "package.json" ], "bin": { - "reskindex": "scripts/reskindex.js", - "matrix-gen-i18n": "scripts/gen-i18n.js", - "matrix-prune-i18n": "scripts/prune-i18n.js" + "reskindex": "scripts/reskindex.js" }, "main": "./src/index.js", "matrix_src_main": "./src/index.js", @@ -33,9 +31,9 @@ "matrix_lib_typings": "./lib/index.d.ts", "scripts": { "prepublishOnly": "yarn build", - "i18n": "matrix-gen-i18n", - "prunei18n": "matrix-prune-i18n", - "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", + "i18n": "gen-i18n", + "prunei18n": "prune-i18n", + "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && gen-i18n && compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "reskindex": "node scripts/reskindex.js -h header", "reskindex:watch": "node scripts/reskindex.js -h header -w", "rethemendex": "res/css/rethemendex.sh", @@ -160,6 +158,7 @@ "jest-fetch-mock": "^3.0.3", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.2", + "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "react-test-renderer": "^16.14.0", "rimraf": "^3.0.2", diff --git a/scripts/compare-file.js b/scripts/compare-file.js deleted file mode 100644 index f53275ebfa..0000000000 --- a/scripts/compare-file.js +++ /dev/null @@ -1,10 +0,0 @@ -const fs = require("fs"); - -if (process.argv.length < 4) throw new Error("Missing source and target file arguments"); - -const sourceFile = fs.readFileSync(process.argv[2], 'utf8'); -const targetFile = fs.readFileSync(process.argv[3], 'utf8'); - -if (sourceFile !== targetFile) { - throw new Error("Files do not match"); -} diff --git a/scripts/gen-i18n.js b/scripts/gen-i18n.js deleted file mode 100755 index 91733469f7..0000000000 --- a/scripts/gen-i18n.js +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env node - -/* -Copyright 2017 New Vector Ltd - -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. -*/ - -/** - * Regenerates the translations en_EN file by walking the source tree and - * parsing each file with the appropriate parser. Emits a JSON file with the - * translatable strings mapped to themselves in the order they appeared - * in the files and grouped by the file they appeared in. - * - * Usage: node scripts/gen-i18n.js - */ -const fs = require('fs'); -const path = require('path'); - -const walk = require('walk'); - -const parser = require("@babel/parser"); -const traverse = require("@babel/traverse"); - -const TRANSLATIONS_FUNCS = ['_t', '_td']; - -const INPUT_TRANSLATIONS_FILE = 'src/i18n/strings/en_EN.json'; -const OUTPUT_FILE = 'src/i18n/strings/en_EN.json'; - -// NB. The sync version of walk is broken for single files so we walk -// all of res rather than just res/home.html. -// https://git.daplie.com/Daplie/node-walk/merge_requests/1 fixes it, -// or if we get bored waiting for it to be merged, we could switch -// to a project that's actively maintained. -const SEARCH_PATHS = ['src', 'res']; - -function getObjectValue(obj, key) { - for (const prop of obj.properties) { - if (prop.key.type === 'Identifier' && prop.key.name === key) { - return prop.value; - } - } - return null; -} - -function getTKey(arg) { - if (arg.type === 'Literal' || arg.type === "StringLiteral") { - return arg.value; - } else if (arg.type === 'BinaryExpression' && arg.operator === '+') { - return getTKey(arg.left) + getTKey(arg.right); - } else if (arg.type === 'TemplateLiteral') { - return arg.quasis.map((q) => { - return q.value.raw; - }).join(''); - } - return null; -} - -function getFormatStrings(str) { - // Match anything that starts with % - // We could make a regex that matched the full placeholder, but this - // would just not match invalid placeholders and so wouldn't help us - // detect the invalid ones. - // Also note that for simplicity, this just matches a % character and then - // anything up to the next % character (or a single %, or end of string). - const formatStringRe = /%([^%]+|%|$)/g; - const formatStrings = new Set(); - - let match; - while ( (match = formatStringRe.exec(str)) !== null ) { - const placeholder = match[1]; // Minus the leading '%' - if (placeholder === '%') continue; // Literal % is %% - - const placeholderMatch = placeholder.match(/^\((.*?)\)(.)/); - if (placeholderMatch === null) { - throw new Error("Invalid format specifier: '"+match[0]+"'"); - } - if (placeholderMatch.length < 3) { - throw new Error("Malformed format specifier"); - } - const placeholderName = placeholderMatch[1]; - const placeholderFormat = placeholderMatch[2]; - - if (placeholderFormat !== 's') { - throw new Error(`'${placeholderFormat}' used as format character: you probably meant 's'`); - } - - formatStrings.add(placeholderName); - } - - return formatStrings; -} - -function getTranslationsJs(file) { - const contents = fs.readFileSync(file, { encoding: 'utf8' }); - - const trs = new Set(); - - try { - const plugins = [ - // https://babeljs.io/docs/en/babel-parser#plugins - "classProperties", - "objectRestSpread", - "throwExpressions", - "exportDefaultFrom", - "decorators-legacy", - ]; - - if (file.endsWith(".js") || file.endsWith(".jsx")) { - // all JS is assumed to be flow or react - plugins.push("flow", "jsx"); - } else if (file.endsWith(".ts")) { - // TS can't use JSX unless it's a TSX file (otherwise angle casts fail) - plugins.push("typescript"); - } else if (file.endsWith(".tsx")) { - // When the file is a TSX file though, enable JSX parsing - plugins.push("typescript", "jsx"); - } - - const babelParsed = parser.parse(contents, { - allowImportExportEverywhere: true, - errorRecovery: true, - sourceFilename: file, - tokens: true, - plugins, - }); - traverse.default(babelParsed, { - enter: (p) => { - const node = p.node; - if (p.isCallExpression() && node.callee && TRANSLATIONS_FUNCS.includes(node.callee.name)) { - const tKey = getTKey(node.arguments[0]); - - // This happens whenever we call _t with non-literals (ie. whenever we've - // had to use a _td to compensate) so is expected. - if (tKey === null) return; - - // check the format string against the args - // We only check _t: _td has no args - if (node.callee.name === '_t') { - try { - const placeholders = getFormatStrings(tKey); - for (const placeholder of placeholders) { - if (node.arguments.length < 2) { - throw new Error(`Placeholder found ('${placeholder}') but no substitutions given`); - } - const value = getObjectValue(node.arguments[1], placeholder); - if (value === null) { - throw new Error(`No value found for placeholder '${placeholder}'`); - } - } - - // Validate tag replacements - if (node.arguments.length > 2) { - const tagMap = node.arguments[2]; - for (const prop of tagMap.properties || []) { - if (prop.key.type === 'Literal') { - const tag = prop.key.value; - // RegExp same as in src/languageHandler.js - const regexp = new RegExp(`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`); - if (!tKey.match(regexp)) { - throw new Error(`No match for ${regexp} in ${tKey}`); - } - } - } - } - - } catch (e) { - console.log(); - console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`); - console.error(e); - process.exit(1); - } - } - - let isPlural = false; - if (node.arguments.length > 1 && node.arguments[1].type === 'ObjectExpression') { - const countVal = getObjectValue(node.arguments[1], 'count'); - if (countVal) { - isPlural = true; - } - } - - if (isPlural) { - trs.add(tKey + "|other"); - const plurals = enPlurals[tKey]; - if (plurals) { - for (const pluralType of Object.keys(plurals)) { - trs.add(tKey + "|" + pluralType); - } - } - } else { - trs.add(tKey); - } - } - }, - }); - } catch (e) { - console.error(e); - process.exit(1); - } - - return trs; -} - -function getTranslationsOther(file) { - const contents = fs.readFileSync(file, { encoding: 'utf8' }); - - const trs = new Set(); - - // Taken from element-web src/components/structures/HomePage.js - const translationsRegex = /_t\(['"]([\s\S]*?)['"]\)/mg; - let matches; - while (matches = translationsRegex.exec(contents)) { - trs.add(matches[1]); - } - return trs; -} - -// gather en_EN plural strings from the input translations file: -// the en_EN strings are all in the source with the exception of -// pluralised strings, which we need to pull in from elsewhere. -const inputTranslationsRaw = JSON.parse(fs.readFileSync(INPUT_TRANSLATIONS_FILE, { encoding: 'utf8' })); -const enPlurals = {}; - -for (const key of Object.keys(inputTranslationsRaw)) { - const parts = key.split("|"); - if (parts.length > 1) { - const plurals = enPlurals[parts[0]] || {}; - plurals[parts[1]] = inputTranslationsRaw[key]; - enPlurals[parts[0]] = plurals; - } -} - -const translatables = new Set(); - -const walkOpts = { - listeners: { - names: function(root, nodeNamesArray) { - // Sort the names case insensitively and alphabetically to - // maintain some sense of order between the different strings. - nodeNamesArray.sort((a, b) => { - a = a.toLowerCase(); - b = b.toLowerCase(); - if (a > b) return 1; - if (a < b) return -1; - return 0; - }); - }, - file: function(root, fileStats, next) { - const fullPath = path.join(root, fileStats.name); - - let trs; - if (fileStats.name.endsWith('.js') || fileStats.name.endsWith('.ts') || fileStats.name.endsWith('.tsx')) { - trs = getTranslationsJs(fullPath); - } else if (fileStats.name.endsWith('.html')) { - trs = getTranslationsOther(fullPath); - } else { - return; - } - console.log(`${fullPath} (${trs.size} strings)`); - for (const tr of trs.values()) { - // Convert DOS line endings to unix - translatables.add(tr.replace(/\r\n/g, "\n")); - } - }, - } -}; - -for (const path of SEARCH_PATHS) { - if (fs.existsSync(path)) { - walk.walkSync(path, walkOpts); - } -} - -const trObj = {}; -for (const tr of translatables) { - if (tr.includes("|")) { - if (inputTranslationsRaw[tr]) { - trObj[tr] = inputTranslationsRaw[tr]; - } else { - trObj[tr] = tr.split("|")[0]; - } - } else { - trObj[tr] = tr; - } -} - -fs.writeFileSync( - OUTPUT_FILE, - JSON.stringify(trObj, translatables.values(), 4) + "\n" -); - -console.log(); -console.log(`Wrote ${translatables.size} strings to ${OUTPUT_FILE}`); diff --git a/scripts/prune-i18n.js b/scripts/prune-i18n.js deleted file mode 100755 index b4fe8d69f5..0000000000 --- a/scripts/prune-i18n.js +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node - -/* -Copyright 2017 New Vector Ltd - -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. -*/ - -/* - * Looks through all the translation files and removes any strings - * which don't appear in en_EN.json. - * Use this if you remove a translation, but merge any outstanding changes - * from weblate first or you'll need to resolve the conflict in weblate. - */ - -const fs = require('fs'); -const path = require('path'); - -const I18NDIR = 'src/i18n/strings'; - -const enStringsRaw = JSON.parse(fs.readFileSync(path.join(I18NDIR, 'en_EN.json'))); - -const enStrings = new Set(); -for (const str of Object.keys(enStringsRaw)) { - const parts = str.split('|'); - if (parts.length > 1) { - enStrings.add(parts[0]); - } else { - enStrings.add(str); - } -} - -for (const filename of fs.readdirSync(I18NDIR)) { - if (filename === 'en_EN.json') continue; - if (filename === 'basefile.json') continue; - if (!filename.endsWith('.json')) continue; - - const trs = JSON.parse(fs.readFileSync(path.join(I18NDIR, filename))); - const oldLen = Object.keys(trs).length; - for (const tr of Object.keys(trs)) { - const parts = tr.split('|'); - const trKey = parts.length > 1 ? parts[0] : tr; - if (!enStrings.has(trKey)) { - delete trs[tr]; - } - } - - const removed = oldLen - Object.keys(trs).length; - if (removed > 0) { - console.log(`${filename}: removed ${removed} translations`); - // XXX: This is totally relying on the impl serialising the JSON object in the - // same order as they were parsed from the file. JSON.stringify() has a specific argument - // that can be used to control the order, but JSON.parse() lacks any kind of equivalent. - // Empirically this does maintain the order on my system, so I'm going to leave it like - // this for now. - fs.writeFileSync(path.join(I18NDIR, filename), JSON.stringify(trs, undefined, 4) + "\n"); - } -} diff --git a/yarn.lock b/yarn.lock index 66329cfa89..f3f58fd8b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26,6 +26,13 @@ dependencies: "@babel/highlight" "^7.10.4" +"@babel/code-frame@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" + integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== + dependencies: + "@babel/highlight" "^7.12.13" + "@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" @@ -61,6 +68,15 @@ jsesc "^2.5.1" source-map "^0.5.0" +"@babel/generator@^7.13.16": + version "7.13.16" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.13.16.tgz#0befc287031a201d84cdfc173b46b320ae472d14" + integrity sha512-grBBR75UnKOcUWMp8WoDxNsWCFl//XCK6HWTrBQKTr5SV9f5g0pNOjdyzi/DTBv12S9GnYPInIXQBTky7OXEMg== + dependencies: + "@babel/types" "^7.13.16" + jsesc "^2.5.1" + source-map "^0.5.0" + "@babel/helper-annotate-as-pure@^7.10.4", "@babel/helper-annotate-as-pure@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.10.tgz#54ab9b000e60a93644ce17b3f37d313aaf1d115d" @@ -130,6 +146,15 @@ "@babel/template" "^7.12.7" "@babel/types" "^7.12.11" +"@babel/helper-function-name@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz#93ad656db3c3c2232559fd7b2c3dbdcbe0eb377a" + integrity sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA== + dependencies: + "@babel/helper-get-function-arity" "^7.12.13" + "@babel/template" "^7.12.13" + "@babel/types" "^7.12.13" + "@babel/helper-get-function-arity@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf" @@ -137,6 +162,13 @@ dependencies: "@babel/types" "^7.12.10" +"@babel/helper-get-function-arity@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz#bc63451d403a3b3082b97e1d8b3fe5bd4091e583" + integrity sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg== + dependencies: + "@babel/types" "^7.12.13" + "@babel/helper-hoist-variables@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e" @@ -225,6 +257,13 @@ dependencies: "@babel/types" "^7.12.11" +"@babel/helper-split-export-declaration@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz#e9430be00baf3e88b0e13e6f9d4eaf2136372b05" + integrity sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg== + dependencies: + "@babel/types" "^7.12.13" + "@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" @@ -263,11 +302,25 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/highlight@^7.12.13": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1" + integrity sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg== + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + chalk "^2.0.0" + js-tokens "^4.0.0" + "@babel/parser@^7.1.0", "@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.7.0": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79" integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg== +"@babel/parser@^7.12.13", "@babel/parser@^7.13.16": + version "7.13.16" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.16.tgz#0f18179b0448e6939b1f3f5c4c355a3a9bcdfd37" + integrity sha512-6bAg36mCwuqLO0hbR+z7PHuqWiCeP7Dzg73OpQwsAB1Eb8HnGEz5xYBzCfbu+YjoaJsJs+qheDxVAuqbt3ILEw== + "@babel/plugin-proposal-async-generator-functions@^7.12.1": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.12.tgz#04b8f24fd4532008ab4e79f788468fd5a8476566" @@ -980,6 +1033,15 @@ "@babel/parser" "^7.12.7" "@babel/types" "^7.12.7" +"@babel/template@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" + integrity sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA== + dependencies: + "@babel/code-frame" "^7.12.13" + "@babel/parser" "^7.12.13" + "@babel/types" "^7.12.13" + "@babel/traverse@^7.1.0", "@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.12", "@babel/traverse@^7.12.5", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.4": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376" @@ -995,6 +1057,20 @@ globals "^11.1.0" lodash "^4.17.19" +"@babel/traverse@^7.13.17": + version "7.13.17" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.17.tgz#c85415e0c7d50ac053d758baec98b28b2ecfeea3" + integrity sha512-BMnZn0R+X6ayqm3C3To7o1j7Q020gWdqdyP50KEoVqaCO2c/Im7sYZSmVgvefp8TTMQ+9CtwuBp0Z1CZ8V3Pvg== + dependencies: + "@babel/code-frame" "^7.12.13" + "@babel/generator" "^7.13.16" + "@babel/helper-function-name" "^7.12.13" + "@babel/helper-split-export-declaration" "^7.12.13" + "@babel/parser" "^7.13.16" + "@babel/types" "^7.13.17" + debug "^4.1.0" + globals "^11.1.0" + "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.7", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299" @@ -1004,6 +1080,14 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" +"@babel/types@^7.12.13", "@babel/types@^7.13.16", "@babel/types@^7.13.17": + version "7.13.17" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.17.tgz#48010a115c9fba7588b4437dd68c9469012b38b4" + integrity sha512-RawydLgxbOPDlTLJNtoIypwdmAy//uQIzlKt2+iBiJaRlVuI6QLUxVAyWGNfOzp8Yu4L4lLIacoCyTNtpb4wiA== + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -5614,6 +5698,14 @@ matrix-react-test-utils@^0.2.2: resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853" integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ== +"matrix-web-i18n@github:matrix-org/matrix-web-i18n": + version "1.1.1" + resolved "https://codeload.github.com/matrix-org/matrix-web-i18n/tar.gz/68ea0c57b6c74c40df6419eb5ac0fa8945ff8a75" + dependencies: + "@babel/parser" "^7.13.16" + "@babel/traverse" "^7.13.17" + walk "^2.3.14" + matrix-widget-api@^0.1.0-beta.13: version "0.1.0-beta.13" resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.13.tgz#ebddc83eaef39bbb87b621a02a35902e1a29b9ef" From c08fe6aa22bdf6aa3d60599e978b942933f8ecc8 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Tue, 27 Apr 2021 15:09:26 +0100 Subject: [PATCH 17/43] Upgrade matrix-web-i18n to use matrix prefixed binaries --- package.json | 6 +++--- yarn.lock | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a357678eaa..c09046bbb8 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,9 @@ "matrix_lib_typings": "./lib/index.d.ts", "scripts": { "prepublishOnly": "yarn build", - "i18n": "gen-i18n", - "prunei18n": "prune-i18n", - "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && gen-i18n && compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", + "i18n": "matrix-gen-i18n", + "prunei18n": "matrix-prune-i18n", + "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && matrix-gen-i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "reskindex": "node scripts/reskindex.js -h header", "reskindex:watch": "node scripts/reskindex.js -h header -w", "rethemendex": "res/css/rethemendex.sh", diff --git a/yarn.lock b/yarn.lock index f3f58fd8b2..a451714904 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5699,8 +5699,8 @@ matrix-react-test-utils@^0.2.2: integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ== "matrix-web-i18n@github:matrix-org/matrix-web-i18n": - version "1.1.1" - resolved "https://codeload.github.com/matrix-org/matrix-web-i18n/tar.gz/68ea0c57b6c74c40df6419eb5ac0fa8945ff8a75" + version "1.1.2" + resolved "https://codeload.github.com/matrix-org/matrix-web-i18n/tar.gz/63f9119bc0bc304e83d4e8e22364caa7850e7671" dependencies: "@babel/parser" "^7.13.16" "@babel/traverse" "^7.13.17" From 2e44d7e17f6050bd60413e17fac189551cb6ba76 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Tue, 27 Apr 2021 15:11:49 +0100 Subject: [PATCH 18/43] Do not throw on setLanguage --- src/BasePlatform.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index b10513b46b..5483ea6874 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -258,9 +258,7 @@ export default abstract class BasePlatform { return null; } - async setLanguage(preferredLangs: string[]) { - throw new Error("Unimplemented"); - } + async setLanguage(preferredLangs: string[]) {} setSpellCheckLanguages(preferredLangs: string[]) {} From b9bd83ad41f4b63b539e027413dce94a706be647 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 27 Apr 2021 08:58:18 -0600 Subject: [PATCH 19/43] Handle possible edge case with getting stuck in "unsent messages" bar Just in case we're not cleaning up the isResending state properly, here's a catch all. Unrelated to https://github.com/vector-im/element-web/issues/17078 (this code doesn't affect the js-sdk error the author is seeing) --- src/components/structures/RoomStatusBar.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index ab4f524faf..38e3cd97e8 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -128,7 +128,11 @@ export default class RoomStatusBar extends React.Component { _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { if (room.roomId !== this.props.room.roomId) return; - this.setState({unsentMessages: getUnsentMessages(this.props.room)}); + const messages = getUnsentMessages(this.props.room); + this.setState({ + unsentMessages: messages, + isResending: messages.length > 0 && this.state.isResending, + }); }; // Check whether current size is greater than 0, if yes call props.onVisible From 35799c213e1e9d03bf33b1b484045bddd113f7ce Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 27 Apr 2021 16:30:54 +0100 Subject: [PATCH 20/43] Fix suggested rooms not showing up regression from room list optimisation --- src/components/views/rooms/RoomSublist.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index a726ab99fc..fb367349a2 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -763,7 +763,9 @@ export default class RoomSublist extends React.Component { 'mx_RoomSublist': true, 'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition, 'mx_RoomSublist_minimized': this.props.isMinimized, - 'mx_RoomSublist_hidden': !this.state.rooms.length && this.props.alwaysVisible !== true, + 'mx_RoomSublist_hidden': ( + !this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true + ), }); let content = null; From b6762c68afd8cec1e06c9544d046e9126c8bf6b4 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 27 Apr 2021 18:55:53 +0100 Subject: [PATCH 21/43] typo Co-authored-by: J. Ryan Stinnett --- src/CallHandler.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 605e5a4a89..f2a2e71854 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -168,7 +168,7 @@ export default class CallHandler { private invitedRoomsAreVirtual = new Map(); private invitedRoomCheckInProgress = false; - // Map of the asserted identiy users after we've looked them up using the API. + // Map of the asserted identity users after we've looked them up using the API. // We need to be be able to determine the mapped room synchronously, so we // do the async lookup when we get new information and then store these mappings here private assertedIdentityNativeUsers = new Map(); From 705505fe85a85e2a14f725d5301ea3c78f19cfbb Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 27 Apr 2021 18:56:22 +0100 Subject: [PATCH 22/43] make copyright not lie --- test/CallHandler-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/CallHandler-test.ts b/test/CallHandler-test.ts index 04dec8979f..cb801b4936 100644 --- a/test/CallHandler-test.ts +++ b/test/CallHandler-test.ts @@ -1,5 +1,5 @@ /* -Copyright 2015-2021 The Matrix.org Foundation C.I.C. +Copyright 2021 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. From be7d4d020bd9713a92e17bf268fc0e382815763a Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 27 Apr 2021 19:33:53 +0100 Subject: [PATCH 23/43] Put asserted identity option under a 'voip' section --- src/CallHandler.tsx | 4 +++- test/CallHandler-test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index f2a2e71854..16bc837aa2 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -188,7 +188,9 @@ export default class CallHandler { public roomIdForCall(call: MatrixCall): string { if (!call) return null; - if (SdkConfig.get()['voipObeyAssertedIdentity']) { + const voipConfig = SdkConfig.get()['voip']; + + if (voipConfig && voipConfig.obeyAssertedIdentity) { const nativeUser = this.assertedIdentityNativeUsers[call.callId]; if (nativeUser) { const room = findDMForUser(MatrixClientPeg.get(), nativeUser); diff --git a/test/CallHandler-test.ts b/test/CallHandler-test.ts index cb801b4936..754610b223 100644 --- a/test/CallHandler-test.ts +++ b/test/CallHandler-test.ts @@ -189,7 +189,9 @@ describe('CallHandler', () => { // Now set the config option SdkConfig.put({ - voipObeyAssertedIdentity: true, + voip: { + obeyAssertedIdentity: true, + }, }); // ...and send another asserted identity event for a different user From 32e3ce3dea7c86b1098ac07bb5c9e430f847f34d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 20 Apr 2021 21:30:21 -0600 Subject: [PATCH 24/43] Handle basic state machine of recordings --- .../views/rooms/_VoiceRecordComposerTile.scss | 7 +- .../views/rooms/VoiceRecordComposerTile.tsx | 160 +++++++++++------- .../IRecordingWaveformStateProps.ts | 26 +++ .../voice_messages/LiveRecordingWaveform.tsx | 13 +- .../views/voice_messages/PlaybackWaveform.tsx | 43 +++++ src/i18n/strings/en_EN.json | 2 +- src/utils/arrays.ts | 20 +++ src/voice/VoiceRecording.ts | 11 ++ test/utils/arrays-test.ts | 33 ++++ 9 files changed, 241 insertions(+), 74 deletions(-) create mode 100644 src/components/views/voice_messages/IRecordingWaveformStateProps.ts create mode 100644 src/components/views/voice_messages/PlaybackWaveform.tsx diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss index 8100a03890..dc4835c4dd 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.scss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -36,11 +36,12 @@ limitations under the License. } .mx_VoiceRecordComposerTile_waveformContainer { - padding: 5px; + padding: 8px; // makes us 4px taller than the send/stop button padding-right: 4px; // there's 1px from the waveform itself, so account for that padding-left: 15px; // +10px for the live circle, +5px for regular padding background-color: $voice-record-waveform-bg-color; border-radius: 12px; + margin: 6px; // force the composer area to put a gutter around us margin-right: 12px; // isolate from stop button // Cheat at alignment a bit @@ -52,7 +53,7 @@ limitations under the License. color: $voice-record-waveform-fg-color; font-size: $font-14px; - &::before { + &.mx_VoiceRecordComposerTile_recording::before { animation: recording-pulse 2s infinite; content: ''; @@ -61,7 +62,7 @@ limitations under the License. height: 10px; position: absolute; left: 8px; - top: 16px; // vertically center + top: 18px; // vertically center border-radius: 10px; } diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 9b7f0da472..3fcafafd05 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -17,7 +17,7 @@ limitations under the License. import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {_t} from "../../../languageHandler"; import React from "react"; -import {VoiceRecording} from "../../../voice/VoiceRecording"; +import {RecordingState, VoiceRecording} from "../../../voice/VoiceRecording"; import {Room} from "matrix-js-sdk/src/models/room"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import classNames from "classnames"; @@ -25,6 +25,8 @@ import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import LiveRecordingClock from "../voice_messages/LiveRecordingClock"; import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore"; +import {UPDATE_EVENT} from "../../../stores/AsyncStore"; +import PlaybackWaveform from "../voice_messages/PlaybackWaveform"; interface IProps { room: Room; @@ -32,6 +34,7 @@ interface IProps { interface IState { recorder?: VoiceRecording; + recordingPhase?: RecordingState; } /** @@ -43,87 +46,126 @@ export default class VoiceRecordComposerTile extends React.PureComponent { - // TODO: @@ TravisR: We do not want to auto-send on stop. + public async componentWillUnmount() { + await VoiceRecordingStore.instance.disposeRecording(); + } + + // called by composer + public async send() { + if (!this.state.recorder) { + throw new Error("No recording started - cannot send anything"); + } + + await this.state.recorder.stop(); + const mxc = await this.state.recorder.upload(); + MatrixClientPeg.get().sendMessage(this.props.room.roomId, { + "body": "Voice message", + "msgtype": "org.matrix.msc2516.voice", + //"msgtype": MsgType.Audio, + "url": mxc, + "info": { + duration: Math.round(this.state.recorder.durationSeconds * 1000), + mimetype: this.state.recorder.contentType, + size: this.state.recorder.contentLength, + }, + + // MSC1767 experiment + "org.matrix.msc1767.text": "Voice message", + "org.matrix.msc1767.file": { + url: mxc, + name: "Voice message.ogg", + mimetype: this.state.recorder.contentType, + size: this.state.recorder.contentLength, + }, + "org.matrix.msc1767.audio": { + duration: Math.round(this.state.recorder.durationSeconds * 1000), + // TODO: @@ TravisR: Waveform? (MSC1767 decision) + }, + "org.matrix.experimental.msc2516.voice": { // MSC2516+MSC1767 experiment + duration: Math.round(this.state.recorder.durationSeconds * 1000), + + // Events can't have floats, so we try to maintain resolution by using 1024 + // as a maximum value. The waveform contains values between zero and 1, so this + // should come out largely sane. + // + // We're expecting about one data point per second of audio. + waveform: this.state.recorder.finalWaveform.map(v => Math.round(v * 1024)), + }, + }); + await VoiceRecordingStore.instance.disposeRecording(); + this.setState({recorder: null}); + } + + private onRecordStartEndClick = async () => { if (this.state.recorder) { await this.state.recorder.stop(); - const mxc = await this.state.recorder.upload(); - MatrixClientPeg.get().sendMessage(this.props.room.roomId, { - "body": "Voice message", - "msgtype": "org.matrix.msc2516.voice", - //"msgtype": MsgType.Audio, - "url": mxc, - "info": { - duration: Math.round(this.state.recorder.durationSeconds * 1000), - mimetype: this.state.recorder.contentType, - size: this.state.recorder.contentLength, - }, - - // MSC1767 experiment - "org.matrix.msc1767.text": "Voice message", - "org.matrix.msc1767.file": { - url: mxc, - name: "Voice message.ogg", - mimetype: this.state.recorder.contentType, - size: this.state.recorder.contentLength, - }, - "org.matrix.msc1767.audio": { - duration: Math.round(this.state.recorder.durationSeconds * 1000), - // TODO: @@ TravisR: Waveform? (MSC1767 decision) - }, - "org.matrix.experimental.msc2516.voice": { // MSC2516+MSC1767 experiment - duration: Math.round(this.state.recorder.durationSeconds * 1000), - - // Events can't have floats, so we try to maintain resolution by using 1024 - // as a maximum value. The waveform contains values between zero and 1, so this - // should come out largely sane. - // - // We're expecting about one data point per second of audio. - waveform: this.state.recorder.finalWaveform.map(v => Math.round(v * 1024)), - }, - }); - await VoiceRecordingStore.instance.disposeRecording(); - this.setState({recorder: null}); return; } const recorder = VoiceRecordingStore.instance.startRecording(); await recorder.start(); - this.setState({recorder}); + + // We don't need to remove the listener: the recorder will clean that up for us. + recorder.on(UPDATE_EVENT, (ev: RecordingState) => { + if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here + this.setState({recordingPhase: ev}); + }); + + this.setState({recorder, recordingPhase: RecordingState.Started}); }; private renderWaveformArea() { if (!this.state.recorder) return null; - return
    - - + const classes = classNames({ + 'mx_VoiceRecordComposerTile_waveformContainer': true, + 'mx_VoiceRecordComposerTile_recording': this.state.recordingPhase === RecordingState.Started, + }); + + const clock = ; + let waveform = ; + if (this.state.recordingPhase !== RecordingState.Started) { + waveform = ; + } + + return
    + {clock} + {waveform}
    ; } public render() { - const classes = classNames({ - 'mx_MessageComposer_button': !this.state.recorder, - 'mx_MessageComposer_voiceMessage': !this.state.recorder, - 'mx_VoiceRecordComposerTile_stop': !!this.state.recorder, - }); + let recordingInfo; + if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) { + const classes = classNames({ + 'mx_MessageComposer_button': !this.state.recorder, + 'mx_MessageComposer_voiceMessage': !this.state.recorder, + 'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording, + }); - let tooltip = _t("Record a voice message"); - if (!!this.state.recorder) { - // TODO: @@ TravisR: Change to match behaviour - tooltip = _t("Stop & send recording"); + let tooltip = _t("Record a voice message"); + if (!!this.state.recorder) { + tooltip = _t("Stop the recording"); + } + + let stopOrRecordBtn = ; + if (this.state.recorder && !this.state.recorder?.isRecording) { + stopOrRecordBtn = null; + } + + recordingInfo = stopOrRecordBtn; } return (<> {this.renderWaveformArea()} - + {recordingInfo} ); } } diff --git a/src/components/views/voice_messages/IRecordingWaveformStateProps.ts b/src/components/views/voice_messages/IRecordingWaveformStateProps.ts new file mode 100644 index 0000000000..fcdbf3e3b1 --- /dev/null +++ b/src/components/views/voice_messages/IRecordingWaveformStateProps.ts @@ -0,0 +1,26 @@ +/* +Copyright 2021 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 {VoiceRecording} from "../../../voice/VoiceRecording"; + +export interface IRecordingWaveformProps { + recorder: VoiceRecording; +} + +export interface IRecordingWaveformState { + heights: number[]; +} + +export const DOWNSAMPLE_TARGET = 35; // number of bars we want diff --git a/src/components/views/voice_messages/LiveRecordingWaveform.tsx b/src/components/views/voice_messages/LiveRecordingWaveform.tsx index c1f5e97fff..e9b3fea629 100644 --- a/src/components/views/voice_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/voice_messages/LiveRecordingWaveform.tsx @@ -20,22 +20,13 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; import {arrayFastResample, arraySeed} from "../../../utils/arrays"; import {percentageOf} from "../../../utils/numbers"; import Waveform from "./Waveform"; - -interface IProps { - recorder: VoiceRecording; -} - -interface IState { - heights: number[]; -} - -const DOWNSAMPLE_TARGET = 35; // number of bars we want +import {DOWNSAMPLE_TARGET, IRecordingWaveformProps, IRecordingWaveformState} from "./IRecordingWaveformStateProps"; /** * A waveform which shows the waveform of a live recording */ @replaceableComponent("views.voice_messages.LiveRecordingWaveform") -export default class LiveRecordingWaveform extends React.PureComponent { +export default class LiveRecordingWaveform extends React.PureComponent { public constructor(props) { super(props); diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/voice_messages/PlaybackWaveform.tsx new file mode 100644 index 0000000000..02647aa3ee --- /dev/null +++ b/src/components/views/voice_messages/PlaybackWaveform.tsx @@ -0,0 +1,43 @@ +/* +Copyright 2021 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 {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {arrayFastResample, arraySeed, arrayTrimFill} from "../../../utils/arrays"; +import {percentageOf} from "../../../utils/numbers"; +import Waveform from "./Waveform"; +import {DOWNSAMPLE_TARGET, IRecordingWaveformProps, IRecordingWaveformState} from "./IRecordingWaveformStateProps"; + +/** + * A waveform which shows the waveform of a previously recorded recording + */ +@replaceableComponent("views.voice_messages.LiveRecordingWaveform") +export default class PlaybackWaveform extends React.PureComponent { + public constructor(props) { + super(props); + + // Like the live recording waveform + const bars = arrayFastResample(this.props.recorder.finalWaveform, DOWNSAMPLE_TARGET); + const seed = arraySeed(0, DOWNSAMPLE_TARGET); + const heights = arrayTrimFill(bars, DOWNSAMPLE_TARGET, seed).map(b => percentageOf(b, 0, 0.5)); + this.state = {heights}; + } + + public render() { + return ; + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c6f7a8d25e..f592cc19cf 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1645,7 +1645,7 @@ "Jump to first unread message.": "Jump to first unread message.", "Mark all as read": "Mark all as read", "Record a voice message": "Record a voice message", - "Stop & send recording": "Stop & send recording", + "Stop the recording": "Stop the 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.", diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index cea377bfe9..f7e693452b 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -73,6 +73,26 @@ export function arraySeed(val: T, length: number): T[] { return a; } +/** + * Trims or fills the array to ensure it meets the desired length. The seed array + * given is pulled from to fill any missing slots - it is recommended that this be + * at least `len` long. The resulting array will be exactly `len` long, either + * trimmed from the source or filled with the some/all of the seed array. + * @param {T[]} a The array to trim/fill. + * @param {number} len The length to trim or fill to, as needed. + * @param {T[]} seed Values to pull from if the array needs filling. + * @returns {T[]} The resulting array of `len` length. + */ +export function arrayTrimFill(a: T[], len: number, seed: T[]): T[] { + // Dev note: we do length checks because the spread operator can result in some + // performance penalties in more critical code paths. As a utility, it should be + // as fast as possible to not cause a problem for the call stack, no matter how + // critical that stack is. + if (a.length === len) return a; + if (a.length > len) return a.slice(0, len); + return a.concat(seed.slice(0, len - a.length)); +} + /** * Clones an array as fast as possible, retaining references of the array's values. * @param a The array to clone. Must be defined. diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts index b0cc3cd407..68a944da51 100644 --- a/src/voice/VoiceRecording.ts +++ b/src/voice/VoiceRecording.ts @@ -25,6 +25,7 @@ import {IDestroyable} from "../utils/IDestroyable"; import {Singleflight} from "../utils/Singleflight"; import {PayloadEvent, WORKLET_NAME} from "./consts"; import {arrayFastClone} from "../utils/arrays"; +import {UPDATE_EVENT} from "../stores/AsyncStore"; const CHANNELS = 1; // stereo isn't important const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. @@ -79,6 +80,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return this.recorderContext.currentTime; } + public get isRecording(): boolean { + return this.recording; + } + + public emit(event: string, ...args: any[]): boolean { + super.emit(event, ...args); + super.emit(UPDATE_EVENT, event, ...args); + return true; // we don't ever care if the event had listeners, so just return "yes" + } + private async makeRecorder() { this.recorderStream = await navigator.mediaDevices.getUserMedia({ audio: { diff --git a/test/utils/arrays-test.ts b/test/utils/arrays-test.ts index ececd274b2..c5be59ab43 100644 --- a/test/utils/arrays-test.ts +++ b/test/utils/arrays-test.ts @@ -22,6 +22,7 @@ import { arrayHasOrderChange, arrayMerge, arraySeed, + arrayTrimFill, arrayUnion, ArrayUtil, GroupedArray, @@ -64,6 +65,38 @@ describe('arrays', () => { }); }); + describe('arrayTrimFill', () => { + it('should shrink arrays', () => { + const input = [1, 2, 3]; + const output = [1, 2]; + const seed = [4, 5, 6]; + const result = arrayTrimFill(input, output.length, seed); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + + it('should expand arrays', () => { + const input = [1, 2, 3]; + const output = [1, 2, 3, 4, 5]; + const seed = [4, 5, 6]; + const result = arrayTrimFill(input, output.length, seed); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + + it('should keep arrays the same', () => { + const input = [1, 2, 3]; + const output = [1, 2, 3]; + const seed = [4, 5, 6]; + const result = arrayTrimFill(input, output.length, seed); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + }); + describe('arraySeed', () => { it('should create an array of given length', () => { const val = 1; From e079f64a16f9bc14c9b458aca971daac2374630d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 20 Apr 2021 21:43:55 -0600 Subject: [PATCH 25/43] Adjust pixel dimensions --- .../views/rooms/_VoiceRecordComposerTile.scss | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss index dc4835c4dd..1975cf943f 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.scss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -36,9 +36,8 @@ limitations under the License. } .mx_VoiceRecordComposerTile_waveformContainer { - padding: 8px; // makes us 4px taller than the send/stop button - padding-right: 4px; // there's 1px from the waveform itself, so account for that - padding-left: 15px; // +10px for the live circle, +5px for regular padding + padding: 6px; // makes us 4px taller than the send/stop button + padding-right: 5px; // there's 1px from the waveform itself, so account for that background-color: $voice-record-waveform-bg-color; border-radius: 12px; margin: 6px; // force the composer area to put a gutter around us @@ -53,27 +52,36 @@ limitations under the License. color: $voice-record-waveform-fg-color; font-size: $font-14px; - &.mx_VoiceRecordComposerTile_recording::before { - animation: recording-pulse 2s infinite; + &.mx_VoiceRecordComposerTile_recording { + padding-left: 16px; // +10px for the live circle, +6px for regular padding - content: ''; - background-color: $voice-record-live-circle-color; - width: 10px; - height: 10px; - position: absolute; - left: 8px; - top: 18px; // vertically center - border-radius: 10px; + &::before { + animation: recording-pulse 2s infinite; + + content: ''; + background-color: $voice-record-live-circle-color; + width: 10px; + height: 10px; + position: absolute; + left: 8px; + top: 16px; // vertically center + border-radius: 10px; + } } - .mx_Waveform_bar { - background-color: $voice-record-waveform-fg-color; + .mx_Waveform { + // We want the bars to be 2px shorter than the play/pause button in the waveform control + height: 28px; // default is 30px, so we're subtracting the 2px border off the bars + + .mx_Waveform_bar { + background-color: $voice-record-waveform-fg-color; + } } .mx_Clock { - padding-right: 8px; // isolate from waveform - padding-left: 10px; // isolate from live circle - width: 42px; // we're not using a monospace font, so fake it + padding-right: 4px; // isolate from waveform + padding-left: 8px; // isolate from live circle + width: 40px; // we're not using a monospace font, so fake it } } From 30e120284d0d9a8661ac164713b7c8cd91ce6495 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 26 Apr 2021 20:48:24 -0600 Subject: [PATCH 26/43] Add simple play/pause controls --- res/css/_components.scss | 1 + .../voice_messages/_PlayPauseButton.scss | 51 ++++++++++ res/img/element-icons/pause-symbol.svg | 4 + res/img/element-icons/play-symbol.svg | 3 + .../views/rooms/VoiceRecordComposerTile.tsx | 7 ++ .../views/voice_messages/PlayPauseButton.tsx | 89 +++++++++++++++++ src/i18n/strings/en_EN.json | 2 + src/voice/Playback.ts | 97 +++++++++++++++++++ src/voice/VoiceRecording.ts | 9 ++ 9 files changed, 263 insertions(+) create mode 100644 res/css/views/voice_messages/_PlayPauseButton.scss create mode 100644 res/img/element-icons/pause-symbol.svg create mode 100644 res/img/element-icons/play-symbol.svg create mode 100644 src/components/views/voice_messages/PlayPauseButton.tsx create mode 100644 src/voice/Playback.ts diff --git a/res/css/_components.scss b/res/css/_components.scss index 253f97bf42..0179d146d1 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -248,6 +248,7 @@ @import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; +@import "./views/voice_messages/_PlayPauseButton.scss"; @import "./views/voice_messages/_Waveform.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/voice_messages/_PlayPauseButton.scss new file mode 100644 index 0000000000..0fd31be28a --- /dev/null +++ b/res/css/views/voice_messages/_PlayPauseButton.scss @@ -0,0 +1,51 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_PlayPauseButton { + position: relative; + width: 32px; + height: 32px; + border-radius: 32px; + background-color: $primary-bg-color; + + &::before { + content: ''; + position: absolute; // sizing varies by icon + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + } + + &.mx_PlayPauseButton_disabled::before { + opacity: 0.5; + } + + &.mx_PlayPauseButton_play::before { + width: 13px; + height: 16px; + top: 8px; // center + left: 12px; // center + mask-image: url('$(res)/img/element-icons/play-symbol.svg'); + } + + &.mx_PlayPauseButton_pause::before { + width: 10px; + height: 12px; + top: 10px; // center + left: 11px; // center + mask-image: url('$(res)/img/element-icons/pause-symbol.svg'); + } +} diff --git a/res/img/element-icons/pause-symbol.svg b/res/img/element-icons/pause-symbol.svg new file mode 100644 index 0000000000..293c0a10d8 --- /dev/null +++ b/res/img/element-icons/pause-symbol.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/play-symbol.svg b/res/img/element-icons/play-symbol.svg new file mode 100644 index 0000000000..339e20b729 --- /dev/null +++ b/res/img/element-icons/play-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 3fcafafd05..53dc7cc9c5 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -27,6 +27,7 @@ import LiveRecordingClock from "../voice_messages/LiveRecordingClock"; import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore"; import {UPDATE_EVENT} from "../../../stores/AsyncStore"; import PlaybackWaveform from "../voice_messages/PlaybackWaveform"; +import PlayPauseButton from "../voice_messages/PlayPauseButton"; interface IProps { room: Room; @@ -131,7 +132,13 @@ export default class VoiceRecordComposerTile extends React.PureComponent; } + let playPause = null; + if (this.state.recordingPhase === RecordingState.Ended) { + playPause = ; + } + return
    + {playPause} {clock} {waveform}
    ; diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/voice_messages/PlayPauseButton.tsx new file mode 100644 index 0000000000..1339caf77f --- /dev/null +++ b/src/components/views/voice_messages/PlayPauseButton.tsx @@ -0,0 +1,89 @@ +/* +Copyright 2021 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 {replaceableComponent} from "../../../utils/replaceableComponent"; +import {VoiceRecording} from "../../../voice/VoiceRecording"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import {_t} from "../../../languageHandler"; +import {Playback, PlaybackState} from "../../../voice/Playback"; +import classNames from "classnames"; +import {UPDATE_EVENT} from "../../../stores/AsyncStore"; + +interface IProps { + recorder: VoiceRecording; +} + +interface IState { + playback: Playback; + playbackPhase: PlaybackState; +} + +/** + * Displays a play/pause button (activating the play/pause function of the recorder) + * to be displayed in reference to a recording. + */ +@replaceableComponent("views.voice_messages.PlayPauseButton") +export default class PlayPauseButton extends React.PureComponent { + public constructor(props) { + super(props); + this.state = { + playback: null, // not ready yet + playbackPhase: PlaybackState.Decoding, + }; + } + + public async componentDidMount() { + const playback = await this.props.recorder.getPlayback(); + playback.on(UPDATE_EVENT, this.onPlaybackState); + this.setState({ + playback: playback, + + // We know the playback is no longer decoding when we get here. It'll emit an update + // before we've bound a listener, so we just update the state here. + playbackPhase: PlaybackState.Stopped, + }); + } + + public componentWillUnmount() { + if (this.state.playback) this.state.playback.off(UPDATE_EVENT, this.onPlaybackState); + } + + private onPlaybackState = (newState: PlaybackState) => { + this.setState({playbackPhase: newState}); + }; + + private onClick = async () => { + if (!this.state.playback) return; // ignore for now + await this.state.playback.toggle(); + }; + + public render() { + const isPlaying = this.state.playback?.isPlaying; + const isDisabled = this.state.playbackPhase === PlaybackState.Decoding; + const classes = classNames('mx_PlayPauseButton', { + 'mx_PlayPauseButton_play': !isPlaying, + 'mx_PlayPauseButton_pause': isPlaying, + 'mx_PlayPauseButton_disabled': isDisabled, + }); + return ; + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f592cc19cf..943b2291b3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -899,6 +899,8 @@ "Incoming call": "Incoming call", "Decline": "Decline", "Accept": "Accept", + "Pause": "Pause", + "Play": "Play", "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.", diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts new file mode 100644 index 0000000000..0039113a57 --- /dev/null +++ b/src/voice/Playback.ts @@ -0,0 +1,97 @@ +/* +Copyright 2021 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 EventEmitter from "events"; +import {UPDATE_EVENT} from "../stores/AsyncStore"; + +export enum PlaybackState { + Decoding = "decoding", + Stopped = "stopped", // no progress on timeline + Paused = "paused", // some progress on timeline + Playing = "playing", // active progress through timeline +} + +export class Playback extends EventEmitter { + private context: AudioContext; + private source: AudioBufferSourceNode; + private state = PlaybackState.Decoding; + private audioBuf: AudioBuffer; + + constructor(private buf: ArrayBuffer) { + super(); + this.context = new AudioContext(); + } + + public emit(event: PlaybackState, ...args: any[]): boolean { + this.state = event; + super.emit(event, ...args); + super.emit(UPDATE_EVENT, event, ...args); + return true; // we don't ever care if the event had listeners, so just return "yes" + } + + public async prepare() { + this.audioBuf = await this.context.decodeAudioData(this.buf); + this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore + } + + public get currentState(): PlaybackState { + return this.state; + } + + public get isPlaying(): boolean { + return this.currentState === PlaybackState.Playing; + } + + private onPlaybackEnd = async () => { + await this.context.suspend(); + this.emit(PlaybackState.Stopped); + }; + + public async play() { + // We can't restart a buffer source, so we need to create a new one if we hit the end + if (this.state === PlaybackState.Stopped) { + if (this.source) { + this.source.disconnect(); + this.source.removeEventListener("ended", this.onPlaybackEnd); + } + + this.source = this.context.createBufferSource(); + this.source.connect(this.context.destination); + this.source.buffer = this.audioBuf; + this.source.start(); // start immediately + this.source.addEventListener("ended", this.onPlaybackEnd); + } + + // We use the context suspend/resume functions because it allows us to pause a source + // node, but that still doesn't help us when the source node runs out (see above). + await this.context.resume(); + this.emit(PlaybackState.Playing); + } + + public async pause() { + await this.context.suspend(); + this.emit(PlaybackState.Paused); + } + + public async stop() { + await this.onPlaybackEnd(); + } + + public async toggle() { + if (this.isPlaying) await this.pause(); + else await this.play(); + } +} diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts index 68a944da51..692e317333 100644 --- a/src/voice/VoiceRecording.ts +++ b/src/voice/VoiceRecording.ts @@ -26,6 +26,7 @@ import {Singleflight} from "../utils/Singleflight"; import {PayloadEvent, WORKLET_NAME} from "./consts"; import {arrayFastClone} from "../utils/arrays"; import {UPDATE_EVENT} from "../stores/AsyncStore"; +import {Playback} from "./Playback"; const CHANNELS = 1; // stereo isn't important const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. @@ -270,6 +271,14 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { }); } + public getPlayback(): Promise { + return Singleflight.for(this, "playback").do(async () => { + const playback = new Playback(this.buffer.buffer); // cast to ArrayBuffer proper + await playback.prepare(); + return playback; + }); + } + public destroy() { // noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here this.stop(); From c1bb0bb0b8866a9b4fb8cdcfff5569ac9a0ad306 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 26 Apr 2021 21:08:51 -0600 Subject: [PATCH 27/43] Add a delete button --- .../views/rooms/_VoiceRecordComposerTile.scss | 11 ++++++++ .../views/rooms/VoiceRecordComposerTile.tsx | 25 +++++++++++++++++-- src/i18n/strings/en_EN.json | 1 + 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss index 1975cf943f..cb77878666 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.scss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -35,6 +35,17 @@ limitations under the License. } } +.mx_VoiceRecordComposerTile_delete { + width: 14px; // w&h are size of icon + height: 18px; + vertical-align: middle; + margin-right: 7px; // distance from left edge of waveform container (container has some margin too) + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/trashcan.svg'); +} + .mx_VoiceRecordComposerTile_waveformContainer { padding: 6px; // makes us 4px taller than the send/stop button padding-right: 5px; // there's 1px from the waveform itself, so account for that diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 53dc7cc9c5..b02c6a4bae 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -97,10 +97,20 @@ export default class VoiceRecordComposerTile extends React.PureComponent Math.round(v * 1024)), }, }); - await VoiceRecordingStore.instance.disposeRecording(); - this.setState({recorder: null}); + await this.disposeRecording(); } + private async disposeRecording() { + await VoiceRecordingStore.instance.disposeRecording(); + + // Reset back to no recording, which means no phase (ie: restart component entirely) + this.setState({recorder: null, recordingPhase: null}); + } + + private onCancel = async () => { + await this.disposeRecording(); + }; + private onRecordStartEndClick = async () => { if (this.state.recorder) { await this.state.recorder.stop(); @@ -134,6 +144,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent; } @@ -146,6 +157,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent; + } + return (<> + {deleteButton} {this.renderWaveformArea()} {recordingInfo} ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 943b2291b3..264c35144e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1648,6 +1648,7 @@ "Mark all as read": "Mark all as read", "Record a voice message": "Record a voice message", "Stop the recording": "Stop the recording", + "Delete recording": "Delete 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.", From 5e646f861cf592e72ae7eb18e64bd65f15de3245 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 27 Apr 2021 18:59:10 -0600 Subject: [PATCH 28/43] Wire up the send button for voice messages This fixes a bug where we couldn't upload voice messages because the audio buffer was being read, therefore changing the position of the cursor. When this happened, the upload function would claim that the buffer was empty and could not be read. --- src/components/views/rooms/MessageComposer.tsx | 12 +++++++++++- src/voice/VoiceRecording.ts | 14 ++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index d126a7b161..3671069903 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -198,6 +198,7 @@ interface IState { export default class MessageComposer extends React.Component { private dispatcherRef: string; private messageComposerInput: SendMessageComposer; + private voiceRecordingButton: VoiceRecordComposerTile; constructor(props) { super(props); @@ -322,7 +323,15 @@ export default class MessageComposer extends React.Component { }); } - sendMessage = () => { + sendMessage = async () => { + if (this.state.haveRecording && this.voiceRecordingButton) { + // There shouldn't be any text message to send when a voice recording is active, so + // just send out the voice recording. + await this.voiceRecordingButton.send(); + return; + } + + // XXX: Private function access this.messageComposerInput._sendMessage(); } @@ -387,6 +396,7 @@ export default class MessageComposer extends React.Component { if (SettingsStore.getValue("feature_voice_messages")) { controls.push( this.voiceRecordingButton = c} room={this.props.room} />); } diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts index 692e317333..0c76ac406f 100644 --- a/src/voice/VoiceRecording.ts +++ b/src/voice/VoiceRecording.ts @@ -54,7 +54,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private recorderStream: MediaStream; private recorderFFT: AnalyserNode; private recorderWorklet: AudioWorkletNode; - private buffer = new Uint8Array(0); + private buffer = new Uint8Array(0); // use this.audioBuffer to access private mxc: string; private recording = false; private observable: SimpleObservable; @@ -166,6 +166,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { }; } + private get audioBuffer(): Uint8Array { + // We need a clone of the buffer to avoid accidentally changing the position + // on the real thing. + return this.buffer.slice(0); + } + public get liveData(): SimpleObservable { if (!this.recording) throw new Error("No observable when not recording"); return this.observable; @@ -267,13 +273,13 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { await this.recorder.close(); this.emit(RecordingState.Ended); - return this.buffer; + return this.audioBuffer; }); } public getPlayback(): Promise { return Singleflight.for(this, "playback").do(async () => { - const playback = new Playback(this.buffer.buffer); // cast to ArrayBuffer proper + const playback = new Playback(this.audioBuffer.buffer); // cast to ArrayBuffer proper await playback.prepare(); return playback; }); @@ -294,7 +300,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { if (this.mxc) return this.mxc; this.emit(RecordingState.Uploading); - this.mxc = await this.client.uploadContent(new Blob([this.buffer], { + this.mxc = await this.client.uploadContent(new Blob([this.audioBuffer], { type: this.contentType, }), { onlyContentUri: false, // to stop the warnings in the console From c2d37af1cb2e4471988d32e27c95dbee9a57c89c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 27 Apr 2021 20:27:36 -0600 Subject: [PATCH 29/43] Move playback to its own set of classes This all started with a bug where the clock wouldn't update appropriately, and ended with a whole refactoring to support later playback in the timeline. Playback and recording instances are now independent, and this applies to the components as well. Instead of those playback components taking a recording, they take a playback instance which has all the information the components need. The clock was incredibly difficult to do because of the audio context's time tracking and the source's inability to say where it is at in the buffer/in time. This means we have to track when we started playing the clip so we can capture the audio context's current time, which may be a few seconds by the first time the user hits play. We also track stops so we know when to reset that flag. Waveform calculations have also been moved into the base component, deduplicating the math a bit. --- res/css/_components.scss | 1 + .../views/rooms/_VoiceRecordComposerTile.scss | 30 +------ .../voice_messages/_PlaybackContainer.scss | 48 ++++++++++++ .../views/rooms/VoiceRecordComposerTile.tsx | 35 +++------ src/components/views/voice_messages/Clock.tsx | 8 +- .../IRecordingWaveformStateProps.ts | 26 ------- .../voice_messages/LiveRecordingClock.tsx | 8 +- .../voice_messages/LiveRecordingWaveform.tsx | 16 +++- .../views/voice_messages/PlayPauseButton.tsx | 44 +++-------- .../views/voice_messages/PlaybackClock.tsx | 71 +++++++++++++++++ .../views/voice_messages/PlaybackWaveform.tsx | 35 ++++++--- .../voice_messages/RecordingPlayback.tsx | 62 +++++++++++++++ src/voice/Playback.ts | 66 +++++++++++++--- src/voice/PlaybackClock.ts | 78 +++++++++++++++++++ src/voice/VoiceRecording.ts | 26 ++++--- 15 files changed, 400 insertions(+), 154 deletions(-) create mode 100644 res/css/views/voice_messages/_PlaybackContainer.scss delete mode 100644 src/components/views/voice_messages/IRecordingWaveformStateProps.ts create mode 100644 src/components/views/voice_messages/PlaybackClock.tsx create mode 100644 src/components/views/voice_messages/RecordingPlayback.tsx create mode 100644 src/voice/PlaybackClock.ts diff --git a/res/css/_components.scss b/res/css/_components.scss index 0179d146d1..0057f8a8fc 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -249,6 +249,7 @@ @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voice_messages/_PlayPauseButton.scss"; +@import "./views/voice_messages/_PlaybackContainer.scss"; @import "./views/voice_messages/_Waveform.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss index cb77878666..e99e1a00e1 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.scss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -46,23 +46,14 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/trashcan.svg'); } -.mx_VoiceRecordComposerTile_waveformContainer { - padding: 6px; // makes us 4px taller than the send/stop button - padding-right: 5px; // there's 1px from the waveform itself, so account for that - background-color: $voice-record-waveform-bg-color; - border-radius: 12px; +.mx_VoiceMessagePrimaryContainer { + // Note: remaining class properties are in the PlayerContainer CSS. + margin: 6px; // force the composer area to put a gutter around us margin-right: 12px; // isolate from stop button - // Cheat at alignment a bit - display: flex; - align-items: center; - position: relative; // important for the live circle - color: $voice-record-waveform-fg-color; - font-size: $font-14px; - &.mx_VoiceRecordComposerTile_recording { padding-left: 16px; // +10px for the live circle, +6px for regular padding @@ -79,21 +70,6 @@ limitations under the License. border-radius: 10px; } } - - .mx_Waveform { - // We want the bars to be 2px shorter than the play/pause button in the waveform control - height: 28px; // default is 30px, so we're subtracting the 2px border off the bars - - .mx_Waveform_bar { - background-color: $voice-record-waveform-fg-color; - } - } - - .mx_Clock { - padding-right: 4px; // isolate from waveform - padding-left: 8px; // isolate from live circle - width: 40px; // we're not using a monospace font, so fake it - } } // The keyframes are slightly weird here to help make a ramping/punch effect diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/voice_messages/_PlaybackContainer.scss new file mode 100644 index 0000000000..a9ebc19667 --- /dev/null +++ b/res/css/views/voice_messages/_PlaybackContainer.scss @@ -0,0 +1,48 @@ +/* +Copyright 2021 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. +*/ + +// Dev note: there's no actual component called . These classes +// are shared amongst multiple voice message components. + +// Container for live recording and playback controls +.mx_VoiceMessagePrimaryContainer { + padding: 6px; // makes us 4px taller than the send/stop button + padding-right: 5px; // there's 1px from the waveform itself, so account for that + background-color: $voice-record-waveform-bg-color; + border-radius: 12px; + + // Cheat at alignment a bit + display: flex; + align-items: center; + + color: $voice-record-waveform-fg-color; + font-size: $font-14px; + + .mx_Waveform { + // We want the bars to be 2px shorter than the play/pause button in the waveform control + height: 28px; // default is 30px, so we're subtracting the 2px border off the bars + + .mx_Waveform_bar { + background-color: $voice-record-waveform-fg-color; + } + } + + .mx_Clock { + padding-right: 4px; // isolate from waveform + padding-left: 8px; // isolate from live circle + width: 40px; // we're not using a monospace font, so fake it + } +} diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index b02c6a4bae..bab95291ba 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -16,7 +16,7 @@ limitations under the License. import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {_t} from "../../../languageHandler"; -import React from "react"; +import React, {ReactNode} from "react"; import {RecordingState, VoiceRecording} from "../../../voice/VoiceRecording"; import {Room} from "matrix-js-sdk/src/models/room"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; @@ -26,8 +26,7 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; import LiveRecordingClock from "../voice_messages/LiveRecordingClock"; import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore"; import {UPDATE_EVENT} from "../../../stores/AsyncStore"; -import PlaybackWaveform from "../voice_messages/PlaybackWaveform"; -import PlayPauseButton from "../voice_messages/PlayPauseButton"; +import RecordingPlayback from "../voice_messages/RecordingPlayback"; interface IProps { room: Room; @@ -94,7 +93,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent Math.round(v * 1024)), + waveform: this.state.recorder.getPlayback().waveform.map(v => Math.round(v * 1024)), }, }); await this.disposeRecording(); @@ -128,34 +127,22 @@ export default class VoiceRecordComposerTile extends React.PureComponent; - let waveform = ; if (this.state.recordingPhase !== RecordingState.Started) { - waveform = ; - } - - let playPause = null; - if (this.state.recordingPhase === RecordingState.Ended) { // TODO: @@ TR: Should we disable this during upload? What does a failed upload look like? - playPause = ; + return ; } - return
    - {playPause} - {clock} - {waveform} + // only other UI is the recording-in-progress UI + return
    + +
    ; } - public render() { + public render(): ReactNode { let recordingInfo; let deleteButton; if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) { diff --git a/src/components/views/voice_messages/Clock.tsx b/src/components/views/voice_messages/Clock.tsx index 6c256957e9..8b71f6b7fe 100644 --- a/src/components/views/voice_messages/Clock.tsx +++ b/src/components/views/voice_messages/Clock.tsx @@ -29,11 +29,17 @@ interface IState { * displayed, making it possible to see "82:29". */ @replaceableComponent("views.voice_messages.Clock") -export default class Clock extends React.PureComponent { +export default class Clock extends React.Component { public constructor(props) { super(props); } + shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean { + const currentFloor = Math.floor(this.props.seconds); + const nextFloor = Math.floor(nextProps.seconds); + return currentFloor !== nextFloor; + } + public render() { const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0'); const seconds = Math.round(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis diff --git a/src/components/views/voice_messages/IRecordingWaveformStateProps.ts b/src/components/views/voice_messages/IRecordingWaveformStateProps.ts deleted file mode 100644 index fcdbf3e3b1..0000000000 --- a/src/components/views/voice_messages/IRecordingWaveformStateProps.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* -Copyright 2021 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 {VoiceRecording} from "../../../voice/VoiceRecording"; - -export interface IRecordingWaveformProps { - recorder: VoiceRecording; -} - -export interface IRecordingWaveformState { - heights: number[]; -} - -export const DOWNSAMPLE_TARGET = 35; // number of bars we want diff --git a/src/components/views/voice_messages/LiveRecordingClock.tsx b/src/components/views/voice_messages/LiveRecordingClock.tsx index 5e9006c6ab..b82539eb16 100644 --- a/src/components/views/voice_messages/LiveRecordingClock.tsx +++ b/src/components/views/voice_messages/LiveRecordingClock.tsx @@ -31,7 +31,7 @@ interface IState { * A clock for a live recording. */ @replaceableComponent("views.voice_messages.LiveRecordingClock") -export default class LiveRecordingClock extends React.Component { +export default class LiveRecordingClock extends React.PureComponent { public constructor(props) { super(props); @@ -39,12 +39,6 @@ export default class LiveRecordingClock extends React.Component this.props.recorder.liveData.onUpdate(this.onRecordingUpdate); } - shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean { - const currentFloor = Math.floor(this.state.seconds); - const nextFloor = Math.floor(nextState.seconds); - return currentFloor !== nextFloor; - } - private onRecordingUpdate = (update: IRecordingUpdate) => { this.setState({seconds: update.timeSeconds}); }; diff --git a/src/components/views/voice_messages/LiveRecordingWaveform.tsx b/src/components/views/voice_messages/LiveRecordingWaveform.tsx index e9b3fea629..e7c34c9177 100644 --- a/src/components/views/voice_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/voice_messages/LiveRecordingWaveform.tsx @@ -20,24 +20,32 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; import {arrayFastResample, arraySeed} from "../../../utils/arrays"; import {percentageOf} from "../../../utils/numbers"; import Waveform from "./Waveform"; -import {DOWNSAMPLE_TARGET, IRecordingWaveformProps, IRecordingWaveformState} from "./IRecordingWaveformStateProps"; +import {PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback"; + +interface IProps { + recorder: VoiceRecording; +} + +interface IState { + heights: number[]; +} /** * A waveform which shows the waveform of a live recording */ @replaceableComponent("views.voice_messages.LiveRecordingWaveform") -export default class LiveRecordingWaveform extends React.PureComponent { +export default class LiveRecordingWaveform extends React.PureComponent { public constructor(props) { super(props); - this.state = {heights: arraySeed(0, DOWNSAMPLE_TARGET)}; + this.state = {heights: arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES)}; this.props.recorder.liveData.onUpdate(this.onRecordingUpdate); } private onRecordingUpdate = (update: IRecordingUpdate) => { // The waveform and the downsample target are pretty close, so we should be fine to // do this, despite the docs on arrayFastResample. - const bars = arrayFastResample(Array.from(update.waveform), DOWNSAMPLE_TARGET); + const bars = arrayFastResample(Array.from(update.waveform), PLAYBACK_WAVEFORM_SAMPLES); this.setState({ // The incoming data is between zero and one, but typically even screaming into a // microphone won't send you over 0.6, so we artificially adjust the gain for the diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/voice_messages/PlayPauseButton.tsx index 1339caf77f..b4f69b02bc 100644 --- a/src/components/views/voice_messages/PlayPauseButton.tsx +++ b/src/components/views/voice_messages/PlayPauseButton.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, {ReactNode} from "react"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import {VoiceRecording} from "../../../voice/VoiceRecording"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; @@ -24,12 +24,14 @@ import classNames from "classnames"; import {UPDATE_EVENT} from "../../../stores/AsyncStore"; interface IProps { - recorder: VoiceRecording; + // Playback instance to manipulate. Cannot change during the component lifecycle. + playback: Playback; + + // The playback phase to render. Able to change during the component lifecycle. + playbackPhase: PlaybackState; } interface IState { - playback: Playback; - playbackPhase: PlaybackState; } /** @@ -40,40 +42,16 @@ interface IState { export default class PlayPauseButton extends React.PureComponent { public constructor(props) { super(props); - this.state = { - playback: null, // not ready yet - playbackPhase: PlaybackState.Decoding, - }; + this.state = {}; } - public async componentDidMount() { - const playback = await this.props.recorder.getPlayback(); - playback.on(UPDATE_EVENT, this.onPlaybackState); - this.setState({ - playback: playback, - - // We know the playback is no longer decoding when we get here. It'll emit an update - // before we've bound a listener, so we just update the state here. - playbackPhase: PlaybackState.Stopped, - }); - } - - public componentWillUnmount() { - if (this.state.playback) this.state.playback.off(UPDATE_EVENT, this.onPlaybackState); - } - - private onPlaybackState = (newState: PlaybackState) => { - this.setState({playbackPhase: newState}); - }; - private onClick = async () => { - if (!this.state.playback) return; // ignore for now - await this.state.playback.toggle(); + await this.props.playback.toggle(); }; - public render() { - const isPlaying = this.state.playback?.isPlaying; - const isDisabled = this.state.playbackPhase === PlaybackState.Decoding; + public render(): ReactNode { + const isPlaying = this.props.playback.isPlaying; + const isDisabled = this.props.playbackPhase === PlaybackState.Decoding; const classes = classNames('mx_PlayPauseButton', { 'mx_PlayPauseButton_play': !isPlaying, 'mx_PlayPauseButton_pause': isPlaying, diff --git a/src/components/views/voice_messages/PlaybackClock.tsx b/src/components/views/voice_messages/PlaybackClock.tsx new file mode 100644 index 0000000000..2e8ec9a3e7 --- /dev/null +++ b/src/components/views/voice_messages/PlaybackClock.tsx @@ -0,0 +1,71 @@ +/* +Copyright 2021 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 {replaceableComponent} from "../../../utils/replaceableComponent"; +import Clock from "./Clock"; +import {Playback, PlaybackState} from "../../../voice/Playback"; +import {UPDATE_EVENT} from "../../../stores/AsyncStore"; + +interface IProps { + playback: Playback; +} + +interface IState { + seconds: number; + durationSeconds: number; + playbackPhase: PlaybackState; +} + +/** + * A clock for a playback of a recording. + */ +@replaceableComponent("views.voice_messages.PlaybackClock") +export default class PlaybackClock extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + seconds: this.props.playback.clockInfo.timeSeconds, + // we track the duration on state because we won't really know what the clip duration + // is until the first time update, and as a PureComponent we are trying to dedupe state + // updates as much as possible. This is just the easiest way to avoid a forceUpdate() or + // member property to track "did we get a duration". + durationSeconds: this.props.playback.clockInfo.durationSeconds, + playbackPhase: PlaybackState.Stopped, // assume not started, so full clock + }; + this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate); + this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); + } + + private onPlaybackUpdate = (ev: PlaybackState) => { + // Convert Decoding -> Stopped because we don't care about the distinction here + if (ev === PlaybackState.Decoding) ev = PlaybackState.Stopped; + this.setState({playbackPhase: ev}); + }; + + private onTimeUpdate = (time: number[]) => { + this.setState({seconds: time[0], durationSeconds: time[1]}); + }; + + public render() { + let seconds = this.state.seconds; + if (this.state.playbackPhase === PlaybackState.Stopped) { + seconds = this.state.durationSeconds; + } + return ; + } +} diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/voice_messages/PlaybackWaveform.tsx index 02647aa3ee..89de908575 100644 --- a/src/components/views/voice_messages/PlaybackWaveform.tsx +++ b/src/components/views/voice_messages/PlaybackWaveform.tsx @@ -15,28 +15,41 @@ limitations under the License. */ import React from "react"; -import {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording"; import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {arrayFastResample, arraySeed, arrayTrimFill} from "../../../utils/arrays"; -import {percentageOf} from "../../../utils/numbers"; +import {arraySeed, arrayTrimFill} from "../../../utils/arrays"; import Waveform from "./Waveform"; -import {DOWNSAMPLE_TARGET, IRecordingWaveformProps, IRecordingWaveformState} from "./IRecordingWaveformStateProps"; +import {Playback, PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback"; + +interface IProps { + playback: Playback; +} + +interface IState { + heights: number[]; +} /** * A waveform which shows the waveform of a previously recorded recording */ -@replaceableComponent("views.voice_messages.LiveRecordingWaveform") -export default class PlaybackWaveform extends React.PureComponent { +@replaceableComponent("views.voice_messages.PlaybackWaveform") +export default class PlaybackWaveform extends React.PureComponent { public constructor(props) { super(props); - // Like the live recording waveform - const bars = arrayFastResample(this.props.recorder.finalWaveform, DOWNSAMPLE_TARGET); - const seed = arraySeed(0, DOWNSAMPLE_TARGET); - const heights = arrayTrimFill(bars, DOWNSAMPLE_TARGET, seed).map(b => percentageOf(b, 0, 0.5)); - this.state = {heights}; + this.state = {heights: this.toHeights(this.props.playback.waveform)}; + + this.props.playback.waveformData.onUpdate(this.onWaveformUpdate); } + private toHeights(waveform: number[]) { + const seed = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); + return arrayTrimFill(waveform, PLAYBACK_WAVEFORM_SAMPLES, seed); + } + + private onWaveformUpdate = (waveform: number[]) => { + this.setState({heights: this.toHeights(waveform)}); + }; + public render() { return ; } diff --git a/src/components/views/voice_messages/RecordingPlayback.tsx b/src/components/views/voice_messages/RecordingPlayback.tsx new file mode 100644 index 0000000000..776997cec2 --- /dev/null +++ b/src/components/views/voice_messages/RecordingPlayback.tsx @@ -0,0 +1,62 @@ +/* +Copyright 2021 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 {Playback, PlaybackState} from "../../../voice/Playback"; +import React, {ReactNode} from "react"; +import {UPDATE_EVENT} from "../../../stores/AsyncStore"; +import PlaybackWaveform from "./PlaybackWaveform"; +import PlayPauseButton from "./PlayPauseButton"; +import PlaybackClock from "./PlaybackClock"; + +interface IProps { + // Playback instance to render. Cannot change during component lifecycle: create + // an all-new component instead. + playback: Playback; +} + +interface IState { + playbackPhase: PlaybackState; +} + +export default class RecordingPlayback extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = { + playbackPhase: PlaybackState.Decoding, // default assumption + }; + + // We don't need to de-register: the class handles this for us internally + this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate); + + // Don't wait for the promise to complete - it will emit a progress update when it + // is done, and it's not meant to take long anyhow. + // noinspection JSIgnoredPromiseFromCall + this.props.playback.prepare(); + } + + private onPlaybackUpdate = (ev: PlaybackState) => { + this.setState({playbackPhase: ev}); + }; + + public render(): ReactNode { + return
    + + + +
    + } +} diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts index 0039113a57..99b1f62866 100644 --- a/src/voice/Playback.ts +++ b/src/voice/Playback.ts @@ -16,6 +16,10 @@ limitations under the License. import EventEmitter from "events"; import {UPDATE_EVENT} from "../stores/AsyncStore"; +import {arrayFastResample, arraySeed} from "../utils/arrays"; +import {SimpleObservable} from "matrix-widget-api"; +import {IDestroyable} from "../utils/IDestroyable"; +import {PlaybackClock} from "./PlaybackClock"; export enum PlaybackState { Decoding = "decoding", @@ -24,15 +28,52 @@ export enum PlaybackState { Playing = "playing", // active progress through timeline } -export class Playback extends EventEmitter { - private context: AudioContext; +export const PLAYBACK_WAVEFORM_SAMPLES = 35; +const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); + +export class Playback extends EventEmitter implements IDestroyable { + private readonly context: AudioContext; private source: AudioBufferSourceNode; private state = PlaybackState.Decoding; private audioBuf: AudioBuffer; + private resampledWaveform: number[]; + private waveformObservable = new SimpleObservable(); + private readonly clock: PlaybackClock; - constructor(private buf: ArrayBuffer) { + /** + * Creates a new playback instance from a buffer. + * @param {ArrayBuffer} buf The buffer containing the sound sample. + * @param {number[]} seedWaveform Optional seed waveform to present until the proper waveform + * can be calculated. Contains values between zero and one, inclusive. + */ + constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) { super(); this.context = new AudioContext(); + this.resampledWaveform = arrayFastResample(seedWaveform, PLAYBACK_WAVEFORM_SAMPLES); + this.waveformObservable.update(this.resampledWaveform); + this.clock = new PlaybackClock(this.context); + + // TODO: @@ TR: Calculate real waveform + } + + public get waveform(): number[] { + return this.resampledWaveform; + } + + public get waveformData(): SimpleObservable { + return this.waveformObservable; + } + + public get clockInfo(): PlaybackClock { + return this.clock; + } + + public get currentState(): PlaybackState { + return this.state; + } + + public get isPlaying(): boolean { + return this.currentState === PlaybackState.Playing; } public emit(event: PlaybackState, ...args: any[]): boolean { @@ -42,17 +83,18 @@ export class Playback extends EventEmitter { return true; // we don't ever care if the event had listeners, so just return "yes" } + public destroy() { + // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here + this.stop(); + this.removeAllListeners(); + this.clock.destroy(); + this.waveformObservable.close(); + } + public async prepare() { this.audioBuf = await this.context.decodeAudioData(this.buf); this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore - } - - public get currentState(): PlaybackState { - return this.state; - } - - public get isPlaying(): boolean { - return this.currentState === PlaybackState.Playing; + this.clock.durationSeconds = this.audioBuf.duration; } private onPlaybackEnd = async () => { @@ -78,6 +120,7 @@ export class Playback extends EventEmitter { // We use the context suspend/resume functions because it allows us to pause a source // node, but that still doesn't help us when the source node runs out (see above). await this.context.resume(); + this.clock.flagStart(); this.emit(PlaybackState.Playing); } @@ -88,6 +131,7 @@ export class Playback extends EventEmitter { public async stop() { await this.onPlaybackEnd(); + this.clock.flagStop(); } public async toggle() { diff --git a/src/voice/PlaybackClock.ts b/src/voice/PlaybackClock.ts new file mode 100644 index 0000000000..06d6381691 --- /dev/null +++ b/src/voice/PlaybackClock.ts @@ -0,0 +1,78 @@ +/* +Copyright 2021 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 {SimpleObservable} from "matrix-widget-api"; +import {IDestroyable} from "../utils/IDestroyable"; + +// Because keeping track of time is sufficiently complicated... +export class PlaybackClock implements IDestroyable { + private clipStart = 0; + private stopped = true; + private lastCheck = 0; + private observable = new SimpleObservable(); + private timerId: number; + private clipDuration = 0; + + public constructor(private context: AudioContext) { + } + + public get durationSeconds(): number { + return this.clipDuration; + } + + public set durationSeconds(val: number) { + this.clipDuration = val; + this.observable.update([this.timeSeconds, this.clipDuration]); + } + + public get timeSeconds(): number { + return (this.context.currentTime - this.clipStart) % this.clipDuration; + } + + public get liveData(): SimpleObservable { + return this.observable; + } + + private checkTime = () => { + const now = this.timeSeconds; + if (this.lastCheck !== now) { + this.observable.update([now, this.durationSeconds]); + this.lastCheck = now; + } + }; + + public flagStart() { + if (this.stopped) { + this.clipStart = this.context.currentTime; + this.stopped = false; + } + + if (!this.timerId) { + // case to number because the types are wrong + // 100ms interval to make sure the time is as accurate as possible + this.timerId = setInterval(this.checkTime, 100); + } + } + + public flagStop() { + this.stopped = true; + } + + public destroy() { + this.observable.close(); + if (this.timerId) clearInterval(this.timerId); + } +} diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts index 0c76ac406f..6b0b84ad18 100644 --- a/src/voice/VoiceRecording.ts +++ b/src/voice/VoiceRecording.ts @@ -24,7 +24,6 @@ import EventEmitter from "events"; import {IDestroyable} from "../utils/IDestroyable"; import {Singleflight} from "../utils/Singleflight"; import {PayloadEvent, WORKLET_NAME} from "./consts"; -import {arrayFastClone} from "../utils/arrays"; import {UPDATE_EVENT} from "../stores/AsyncStore"; import {Playback} from "./Playback"; @@ -59,15 +58,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private recording = false; private observable: SimpleObservable; private amplitudes: number[] = []; // at each second mark, generated + private playback: Playback; public constructor(private client: MatrixClient) { super(); } - public get finalWaveform(): number[] { - return arrayFastClone(this.amplitudes); - } - public get contentType(): string { return "audio/ogg"; } @@ -277,12 +273,19 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { }); } - public getPlayback(): Promise { - return Singleflight.for(this, "playback").do(async () => { - const playback = new Playback(this.audioBuffer.buffer); // cast to ArrayBuffer proper - await playback.prepare(); - return playback; + /** + * Gets a playback instance for this voice recording. Note that the playback will not + * have been prepared fully, meaning the `prepare()` function needs to be called on it. + * + * The same playback instance is returned each time. + * + * @returns {Playback} The playback instance. + */ + public getPlayback(): Playback { + this.playback = Singleflight.for(this, "playback").do(() => { + return new Playback(this.audioBuffer.buffer, this.amplitudes); // cast to ArrayBuffer proper; }); + return this.playback; } public destroy() { @@ -290,6 +293,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { this.stop(); this.removeAllListeners(); Singleflight.forgetAllFor(this); + // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here + this.playback?.destroy(); + this.observable.close(); } public async upload(): Promise { From e2ce699130b00ca1e37b9738ba0e290fcb40e7f0 Mon Sep 17 00:00:00 2001 From: Ayush PS Date: Wed, 28 Apr 2021 10:02:20 +0530 Subject: [PATCH 30/43] Fixed linting warnings in MessagePanel.js --- src/components/structures/MessagePanel.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index adf1874ba0..f669a10267 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -1101,7 +1101,8 @@ class RedactionGrouper { let eventTiles = this.events.map((e, i) => { senders.add(e.sender); const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; - return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); + return panel._getTilesForEvent( + prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { From c4d85c457b084db57b676ce488984d616d8068b5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 27 Apr 2021 22:59:16 -0600 Subject: [PATCH 31/43] Add progress effect to playback waveform --- .../voice_messages/_PlaybackContainer.scss | 8 +++++++- res/themes/legacy-light/css/_legacy-light.scss | 1 + res/themes/light/css/_light.scss | 1 + .../views/voice_messages/PlaybackWaveform.tsx | 15 +++++++++++++-- .../views/voice_messages/Waveform.tsx | 17 ++++++++++++++++- 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/voice_messages/_PlaybackContainer.scss index a9ebc19667..49bd81ef81 100644 --- a/res/css/views/voice_messages/_PlaybackContainer.scss +++ b/res/css/views/voice_messages/_PlaybackContainer.scss @@ -36,7 +36,13 @@ limitations under the License. height: 28px; // default is 30px, so we're subtracting the 2px border off the bars .mx_Waveform_bar { - background-color: $voice-record-waveform-fg-color; + background-color: $voice-record-waveform-incomplete-fg-color; + + &.mx_Waveform_bar_100pct { + // Small animation to remove the mechanical feel of progress + transition: background-color 250ms ease; + background-color: $voice-record-waveform-fg-color; + } } } diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index e05285721e..d7352e5684 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -196,6 +196,7 @@ $voice-record-stop-border-color: #E3E8F0; $voice-record-stop-symbol-color: #ff4b55; $voice-record-waveform-bg-color: #E3E8F0; $voice-record-waveform-fg-color: $muted-fg-color; +$voice-record-waveform-incomplete-fg-color: #C1C6CD; $voice-record-live-circle-color: #ff4b55; $roomtile-preview-color: #9e9e9e; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 342b5dfd9a..20ccc2ee41 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -186,6 +186,7 @@ $voice-record-stop-border-color: #E3E8F0; $voice-record-stop-symbol-color: #ff4b55; // $warning-color, but without letting people change it in themes $voice-record-waveform-bg-color: #E3E8F0; $voice-record-waveform-fg-color: $muted-fg-color; +$voice-record-waveform-incomplete-fg-color: #C1C6CD; $voice-record-live-circle-color: #ff4b55; // $warning-color, but without letting people change it in themes $roomtile-preview-color: $secondary-fg-color; diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/voice_messages/PlaybackWaveform.tsx index 89de908575..de38de63bb 100644 --- a/src/components/views/voice_messages/PlaybackWaveform.tsx +++ b/src/components/views/voice_messages/PlaybackWaveform.tsx @@ -19,6 +19,7 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; import {arraySeed, arrayTrimFill} from "../../../utils/arrays"; import Waveform from "./Waveform"; import {Playback, PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback"; +import {percentageOf} from "../../../utils/numbers"; interface IProps { playback: Playback; @@ -26,6 +27,7 @@ interface IProps { interface IState { heights: number[]; + progress: number; } /** @@ -36,9 +38,13 @@ export default class PlaybackWaveform extends React.PureComponent { + const progress = percentageOf(time[0], 0, time[1]); + this.setState({progress}); + }; + public render() { - return ; + return ; } } diff --git a/src/components/views/voice_messages/Waveform.tsx b/src/components/views/voice_messages/Waveform.tsx index 5fa68dcadc..840a5a12b3 100644 --- a/src/components/views/voice_messages/Waveform.tsx +++ b/src/components/views/voice_messages/Waveform.tsx @@ -16,9 +16,11 @@ limitations under the License. import React from "react"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import classNames from "classnames"; interface IProps { relHeights: number[]; // relative heights (0-1) + progress: number; // percent complete, 0-1, default 100% } interface IState { @@ -28,9 +30,16 @@ interface IState { * A simple waveform component. This renders bars (centered vertically) for each * height provided in the component properties. Updating the properties will update * the rendered waveform. + * + * For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be + * "filled", as a demonstration of the progress property. */ @replaceableComponent("views.voice_messages.Waveform") export default class Waveform extends React.PureComponent { + public static defaultProps = { + progress: 1, + }; + public constructor(props) { super(props); } @@ -38,7 +47,13 @@ export default class Waveform extends React.PureComponent { public render() { return
    {this.props.relHeights.map((h, i) => { - return ; + const progress = this.props.progress; + const isCompleteBar = (i / this.props.relHeights.length) <= progress && progress > 0; + const classes = classNames({ + 'mx_Waveform_bar': true, + 'mx_Waveform_bar_100pct': isCompleteBar, + }); + return ; })}
    ; } From c2bcdae8a9bb26ab2aa69879c51c37e5ac079a14 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 27 Apr 2021 23:04:49 -0600 Subject: [PATCH 32/43] Switch global var to the store for easier debugging --- src/@types/global.d.ts | 4 ++-- src/stores/VoiceRecordingStore.ts | 2 ++ src/voice/VoiceRecording.ts | 2 -- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 01bb51732e..0ab26ef943 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -39,9 +39,9 @@ import {ModalWidgetStore} from "../stores/ModalWidgetStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import VoipUserMapper from "../VoipUserMapper"; import {SpaceStoreClass} from "../stores/SpaceStore"; -import {VoiceRecording} from "../voice/VoiceRecording"; import TypingStore from "../stores/TypingStore"; import { EventIndexPeg } from "../indexing/EventIndexPeg"; +import {VoiceRecordingStore} from "../stores/VoiceRecordingStore"; declare global { interface Window { @@ -73,7 +73,7 @@ declare global { mxModalWidgetStore: ModalWidgetStore; mxVoipUserMapper: VoipUserMapper; mxSpaceStore: SpaceStoreClass; - mxVoiceRecorder: typeof VoiceRecording; + mxVoiceRecordingStore: VoiceRecordingStore; mxTypingStore: TypingStore; mxEventIndexPeg: EventIndexPeg; } diff --git a/src/stores/VoiceRecordingStore.ts b/src/stores/VoiceRecordingStore.ts index cc999f23f8..8ee44359fb 100644 --- a/src/stores/VoiceRecordingStore.ts +++ b/src/stores/VoiceRecordingStore.ts @@ -78,3 +78,5 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { return this.updateState({recording: null}); } } + +window.mxVoiceRecordingStore = VoiceRecordingStore.instance; diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts index 6b0b84ad18..3a083a60b1 100644 --- a/src/voice/VoiceRecording.ts +++ b/src/voice/VoiceRecording.ts @@ -315,5 +315,3 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return this.mxc; } } - -window.mxVoiceRecorder = VoiceRecording; From 617d74f9cdbf8b6cd4d7613088a2b3dee0e1ad13 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 27 Apr 2021 23:07:45 -0600 Subject: [PATCH 33/43] Treat 119.68 seconds as 1:59 instead of 1:60 --- src/components/views/voice_messages/Clock.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/voice_messages/Clock.tsx b/src/components/views/voice_messages/Clock.tsx index 8b71f6b7fe..23e6762c52 100644 --- a/src/components/views/voice_messages/Clock.tsx +++ b/src/components/views/voice_messages/Clock.tsx @@ -42,7 +42,7 @@ export default class Clock extends React.Component { public render() { const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0'); - const seconds = Math.round(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis + const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis return {minutes}:{seconds}; } } From f0ff2fc38d7d43cb09314c5106f9ec88e59a3461 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 27 Apr 2021 23:30:54 -0600 Subject: [PATCH 34/43] Ensure we capture an absolute maximum amount of audio samples We say the limit is 2 minutes, not 1m59s, so let's give the user that last frame. --- src/voice/VoiceRecording.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts index 3a083a60b1..eb705200ca 100644 --- a/src/voice/VoiceRecording.ts +++ b/src/voice/VoiceRecording.ts @@ -217,8 +217,19 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // Now that we've updated the data/waveform, let's do a time check. We don't want to // go horribly over the limit. We also emit a warning state if needed. - const secondsLeft = TARGET_MAX_LENGTH - timeSeconds; - if (secondsLeft <= 0) { + // + // We use the recorder's perspective of time to make sure we don't cut off the last + // frame of audio, otherwise we end up with a 1:59 clip (119.68 seconds). This extra + // safety can allow us to overshoot the target a bit, but at least when we say 2min + // maximum we actually mean it. + // + // In testing, recorder time and worker time lag by about 400ms, which is roughly the + // time needed to encode a sample/frame. + // + // Ref for recorderSeconds: https://github.com/chris-rudmin/opus-recorder#instance-fields + const recorderSeconds = this.recorder.encodedSamplePosition / 48000; + const secondsLeft = TARGET_MAX_LENGTH - recorderSeconds; + if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame // noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping this.stop(); } else if (secondsLeft <= TARGET_WARN_TIME_LEFT) { @@ -253,9 +264,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } // Disconnect the source early to start shutting down resources + await this.recorder.stop(); // stop first to flush the last frame this.recorderSource.disconnect(); this.recorderWorklet.disconnect(); - await this.recorder.stop(); // close the context after the recorder so the recorder doesn't try to // connect anything to the context (this would generate a warning) From 8213c48b7f8e1947803faa8ddee8c0679aaa5ab5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 27 Apr 2021 23:34:26 -0600 Subject: [PATCH 35/43] Fix first waveform bar highlighting in playback at 0% --- src/components/views/voice_messages/PlaybackWaveform.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/voice_messages/PlaybackWaveform.tsx index de38de63bb..123af5dfa5 100644 --- a/src/components/views/voice_messages/PlaybackWaveform.tsx +++ b/src/components/views/voice_messages/PlaybackWaveform.tsx @@ -57,7 +57,8 @@ export default class PlaybackWaveform extends React.PureComponent { - const progress = percentageOf(time[0], 0, time[1]); + // Track percentages to very coarse precision, otherwise 0.002 ends up highlighting a bar. + const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(1)); this.setState({progress}); }; From 8fca32d651c672bd7c26213e6ca879d8b48d2eb7 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 27 Apr 2021 23:48:07 -0600 Subject: [PATCH 36/43] Clean up imports from refactoring --- src/components/views/voice_messages/PlayPauseButton.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/voice_messages/PlayPauseButton.tsx index b4f69b02bc..2ed0368467 100644 --- a/src/components/views/voice_messages/PlayPauseButton.tsx +++ b/src/components/views/voice_messages/PlayPauseButton.tsx @@ -16,12 +16,10 @@ limitations under the License. import React, {ReactNode} from "react"; import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {VoiceRecording} from "../../../voice/VoiceRecording"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {_t} from "../../../languageHandler"; import {Playback, PlaybackState} from "../../../voice/Playback"; import classNames from "classnames"; -import {UPDATE_EVENT} from "../../../stores/AsyncStore"; interface IProps { // Playback instance to manipulate. Cannot change during the component lifecycle. From 5966fade0b4875654b597fcaded0141502edd749 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 28 Apr 2021 09:04:02 +0100 Subject: [PATCH 37/43] Fix joining room using via servers regression --- src/stores/RoomViewStore.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 601c77cdf3..8dda310ab4 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -60,6 +60,8 @@ const INITIAL_STATE = { replyingToEvent: null, shouldPeek: false, + + viaServers: [], }; /** @@ -113,6 +115,7 @@ class RoomViewStore extends Store { this.setState({ roomId: null, roomAlias: null, + viaServers: [], }); break; case 'view_room_error': @@ -191,6 +194,7 @@ class RoomViewStore extends Store { replyingToEvent: null, // pull the user out of Room Settings isEditingSettings: false, + viaServers: payload.via_servers, }; // Allow being given an event to be replied to when switching rooms but sanity check its for this room @@ -226,6 +230,7 @@ class RoomViewStore extends Store { roomAlias: payload.room_alias, roomLoading: true, roomLoadError: null, + viaServers: payload.via_servers, }); try { const result = await MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias); @@ -261,6 +266,7 @@ class RoomViewStore extends Store { roomAlias: payload.room_alias, roomLoading: false, roomLoadError: payload.err, + viaServers: [], }); } @@ -273,8 +279,9 @@ class RoomViewStore extends Store { const cli = MatrixClientPeg.get(); const address = this.state.roomAlias || this.state.roomId; try { + const viaServers = this.state.viaServers || []; await retry(() => cli.joinRoom(address, { - viaServers: payload.via_servers, + viaServers, ...payload.opts, }), NUM_JOIN_RETRY, (err) => { // if we received a Gateway timeout then retry From 27731ac25bacee066284f5e9fff6654f769f952b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 28 Apr 2021 09:07:02 +0100 Subject: [PATCH 38/43] tidy --- src/stores/RoomViewStore.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 8dda310ab4..a5bdb7ef33 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -278,8 +278,8 @@ class RoomViewStore extends Store { const cli = MatrixClientPeg.get(); const address = this.state.roomAlias || this.state.roomId; + const viaServers = this.state.viaServers || []; try { - const viaServers = this.state.viaServers || []; await retry(() => cli.joinRoom(address, { viaServers, ...payload.opts, From 69f797eda4cf384e89e6e6fe521065f6958a6403 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 28 Apr 2021 14:17:57 +0100 Subject: [PATCH 39/43] Add test coverage collection script This makes it clear to how collect basic test coverage when desired. --- package.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 39c3b68103..e54de8a96c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "lint:types": "tsc --noEmit --jsx react", "lint:style": "stylelint 'res/css/**/*.scss'", "test": "jest", - "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080" + "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080", + "coverage": "yarn test --coverage" }, "dependencies": { "@babel/runtime": "^7.12.5", @@ -188,6 +189,12 @@ }, "transformIgnorePatterns": [ "/node_modules/(?!matrix-js-sdk).+$" + ], + "collectCoverageFrom": [ + "/src/**/*.{js,ts,tsx}" + ], + "coverageReporters": [ + "text" ] } } From d4acd0e41ceddf74b7123df07def785ed5b48f73 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 28 Apr 2021 09:28:46 -0600 Subject: [PATCH 40/43] Remove excess IState --- src/components/views/voice_messages/PlayPauseButton.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/voice_messages/PlayPauseButton.tsx index 2ed0368467..1f87eb012d 100644 --- a/src/components/views/voice_messages/PlayPauseButton.tsx +++ b/src/components/views/voice_messages/PlayPauseButton.tsx @@ -29,18 +29,14 @@ interface IProps { playbackPhase: PlaybackState; } -interface IState { -} - /** * Displays a play/pause button (activating the play/pause function of the recorder) * to be displayed in reference to a recording. */ @replaceableComponent("views.voice_messages.PlayPauseButton") -export default class PlayPauseButton extends React.PureComponent { +export default class PlayPauseButton extends React.PureComponent { public constructor(props) { super(props); - this.state = {}; } private onClick = async () => { From 6764b8d645df5cdac8977836f3386dd8fbf5bad5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 28 Apr 2021 09:29:31 -0600 Subject: [PATCH 41/43] Change symbol names --- res/css/views/voice_messages/_PlayPauseButton.scss | 4 ++-- res/img/element-icons/{pause-symbol.svg => pause.svg} | 0 res/img/element-icons/{play-symbol.svg => play.svg} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename res/img/element-icons/{pause-symbol.svg => pause.svg} (100%) rename res/img/element-icons/{play-symbol.svg => play.svg} (100%) diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/voice_messages/_PlayPauseButton.scss index 0fd31be28a..c8ab162694 100644 --- a/res/css/views/voice_messages/_PlayPauseButton.scss +++ b/res/css/views/voice_messages/_PlayPauseButton.scss @@ -38,7 +38,7 @@ limitations under the License. height: 16px; top: 8px; // center left: 12px; // center - mask-image: url('$(res)/img/element-icons/play-symbol.svg'); + mask-image: url('$(res)/img/element-icons/play.svg'); } &.mx_PlayPauseButton_pause::before { @@ -46,6 +46,6 @@ limitations under the License. height: 12px; top: 10px; // center left: 11px; // center - mask-image: url('$(res)/img/element-icons/pause-symbol.svg'); + mask-image: url('$(res)/img/element-icons/pause.svg'); } } diff --git a/res/img/element-icons/pause-symbol.svg b/res/img/element-icons/pause.svg similarity index 100% rename from res/img/element-icons/pause-symbol.svg rename to res/img/element-icons/pause.svg diff --git a/res/img/element-icons/play-symbol.svg b/res/img/element-icons/play.svg similarity index 100% rename from res/img/element-icons/play-symbol.svg rename to res/img/element-icons/play.svg From b90c845fcb837e986f97bb015f22af11f26817e7 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 28 Apr 2021 10:07:22 -0600 Subject: [PATCH 42/43] Revert "Fixes the two Todays problem in Redaction" --- src/components/structures/MessagePanel.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index f669a10267..132d9ab4c3 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -562,7 +562,7 @@ export default class MessagePanel extends React.Component { return ret; } - _getTilesForEvent(prevEvent, mxEv, last, isGrouped=false, nextEvent, nextEventWithTile) { + _getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) { const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary'); const EventTile = sdk.getComponent('rooms.EventTile'); const DateSeparator = sdk.getComponent('messages.DateSeparator'); @@ -582,7 +582,7 @@ export default class MessagePanel extends React.Component { // do we need a date separator since the last event? const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate); - if (wantsDateSeparator && !isGrouped) { + if (wantsDateSeparator) { const dateSeparator =
  • ; ret.push(dateSeparator); } @@ -966,6 +966,7 @@ class CreationGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); + const panel = this.panel; const ret = []; const createEvent = this.createEvent; @@ -981,7 +982,7 @@ class CreationGrouper { // If this m.room.create event should be shown (room upgrade) then show it before the summary if (panel._shouldShowEvent(createEvent)) { // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered - ret.push(...panel._getTilesForEvent(createEvent, createEvent)); + ret.push(...panel._getTilesForEvent(createEvent, createEvent, false)); } for (const ejected of this.ejectedEvents) { @@ -1080,7 +1081,7 @@ class RedactionGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - const isGrouped=true; + const panel = this.panel; const ret = []; const lastShownEvent = this.lastShownEvent; @@ -1097,12 +1098,10 @@ class RedactionGrouper { ); const senders = new Set(); - let eventTiles = this.events.map((e, i) => { senders.add(e.sender); const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; - return panel._getTilesForEvent( - prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); + return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { @@ -1181,7 +1180,7 @@ class MemberGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); - const isGrouped=true; + const panel = this.panel; const lastShownEvent = this.lastShownEvent; const ret = []; @@ -1214,7 +1213,7 @@ class MemberGrouper { // of MemberEventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped); + return panel._getTilesForEvent(e, e, e === lastShownEvent); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { From e0bcccd6003cfab277cff644fb0c369bb6afa2ba Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 28 Apr 2021 17:17:43 +0100 Subject: [PATCH 43/43] Ignore possible coverage output --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e1dd7726e1..50aa10fbfd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /*.log package-lock.json +/coverage /node_modules /lib