From 06c4ba32cd3306c77c085672d5f13174bd573038 Mon Sep 17 00:00:00 2001 From: kegsay Date: Tue, 20 Sep 2022 16:32:39 +0100 Subject: [PATCH 001/189] Store refactor: make it easier to test stores (#9290) * refactor: convert RoomViewStore from flux Store to standard EventEmitter Parts of a series of experimental changes to improve the design of stores. * Use a gen5 store for RoomViewStore for now due to lock handling * Revert "Use a gen5 store for RoomViewStore for now due to lock handling" This reverts commit 1076af071d997d87b8ae0b0dcddfd1ae428665af. * Add untilEmission and tweak untilDispatch; use it in RoomViewStore * Add more RVS tests; remove custom room ID listener code and use EventEmitter * Better comments * Null guard `dis` as tests mock out `defaultDispatcher` * Additional tests --- src/components/structures/RoomView.tsx | 9 +- .../views/right_panel/TimelineCard.tsx | 9 +- src/components/views/rooms/RoomList.tsx | 7 +- src/components/views/voip/PipView.tsx | 7 +- src/stores/RoomViewStore.tsx | 85 +++++---- src/stores/room-list/RoomListStore.ts | 3 +- src/stores/room-list/SlidingRoomListStore.ts | 3 +- test/components/structures/RoomView-test.tsx | 8 +- test/stores/RoomViewStore-test.tsx | 174 +++++++++++++----- test/test-utils/test-utils.ts | 13 -- test/test-utils/utilities.ts | 100 +++++++++- 11 files changed, 289 insertions(+), 129 deletions(-) diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index b94c8d4ea4a..fed78d76177 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -24,7 +24,6 @@ import React, { createRef, ReactElement, ReactNode, RefObject, useContext } from import classNames from 'classnames'; import { IRecommendedVersion, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; -import { EventSubscription } from "fbemitter"; import { ISearchResults } from 'matrix-js-sdk/src/@types/search'; import { logger } from "matrix-js-sdk/src/logger"; import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; @@ -366,7 +365,6 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement export class RoomView extends React.Component { private readonly dispatcherRef: string; - private readonly roomStoreToken: EventSubscription; private settingWatchers: string[]; private unmounted = false; @@ -439,7 +437,7 @@ export class RoomView extends React.Component { context.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); context.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); // Start listening for RoomViewStore updates - this.roomStoreToken = RoomViewStore.instance.addListener(this.onRoomViewStoreUpdate); + RoomViewStore.instance.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); @@ -883,10 +881,7 @@ export class RoomView extends React.Component { window.removeEventListener('beforeunload', this.onPageUnload); - // Remove RoomStore listener - if (this.roomStoreToken) { - this.roomStoreToken.remove(); - } + RoomViewStore.instance.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index 941a74163c7..f1eea5ad491 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import React from 'react'; -import { EventSubscription } from "fbemitter"; import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set'; import { NotificationCountType, Room } from 'matrix-js-sdk/src/models/room'; @@ -42,6 +41,7 @@ import JumpToBottomButton from '../rooms/JumpToBottomButton'; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import Measured from '../elements/Measured'; import Heading from '../typography/Heading'; +import { UPDATE_EVENT } from '../../../stores/AsyncStore'; interface IProps { room: Room; @@ -77,7 +77,6 @@ export default class TimelineCard extends React.Component { private layoutWatcherRef: string; private timelinePanel = React.createRef(); private card = React.createRef(); - private roomStoreToken: EventSubscription; private readReceiptsSettingWatcher: string; constructor(props: IProps) { @@ -92,7 +91,7 @@ export default class TimelineCard extends React.Component { } public componentDidMount(): void { - this.roomStoreToken = RoomViewStore.instance.addListener(this.onRoomViewStoreUpdate); + RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.dispatcherRef = dis.register(this.onAction); this.readReceiptsSettingWatcher = SettingsStore.watchSetting("showReadReceipts", null, (...[,,, value]) => this.setState({ showReadReceipts: value as boolean }), @@ -103,9 +102,7 @@ export default class TimelineCard extends React.Component { } public componentWillUnmount(): void { - // Remove RoomStore listener - - this.roomStoreToken?.remove(); + RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); if (this.readReceiptsSettingWatcher) { SettingsStore.unwatchSetting(this.readReceiptsSettingWatcher); diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 0d6756a7e17..13b1011088d 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as fbEmitter from "fbemitter"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { Room } from "matrix-js-sdk/src/models/room"; import React, { ComponentType, createRef, ReactComponentElement, RefObject } from "react"; @@ -37,6 +36,7 @@ import { UIComponent } from "../../../settings/UIFeature"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { ITagMap } from "../../../stores/room-list/algorithms/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import { RoomViewStore } from "../../../stores/RoomViewStore"; import { @@ -403,7 +403,6 @@ const TAG_AESTHETICS: ITagAestheticsMap = { export default class RoomList extends React.PureComponent { private dispatcherRef; - private roomStoreToken: fbEmitter.EventSubscription; private treeRef = createRef(); private favouriteMessageWatcher: string; @@ -422,7 +421,7 @@ export default class RoomList extends React.PureComponent { public componentDidMount(): void { this.dispatcherRef = defaultDispatcher.register(this.onAction); - this.roomStoreToken = RoomViewStore.instance.addListener(this.onRoomViewStoreUpdate); + RoomViewStore.instance.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists); this.favouriteMessageWatcher = @@ -437,7 +436,7 @@ export default class RoomList extends React.PureComponent { RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists); SettingsStore.unwatchSetting(this.favouriteMessageWatcher); defaultDispatcher.unregister(this.dispatcherRef); - if (this.roomStoreToken) this.roomStoreToken.remove(); + RoomViewStore.instance.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); } private onRoomViewStoreUpdate = () => { diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx index be411bb1558..691f422e5b7 100644 --- a/src/components/views/voip/PipView.tsx +++ b/src/components/views/voip/PipView.tsx @@ -16,7 +16,6 @@ limitations under the License. import React, { createRef } from 'react'; import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; -import { EventSubscription } from 'fbemitter'; import { logger } from "matrix-js-sdk/src/logger"; import classNames from 'classnames'; import { Room } from "matrix-js-sdk/src/models/room"; @@ -35,6 +34,7 @@ import LegacyCallViewHeader from './LegacyCallView/LegacyCallViewHeader'; import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/ActiveWidgetStore'; import WidgetStore, { IApp } from "../../../stores/WidgetStore"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { UPDATE_EVENT } from '../../../stores/AsyncStore'; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -116,7 +116,6 @@ function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall */ export default class PipView extends React.Component { - private roomStoreToken: EventSubscription; private settingsWatcherRef: string; private movePersistedElement = createRef<() => void>(); @@ -141,7 +140,7 @@ export default class PipView extends React.Component { public componentDidMount() { LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls); LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCalls); - this.roomStoreToken = RoomViewStore.instance.addListener(this.onRoomViewStoreUpdate); + RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); const room = MatrixClientPeg.get()?.getRoom(this.state.viewedRoomId); if (room) { @@ -157,7 +156,7 @@ export default class PipView extends React.Component { LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls); LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls); MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); - this.roomStoreToken?.remove(); + RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); SettingsStore.unwatchSetting(this.settingsWatcherRef); const room = MatrixClientPeg.get().getRoom(this.state.viewedRoomId); if (room) { diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 593dd7115f9..3c127275a25 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -17,7 +17,6 @@ limitations under the License. */ import React, { ReactNode } from "react"; -import { Store } from 'flux/utils'; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; import { ViewRoom as ViewRoomEvent } from "@matrix-org/analytics-events/types/typescript/ViewRoom"; @@ -26,13 +25,13 @@ import { JoinRule } from "matrix-js-sdk/src/@types/partials"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Optional } from "matrix-events-sdk"; +import EventEmitter from "events"; -import dis from '../dispatcher/dispatcher'; +import { defaultDispatcher, MatrixDispatcher } from '../dispatcher/dispatcher'; import { MatrixClientPeg } from '../MatrixClientPeg'; import Modal from '../Modal'; import { _t } from '../languageHandler'; import { getCachedRoomIDForAlias, storeRoomAliasInCache } from '../RoomAliasCache'; -import { ActionPayload } from "../dispatcher/payloads"; import { Action } from "../dispatcher/actions"; import { retry } from "../utils/promise"; import { TimelineRenderingType } from "../contexts/RoomContext"; @@ -50,6 +49,7 @@ import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChang import SettingsStore from "../settings/SettingsStore"; import { SlidingSyncManager } from "../SlidingSyncManager"; import { awaitRoomDownSync } from "../utils/RoomUpgrade"; +import { UPDATE_EVENT } from "./AsyncStore"; const NUM_JOIN_RETRY = 5; @@ -90,47 +90,34 @@ const INITIAL_STATE = { type Listener = (isActive: boolean) => void; /** - * A class for storing application state for RoomView. This is the RoomView's interface -* with a subset of the js-sdk. - * ``` + * A class for storing application state for RoomView. */ -export class RoomViewStore extends Store { +export class RoomViewStore extends EventEmitter { // Important: This cannot be a dynamic getter (lazily-constructed instance) because // otherwise we'll miss view_room dispatches during startup, breaking relaunches of // the app. We need to eagerly create the instance. - public static readonly instance = new RoomViewStore(); + public static readonly instance = new RoomViewStore(defaultDispatcher); private state = INITIAL_STATE; // initialize state - // Keep these out of state to avoid causing excessive/recursive updates - private roomIdActivityListeners: Record = {}; + private dis: MatrixDispatcher; + private dispatchToken: string; - public constructor() { - super(dis); + public constructor(dis: MatrixDispatcher) { + super(); + this.resetDispatcher(dis); } public addRoomListener(roomId: string, fn: Listener): void { - if (!this.roomIdActivityListeners[roomId]) this.roomIdActivityListeners[roomId] = []; - this.roomIdActivityListeners[roomId].push(fn); + this.on(roomId, fn); } public removeRoomListener(roomId: string, fn: Listener): void { - if (this.roomIdActivityListeners[roomId]) { - const i = this.roomIdActivityListeners[roomId].indexOf(fn); - if (i > -1) { - this.roomIdActivityListeners[roomId].splice(i, 1); - } - } else { - logger.warn("Unregistering unrecognised listener (roomId=" + roomId + ")"); - } + this.off(roomId, fn); } private emitForRoom(roomId: string, isActive: boolean): void { - if (!this.roomIdActivityListeners[roomId]) return; - - for (const fn of this.roomIdActivityListeners[roomId]) { - fn.call(null, isActive); - } + this.emit(roomId, isActive); } private setState(newState: Partial): void { @@ -156,17 +143,17 @@ export class RoomViewStore extends Store { // Fired so we can reduce dependency on event emitters to this store, which is relatively // central to the application and can easily cause import cycles. - dis.dispatch({ + this.dis.dispatch({ action: Action.ActiveRoomChanged, oldRoomId: lastRoomId, newRoomId: this.state.roomId, }); } - this.__emitChange(); + this.emit(UPDATE_EVENT); } - protected __onDispatch(payload): void { // eslint-disable-line @typescript-eslint/naming-convention + private onDispatch(payload): void { // eslint-disable-line @typescript-eslint/naming-convention switch (payload.action) { // view_room: // - room_alias: '#somealias:matrix.org' @@ -243,7 +230,7 @@ export class RoomViewStore extends Store { // both room and search timeline rendering types, search will get auto-closed by RoomView at this time. if ([TimelineRenderingType.Room, TimelineRenderingType.Search].includes(payload.context)) { if (payload.event && payload.event.getRoomId() !== this.state.roomId) { - dis.dispatch({ + this.dis.dispatch({ action: Action.ViewRoom, room_id: payload.event.getRoomId(), replyingToEvent: payload.event, @@ -283,9 +270,9 @@ export class RoomViewStore extends Store { }); } if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) { - if (this.state.roomId) { + if (this.state.subscribingRoomId && this.state.subscribingRoomId !== payload.room_id) { // unsubscribe from this room, but don't await it as we don't care when this gets done. - SlidingSyncManager.instance.setRoomVisible(this.state.roomId, false); + SlidingSyncManager.instance.setRoomVisible(this.state.subscribingRoomId, false); } this.setState({ subscribingRoomId: payload.room_id, @@ -306,11 +293,11 @@ export class RoomViewStore extends Store { // Whilst we were subscribing another room was viewed, so stop what we're doing and // unsubscribe if (this.state.subscribingRoomId !== payload.room_id) { - SlidingSyncManager.instance.setRoomVisible(this.state.roomId, false); + SlidingSyncManager.instance.setRoomVisible(payload.room_id, false); return; } // Re-fire the payload: we won't re-process it because the prev room ID == payload room ID now - dis.dispatch({ + this.dis.dispatch({ ...payload, }); return; @@ -346,7 +333,7 @@ export class RoomViewStore extends Store { this.setState(newState); if (payload.auto_join) { - dis.dispatch({ + this.dis.dispatch({ ...payload, action: Action.JoinRoom, roomId: payload.room_id, @@ -378,7 +365,7 @@ export class RoomViewStore extends Store { roomId = result.room_id; } catch (err) { logger.error("RVS failed to get room id for alias: ", err); - dis.dispatch({ + this.dis.dispatch({ action: Action.ViewRoomError, room_id: null, room_alias: payload.room_alias, @@ -389,7 +376,7 @@ export class RoomViewStore extends Store { } // Re-fire the payload with the newly found room_id - dis.dispatch({ + this.dis.dispatch({ ...payload, room_id: roomId, }); @@ -427,13 +414,13 @@ export class RoomViewStore extends Store { // We do *not* clear the 'joining' flag because the Room object and/or our 'joined' member event may not // have come down the sync stream yet, and that's the point at which we'd consider the user joined to the // room. - dis.dispatch({ + this.dis.dispatch({ action: Action.JoinRoomReady, roomId, metricsTrigger: payload.metricsTrigger, }); } catch (err) { - dis.dispatch({ + this.dis.dispatch({ action: Action.JoinRoomError, roomId, err, @@ -493,6 +480,24 @@ export class RoomViewStore extends Store { this.state = Object.assign({}, INITIAL_STATE); } + /** + * Reset which dispatcher should be used to listen for actions. The old dispatcher will be + * unregistered. + * @param dis The new dispatcher to use. + */ + public resetDispatcher(dis: MatrixDispatcher) { + if (this.dispatchToken) { + this.dis.unregister(this.dispatchToken); + } + this.dis = dis; + if (dis) { + // Some tests mock the dispatcher file resulting in an empty defaultDispatcher + // so rather than dying here, just ignore it. When we no longer mock files like this, + // we should remove the null check. + this.dispatchToken = this.dis.register(this.onDispatch.bind(this)); + } + } + // The room ID of the room currently being viewed public getRoomId(): Optional { return this.state.roomId; diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index c74a58494ae..83c79a16a9a 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -39,6 +39,7 @@ import { SpaceWatcher } from "./SpaceWatcher"; import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators"; import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; import { SlidingRoomListStoreClass } from "./SlidingRoomListStore"; +import { UPDATE_EVENT } from "../AsyncStore"; interface IState { // state is tracked in underlying classes @@ -104,7 +105,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements this.readyStore.useUnitTestClient(forcedClient); } - RoomViewStore.instance.addListener(() => this.handleRVSUpdate({})); + RoomViewStore.instance.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({})); this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated); this.setupWatchers(); diff --git a/src/stores/room-list/SlidingRoomListStore.ts b/src/stores/room-list/SlidingRoomListStore.ts index b8ba48659f1..3d532fe0c93 100644 --- a/src/stores/room-list/SlidingRoomListStore.ts +++ b/src/stores/room-list/SlidingRoomListStore.ts @@ -30,6 +30,7 @@ import SpaceStore from "../spaces/SpaceStore"; import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces"; import { LISTS_LOADING_EVENT } from "./RoomListStore"; import { RoomViewStore } from "../RoomViewStore"; +import { UPDATE_EVENT } from "../AsyncStore"; interface IState { // state is tracked in underlying classes @@ -313,7 +314,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl logger.info("SlidingRoomListStore.onReady"); // permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation. SlidingSyncManager.instance.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this)); - RoomViewStore.instance.addListener(this.onRoomViewStoreUpdated.bind(this)); + RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); if (SpaceStore.instance.activeSpace) { this.onSelectedSpaceUpdated(SpaceStore.instance.activeSpace, false); diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index a9e9fe87ff4..dd45c7df099 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -42,6 +42,7 @@ import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStor import { LocalRoom, LocalRoomState } from "../../../src/models/LocalRoom"; import { DirectoryMember } from "../../../src/utils/direct-messages"; import { createDmLocalRoom } from "../../../src/utils/dm/createDmLocalRoom"; +import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; const RoomView = wrapInMatrixClientContext(_RoomView); @@ -74,12 +75,13 @@ describe("RoomView", () => { const mountRoomView = async (): Promise => { if (RoomViewStore.instance.getRoomId() !== room.roomId) { const switchedRoom = new Promise(resolve => { - const subscription = RoomViewStore.instance.addListener(() => { + const subFn = () => { if (RoomViewStore.instance.getRoomId()) { - subscription.remove(); + RoomViewStore.instance.off(UPDATE_EVENT, subFn); resolve(); } - }); + }; + RoomViewStore.instance.on(UPDATE_EVENT, subFn); }); defaultDispatcher.dispatch({ diff --git a/test/stores/RoomViewStore-test.tsx b/test/stores/RoomViewStore-test.tsx index 6d2bbb33f84..3ea402438db 100644 --- a/test/stores/RoomViewStore-test.tsx +++ b/test/stores/RoomViewStore-test.tsx @@ -18,13 +18,13 @@ import { Room } from 'matrix-js-sdk/src/matrix'; import { RoomViewStore } from '../../src/stores/RoomViewStore'; import { Action } from '../../src/dispatcher/actions'; -import * as testUtils from '../test-utils'; -import { flushPromises, getMockClientWithEventEmitter } from '../test-utils'; +import { getMockClientWithEventEmitter, untilDispatch, untilEmission } from '../test-utils'; import SettingsStore from '../../src/settings/SettingsStore'; import { SlidingSyncManager } from '../../src/SlidingSyncManager'; import { TimelineRenderingType } from '../../src/contexts/RoomContext'; - -const dispatch = testUtils.getDispatchForStore(RoomViewStore.instance); +import { MatrixDispatcher } from '../../src/dispatcher/dispatcher'; +import { UPDATE_EVENT } from '../../src/stores/AsyncStore'; +import { ActiveRoomChangedPayload } from '../../src/dispatcher/payloads/ActiveRoomChangedPayload'; jest.mock('../../src/utils/DMRoomMap', () => { const mock = { @@ -40,13 +40,18 @@ jest.mock('../../src/utils/DMRoomMap', () => { describe('RoomViewStore', function() { const userId = '@alice:server'; + const roomId = "!randomcharacters:aser.ver"; + // we need to change the alias to ensure cache misses as the cache exists + // through all tests. + let alias = "#somealias2:aser.ver"; const mockClient = getMockClientWithEventEmitter({ joinRoom: jest.fn(), getRoom: jest.fn(), getRoomIdForAlias: jest.fn(), isGuest: jest.fn(), }); - const room = new Room('!room:server', mockClient, userId); + const room = new Room(roomId, mockClient, userId); + let dis: MatrixDispatcher; beforeEach(function() { jest.clearAllMocks(); @@ -56,54 +61,131 @@ describe('RoomViewStore', function() { mockClient.isGuest.mockReturnValue(false); // Reset the state of the store + dis = new MatrixDispatcher(); RoomViewStore.instance.reset(); + RoomViewStore.instance.resetDispatcher(dis); }); it('can be used to view a room by ID and join', async () => { - dispatch({ action: Action.ViewRoom, room_id: '!randomcharacters:aser.ver' }); - dispatch({ action: Action.JoinRoom }); - await flushPromises(); - expect(mockClient.joinRoom).toHaveBeenCalledWith('!randomcharacters:aser.ver', { viaServers: [] }); + dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); + dis.dispatch({ action: Action.JoinRoom }); + await untilDispatch(Action.JoinRoomReady, dis); + expect(mockClient.joinRoom).toHaveBeenCalledWith(roomId, { viaServers: [] }); expect(RoomViewStore.instance.isJoining()).toBe(true); }); - it('can be used to view a room by alias and join', async () => { - const roomId = "!randomcharacters:aser.ver"; - const alias = "#somealias2:aser.ver"; + it('can auto-join a room', async () => { + dis.dispatch({ action: Action.ViewRoom, room_id: roomId, auto_join: true }); + await untilDispatch(Action.JoinRoomReady, dis); + expect(mockClient.joinRoom).toHaveBeenCalledWith(roomId, { viaServers: [] }); + expect(RoomViewStore.instance.isJoining()).toBe(true); + }); - mockClient.getRoomIdForAlias.mockResolvedValue({ room_id: roomId, servers: [] }); + it('emits ActiveRoomChanged when the viewed room changes', async () => { + const roomId2 = "!roomid:2"; + dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); + let payload = await untilDispatch(Action.ActiveRoomChanged, dis) as ActiveRoomChangedPayload; + expect(payload.newRoomId).toEqual(roomId); + expect(payload.oldRoomId).toEqual(null); + + dis.dispatch({ action: Action.ViewRoom, room_id: roomId2 }); + payload = await untilDispatch(Action.ActiveRoomChanged, dis) as ActiveRoomChangedPayload; + expect(payload.newRoomId).toEqual(roomId2); + expect(payload.oldRoomId).toEqual(roomId); + }); - dispatch({ action: Action.ViewRoom, room_alias: alias }); - await flushPromises(); - await flushPromises(); + it('invokes room activity listeners when the viewed room changes', async () => { + const roomId2 = "!roomid:2"; + const callback = jest.fn(); + RoomViewStore.instance.addRoomListener(roomId, callback); + dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); + await untilDispatch(Action.ActiveRoomChanged, dis) as ActiveRoomChangedPayload; + expect(callback).toHaveBeenCalledWith(true); + expect(callback).not.toHaveBeenCalledWith(false); + + dis.dispatch({ action: Action.ViewRoom, room_id: roomId2 }); + await untilDispatch(Action.ActiveRoomChanged, dis) as ActiveRoomChangedPayload; + expect(callback).toHaveBeenCalledWith(false); + }); + + it('can be used to view a room by alias and join', async () => { + mockClient.getRoomIdForAlias.mockResolvedValue({ room_id: roomId, servers: [] }); + dis.dispatch({ action: Action.ViewRoom, room_alias: alias }); + await untilDispatch((p) => { // wait for the re-dispatch with the room ID + return p.action === Action.ViewRoom && p.room_id === roomId; + }, dis); // roomId is set to id of the room alias expect(RoomViewStore.instance.getRoomId()).toBe(roomId); // join the room - dispatch({ action: Action.JoinRoom }); + dis.dispatch({ action: Action.JoinRoom }, true); - expect(RoomViewStore.instance.isJoining()).toBeTruthy(); - await flushPromises(); + await untilDispatch(Action.JoinRoomReady, dis); + expect(RoomViewStore.instance.isJoining()).toBeTruthy(); expect(mockClient.joinRoom).toHaveBeenCalledWith(alias, { viaServers: [] }); }); + it('emits ViewRoomError if the alias lookup fails', async () => { + alias = "#something-different:to-ensure-cache-miss"; + mockClient.getRoomIdForAlias.mockRejectedValue(new Error("network error or something")); + dis.dispatch({ action: Action.ViewRoom, room_alias: alias }); + const payload = await untilDispatch(Action.ViewRoomError, dis); + expect(payload.room_id).toBeNull(); + expect(payload.room_alias).toEqual(alias); + expect(RoomViewStore.instance.getRoomAlias()).toEqual(alias); + }); + + it('emits JoinRoomError if joining the room fails', async () => { + const joinErr = new Error("network error or something"); + mockClient.joinRoom.mockRejectedValue(joinErr); + dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); + dis.dispatch({ action: Action.JoinRoom }); + await untilDispatch(Action.JoinRoomError, dis); + expect(RoomViewStore.instance.isJoining()).toBe(false); + expect(RoomViewStore.instance.getJoinError()).toEqual(joinErr); + }); + it('remembers the event being replied to when swapping rooms', async () => { - dispatch({ action: Action.ViewRoom, room_id: '!randomcharacters:aser.ver' }); - await flushPromises(); + dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); + await untilDispatch(Action.ActiveRoomChanged, dis); const replyToEvent = { - getRoomId: () => '!randomcharacters:aser.ver', + getRoomId: () => roomId, }; - dispatch({ action: 'reply_to_event', event: replyToEvent, context: TimelineRenderingType.Room }); - await flushPromises(); + dis.dispatch({ action: 'reply_to_event', event: replyToEvent, context: TimelineRenderingType.Room }); + await untilEmission(RoomViewStore.instance, UPDATE_EVENT); expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent); // view the same room, should remember the event. - dispatch({ action: Action.ViewRoom, room_id: '!randomcharacters:aser.ver' }); - await flushPromises(); + // set the highlighed flag to make sure there is a state change so we get an update event + dis.dispatch({ action: Action.ViewRoom, room_id: roomId, highlighted: true }); + await untilEmission(RoomViewStore.instance, UPDATE_EVENT); expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent); }); + it('swaps to the replied event room if it is not the current room', async () => { + const roomId2 = "!room2:bar"; + dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); + await untilDispatch(Action.ActiveRoomChanged, dis); + const replyToEvent = { + getRoomId: () => roomId2, + }; + dis.dispatch({ action: 'reply_to_event', event: replyToEvent, context: TimelineRenderingType.Room }); + await untilDispatch(Action.ViewRoom, dis); + expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent); + expect(RoomViewStore.instance.getRoomId()).toEqual(roomId2); + }); + + it('removes the roomId on ViewHomePage', async () => { + dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); + await untilDispatch(Action.ActiveRoomChanged, dis); + expect(RoomViewStore.instance.getRoomId()).toEqual(roomId); + + dis.dispatch({ action: Action.ViewHomePage }); + await untilEmission(RoomViewStore.instance, UPDATE_EVENT); + expect(RoomViewStore.instance.getRoomId()).toBeNull(); + }); + describe('Sliding Sync', function() { beforeEach(() => { jest.spyOn(SettingsStore, 'getValue').mockImplementation((settingName, roomId, value) => { @@ -117,9 +199,8 @@ describe('RoomViewStore', function() { Promise.resolve(""), ); const subscribedRoomId = "!sub1:localhost"; - dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId }); - await flushPromises(); - await flushPromises(); + dis.dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId }); + await untilDispatch(Action.ActiveRoomChanged, dis); expect(RoomViewStore.instance.getRoomId()).toBe(subscribedRoomId); expect(setRoomVisible).toHaveBeenCalledWith(subscribedRoomId, true); }); @@ -129,20 +210,27 @@ describe('RoomViewStore', function() { const setRoomVisible = jest.spyOn(SlidingSyncManager.instance, "setRoomVisible").mockReturnValue( Promise.resolve(""), ); - const subscribedRoomId = "!sub2:localhost"; - const subscribedRoomId2 = "!sub3:localhost"; - dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId }); - dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId2 }); - // sub(1) then unsub(1) sub(2) - expect(setRoomVisible).toHaveBeenCalledTimes(3); - await flushPromises(); - await flushPromises(); - // this should not churn, extra call to allow unsub(1) - expect(setRoomVisible).toHaveBeenCalledTimes(4); - // flush a bit more to ensure this doesn't change - await flushPromises(); - await flushPromises(); - expect(setRoomVisible).toHaveBeenCalledTimes(4); + const subscribedRoomId = "!sub1:localhost"; + const subscribedRoomId2 = "!sub2:localhost"; + dis.dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId }, true); + dis.dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId2 }, true); + await untilDispatch(Action.ActiveRoomChanged, dis); + // sub(1) then unsub(1) sub(2), unsub(1) + const wantCalls = [ + [subscribedRoomId, true], + [subscribedRoomId, false], + [subscribedRoomId2, true], + [subscribedRoomId, false], + ]; + expect(setRoomVisible).toHaveBeenCalledTimes(wantCalls.length); + wantCalls.forEach((v, i) => { + try { + expect(setRoomVisible.mock.calls[i][0]).toEqual(v[0]); + expect(setRoomVisible.mock.calls[i][1]).toEqual(v[1]); + } catch (err) { + throw new Error(`i=${i} got ${setRoomVisible.mock.calls[i]} want ${v}`); + } + }); }); }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index ed600403466..029c88df8f5 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -35,7 +35,6 @@ import { normalize } from "matrix-js-sdk/src/utils"; import { ReEmitter } from "matrix-js-sdk/src/ReEmitter"; import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg'; -import dis from '../../src/dispatcher/dispatcher'; import { makeType } from "../../src/utils/TypeUtils"; import { ValidatedServerConfig } from "../../src/utils/ValidatedServerConfig"; import { EnhancedMap } from "../../src/utils/maps"; @@ -456,18 +455,6 @@ export function mkServerConfig(hsUrl, isUrl) { }); } -export function getDispatchForStore(store) { - // Mock the dispatcher by gut-wrenching. Stores can only __emitChange whilst a - // dispatcher `_isDispatching` is true. - return (payload) => { - // these are private properties in flux dispatcher - // fool ts - (dis as any)._isDispatching = true; - (dis as any)._callbacks[store._dispatchToken](payload); - (dis as any)._isDispatching = false; - }; -} - // These methods make some use of some private methods on the AsyncStoreWithClient to simplify getting into a consistent // ready state without needing to wire up a dispatcher and pretend to be a js-sdk client. diff --git a/test/test-utils/utilities.ts b/test/test-utils/utilities.ts index 4da1389477f..76859da263a 100644 --- a/test/test-utils/utilities.ts +++ b/test/test-utils/utilities.ts @@ -24,18 +24,104 @@ import { DispatcherAction } from "../../src/dispatcher/actions"; export const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r)); -export function untilDispatch(waitForAction: DispatcherAction): Promise { - let dispatchHandle: string; - return new Promise(resolve => { - dispatchHandle = defaultDispatcher.register(payload => { - if (payload.action === waitForAction) { - defaultDispatcher.unregister(dispatchHandle); - resolve(payload); +/** + * Waits for a certain payload to be dispatched. + * @param waitForAction The action string to wait for or the callback which is invoked for every dispatch. If this returns true, stops waiting. + * @param timeout The max time to wait before giving up and stop waiting. If 0, no timeout. + * @param dispatcher The dispatcher to listen on. + * @returns A promise which resolves when the callback returns true. Resolves with the payload that made it stop waiting. + * Rejects when the timeout is reached. + */ +export function untilDispatch( + waitForAction: DispatcherAction | ((payload: ActionPayload) => boolean), dispatcher=defaultDispatcher, timeout=1000, +): Promise { + const callerLine = new Error().stack.toString().split("\n")[2]; + if (typeof waitForAction === "string") { + const action = waitForAction; + waitForAction = (payload) => { + return payload.action === action; + }; + } + const callback = waitForAction as ((payload: ActionPayload) => boolean); + return new Promise((resolve, reject) => { + let fulfilled = false; + let timeoutId; + // set a timeout handler if needed + if (timeout > 0) { + timeoutId = setTimeout(() => { + if (!fulfilled) { + reject(new Error(`untilDispatch: timed out at ${callerLine}`)); + fulfilled = true; + } + }, timeout); + } + // listen for dispatches + const token = dispatcher.register((p: ActionPayload) => { + const finishWaiting = callback(p); + if (finishWaiting || fulfilled) { // wait until we're told or we timeout + // if we haven't timed out, resolve now with the payload. + if (!fulfilled) { + resolve(p); + fulfilled = true; + } + // cleanup + dispatcher.unregister(token); + if (timeoutId) { + clearTimeout(timeoutId); + } } }); }); } +/** + * Waits for a certain event to be emitted. + * @param emitter The EventEmitter to listen on. + * @param eventName The event string to wait for. + * @param check Optional function which is invoked when the event fires. If this returns true, stops waiting. + * @param timeout The max time to wait before giving up and stop waiting. If 0, no timeout. + * @returns A promise which resolves when the callback returns true or when the event is emitted if + * no callback is provided. Rejects when the timeout is reached. + */ +export function untilEmission( + emitter: EventEmitter, eventName: string, check: ((...args: any[]) => boolean)=undefined, timeout=1000, +): Promise { + const callerLine = new Error().stack.toString().split("\n")[2]; + return new Promise((resolve, reject) => { + let fulfilled = false; + let timeoutId; + // set a timeout handler if needed + if (timeout > 0) { + timeoutId = setTimeout(() => { + if (!fulfilled) { + reject(new Error(`untilEmission: timed out at ${callerLine}`)); + fulfilled = true; + } + }, timeout); + } + const callback = (...args: any[]) => { + // if they supplied a check function, call it now. Bail if it returns false. + if (check) { + if (!check(...args)) { + return; + } + } + // we didn't time out, resolve. Otherwise, we already rejected so don't resolve now. + if (!fulfilled) { + resolve(); + fulfilled = true; + } + // cleanup + emitter.off(eventName, callback); + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + // listen for emissions + emitter.on(eventName, callback); + }); +} + export const findByAttr = (attr: string) => (component: ReactWrapper, value: string) => component.find(`[${attr}="${value}"]`); export const findByTestId = findByAttr('data-test-id'); From 9076152f79f4adf1ad7f751eb1268b28516d1284 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 20 Sep 2022 17:39:07 +0100 Subject: [PATCH 002/189] Fix tile soft crash in ReplyInThreadButton (#9300) --- src/components/views/context_menus/MessageContextMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 9452fa28268..eca720412d2 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -73,7 +73,7 @@ const ReplyInThreadButton = ({ mxEvent, closeMenu }: IReplyInThreadButton) => { const relationType = mxEvent?.getRelation()?.rel_type; // Can't create a thread from an event with an existing relation - if (Boolean(relationType) && relationType !== RelationType.Thread) return; + if (Boolean(relationType) && relationType !== RelationType.Thread) return null; const onClick = (): void => { if (!localStorage.getItem("mx_seen_feature_thread")) { From 7e435eef131d7b26403cdf261553420b7a817d54 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 20 Sep 2022 17:59:29 +0100 Subject: [PATCH 003/189] Fix spaces feedback prompt wrongly showing when feedback is disabled (#9302) --- src/components/views/spaces/SpaceCreateMenu.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index fee5f2190f3..1e919981e2a 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -38,6 +38,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { UIFeature } from "../../../settings/UIFeature"; export const createSpace = async ( name: string, @@ -100,7 +101,7 @@ const nameToLocalpart = (name: string): string => { // XXX: Temporary for the Spaces release only export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => { - if (!SdkConfig.get().bug_report_endpoint_url) return null; + if (!SdkConfig.get().bug_report_endpoint_url || !SettingsStore.getValue(UIFeature.Feedback)) return null; return
{ _t("Spaces are a new feature.") } From fa2ec7f6c98ec01b5f594285743369d68a019af9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 20 Sep 2022 18:00:31 +0100 Subject: [PATCH 004/189] Fix soft crash around unknown room pills (#9301) * Fix soft crash around unknown room pills * Add tests * Fix types --- src/editor/parts.ts | 10 +++++----- test/editor/parts-test.ts | 7 +++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/editor/parts.ts b/src/editor/parts.ts index 8d98c398b54..7dbdfcdda3a 100644 --- a/src/editor/parts.ts +++ b/src/editor/parts.ts @@ -411,7 +411,7 @@ export class EmojiPart extends BasePart implements IBasePart { } class RoomPillPart extends PillPart { - constructor(resourceId: string, label: string, private room: Room) { + constructor(resourceId: string, label: string, private room?: Room) { super(resourceId, label); } @@ -419,8 +419,8 @@ class RoomPillPart extends PillPart { let initialLetter = ""; let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop"); if (!avatarUrl) { - initialLetter = Avatar.getInitialLetter(this.room ? this.room.name : this.resourceId); - avatarUrl = Avatar.defaultAvatarUrlForString(this.room ? this.room.roomId : this.resourceId); + initialLetter = Avatar.getInitialLetter(this.room?.name || this.resourceId); + avatarUrl = Avatar.defaultAvatarUrlForString(this.room?.roomId ?? this.resourceId); } this.setAvatarVars(node, avatarUrl, initialLetter); } @@ -430,7 +430,7 @@ class RoomPillPart extends PillPart { } protected get className() { - return "mx_Pill " + (this.room.isSpaceRoom() ? "mx_SpacePill" : "mx_RoomPill"); + return "mx_Pill " + (this.room?.isSpaceRoom() ? "mx_SpacePill" : "mx_RoomPill"); } } @@ -610,7 +610,7 @@ export class PartCreator { } public roomPill(alias: string, roomId?: string): RoomPillPart { - let room; + let room: Room | undefined; if (roomId || alias[0] !== "#") { room = this.client.getRoom(roomId || alias); } else { diff --git a/test/editor/parts-test.ts b/test/editor/parts-test.ts index b77971c2aa7..534221ece3a 100644 --- a/test/editor/parts-test.ts +++ b/test/editor/parts-test.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { EmojiPart, PlainPart } from "../../src/editor/parts"; +import { createPartCreator } from "./mock"; describe("editor/parts", () => { describe("appendUntilRejected", () => { @@ -32,4 +33,10 @@ describe("editor/parts", () => { expect(part.text).toEqual(femaleFacepalmEmoji); }); }); + + it("should not explode on room pills for unknown rooms", () => { + const pc = createPartCreator(); + const part = pc.roomPill("#room:server"); + expect(() => part.toDOMNode()).not.toThrow(); + }); }); From 71cf9bf932cc975157a9d18ea4b8c1d1c6784d76 Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 21 Sep 2022 10:13:33 +0100 Subject: [PATCH 005/189] Read receipts for threads (#9239) * Use EventType enum instead of hardcoded value * Enable read receipts on thread timelines * Strict null checks * Strict null checks * fix import group * strict checks * strict checks * null check * fix tests --- src/components/structures/MessagePanel.tsx | 11 +- src/components/structures/ThreadPanel.tsx | 8 +- src/components/structures/ThreadView.tsx | 3 +- src/components/structures/TimelinePanel.tsx | 121 ++++++++++-------- src/components/views/rooms/EventTile.tsx | 1 + .../views/settings/Notifications.tsx | 5 +- .../structures/TimelinePanel-test.tsx | 6 +- 7 files changed, 88 insertions(+), 67 deletions(-) diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 8e41a599f11..0dbd10cb467 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -25,6 +25,8 @@ import { logger } from 'matrix-js-sdk/src/logger'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon'; import { isSupportedReceiptType } from "matrix-js-sdk/src/utils"; +import { ReadReceipt } from 'matrix-js-sdk/src/models/read-receipt'; +import { ListenerMap } from 'matrix-js-sdk/src/models/typed-event-emitter'; import shouldHideEvent from '../../shouldHideEvent'; import { wantsDateSeparator } from '../../DateUtils'; @@ -135,7 +137,7 @@ interface IProps { showUrlPreview?: boolean; // event after which we should show a read marker - readMarkerEventId?: string; + readMarkerEventId?: string | null; // whether the read marker should be visible readMarkerVisible?: boolean; @@ -826,8 +828,13 @@ export default class MessagePanel extends React.Component { if (!room) { return null; } + + const receiptDestination: ReadReceipt> = this.context.threadId + ? room.getThread(this.context.threadId) + : room; + const receipts: IReadReceiptProps[] = []; - room.getReceiptsForEvent(event).forEach((r) => { + receiptDestination.getReceiptsForEvent(event).forEach((r) => { if ( !r.userId || !isSupportedReceiptType(r.type) || diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 0a073938635..8ccb7fbdd46 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -282,10 +282,10 @@ const ThreadPanel: React.FC = ({ ? { { disableGrouping: false, }; - private lastRRSentEventId: string = undefined; - private lastRMSentEventId: string = undefined; + private lastRRSentEventId: string | null | undefined = undefined; + private lastRMSentEventId: string | null | undefined = undefined; private readonly messagePanel = createRef(); private readonly dispatcherRef: string; @@ -250,7 +251,7 @@ class TimelinePanel extends React.Component { // XXX: we could track RM per TimelineSet rather than per Room. // but for now we just do it per room for simplicity. - let initialReadMarker = null; + let initialReadMarker: string | null = null; if (this.props.manageReadMarkers) { const readmarker = this.props.timelineSet.room.getAccountData('m.fully_read'); if (readmarker) { @@ -942,13 +943,13 @@ class TimelinePanel extends React.Component { if (lastReadEventIndex === null) { shouldSendRR = false; } - let lastReadEvent = this.state.events[lastReadEventIndex]; + let lastReadEvent: MatrixEvent | null = this.state.events[lastReadEventIndex ?? 0]; shouldSendRR = shouldSendRR && // Only send a RR if the last read event is ahead in the timeline relative to // the current RR event. lastReadEventIndex > currentRREventIndex && // Only send a RR if the last RR set != the one we would send - this.lastRRSentEventId != lastReadEvent.getId(); + this.lastRRSentEventId !== lastReadEvent?.getId(); // Only send a RM if the last RM sent != the one we would send const shouldSendRM = @@ -958,7 +959,7 @@ class TimelinePanel extends React.Component { // same one at the server repeatedly if (shouldSendRR || shouldSendRM) { if (shouldSendRR) { - this.lastRRSentEventId = lastReadEvent.getId(); + this.lastRRSentEventId = lastReadEvent?.getId(); } else { lastReadEvent = null; } @@ -974,48 +975,57 @@ class TimelinePanel extends React.Component { `prr=${lastReadEvent?.getId()}`, ); - MatrixClientPeg.get().setRoomReadMarkers( - roomId, - this.state.readMarkerEventId, - sendRRs ? lastReadEvent : null, // Public read receipt (could be null) - lastReadEvent, // Private read receipt (could be null) - ).catch(async (e) => { - // /read_markers API is not implemented on this HS, fallback to just RR - if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { - if ( - !sendRRs - && !MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285.stable") - ) return; - - try { - return await MatrixClientPeg.get().sendReadReceipt( - lastReadEvent, - sendRRs ? ReceiptType.Read : ReceiptType.ReadPrivate, - ); - } catch (error) { + + if (this.props.timelineSet.thread && sendRRs && lastReadEvent) { + // There's no support for fully read markers on threads + // as defined by MSC3771 + cli.sendReadReceipt( + lastReadEvent, + sendRRs ? ReceiptType.Read : ReceiptType.ReadPrivate, + ); + } else { + cli.setRoomReadMarkers( + roomId, + this.state.readMarkerEventId ?? "", + sendRRs ? (lastReadEvent ?? undefined) : undefined, // Public read receipt (could be null) + lastReadEvent ?? undefined, // Private read receipt (could be null) + ).catch(async (e) => { + // /read_markers API is not implemented on this HS, fallback to just RR + if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { + if ( + !sendRRs + && !cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable") + ) return; + try { + return await cli.sendReadReceipt( + lastReadEvent, + sendRRs ? ReceiptType.Read : ReceiptType.ReadPrivate, + ); + } catch (error) { + logger.error(e); + this.lastRRSentEventId = undefined; + } + } else { logger.error(e); - this.lastRRSentEventId = undefined; } - } else { - logger.error(e); - } - // it failed, so allow retries next time the user is active - this.lastRRSentEventId = undefined; - this.lastRMSentEventId = undefined; - }); - - // do a quick-reset of our unreadNotificationCount to avoid having - // to wait from the remote echo from the homeserver. - // we only do this if we're right at the end, because we're just assuming - // that sending an RR for the latest message will set our notif counter - // to zero: it may not do this if we send an RR for somewhere before the end. - if (this.isAtEndOfLiveTimeline()) { - this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0); - this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); - dis.dispatch({ - action: 'on_room_read', - roomId: this.props.timelineSet.room.roomId, + // it failed, so allow retries next time the user is active + this.lastRRSentEventId = undefined; + this.lastRMSentEventId = undefined; }); + + // do a quick-reset of our unreadNotificationCount to avoid having + // to wait from the remote echo from the homeserver. + // we only do this if we're right at the end, because we're just assuming + // that sending an RR for the latest message will set our notif counter + // to zero: it may not do this if we send an RR for somewhere before the end. + if (this.isAtEndOfLiveTimeline()) { + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0); + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); + dis.dispatch({ + action: 'on_room_read', + roomId: this.props.timelineSet.room.roomId, + }); + } } } }; @@ -1149,7 +1159,7 @@ class TimelinePanel extends React.Component { const rmId = this.getCurrentReadReceipt(); // Look up the timestamp if we can find it - const tl = this.props.timelineSet.getTimelineForEvent(rmId); + const tl = this.props.timelineSet.getTimelineForEvent(rmId ?? ""); let rmTs: number; if (tl) { const event = tl.getEvents().find((e) => { return e.getId() == rmId; }); @@ -1554,7 +1564,8 @@ class TimelinePanel extends React.Component { return 0; } - private indexForEventId(evId: string): number | null { + private indexForEventId(evId: string | null): number | null { + if (evId === null) { return null; } /* Threads do not have server side support for read receipts and the concept is very tied to the main room timeline, we are forcing the timeline to send read receipts for threaded events */ @@ -1655,7 +1666,7 @@ class TimelinePanel extends React.Component { * SDK. * @return {String} the event ID */ - private getCurrentReadReceipt(ignoreSynthesized = false): string { + private getCurrentReadReceipt(ignoreSynthesized = false): string | null { const client = MatrixClientPeg.get(); // the client can be null on logout if (client == null) { @@ -1663,21 +1674,23 @@ class TimelinePanel extends React.Component { } const myUserId = client.credentials.userId; - return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized); + const receiptStore: ReadReceipt = + this.props.timelineSet.thread ?? this.props.timelineSet.room; + return receiptStore?.getEventReadUpTo(myUserId, ignoreSynthesized); } - private setReadMarker(eventId: string, eventTs: number, inhibitSetState = false): void { - const roomId = this.props.timelineSet.room.roomId; + private setReadMarker(eventId: string | null, eventTs: number, inhibitSetState = false): void { + const roomId = this.props.timelineSet.room?.roomId; // don't update the state (and cause a re-render) if there is // no change to the RM. - if (eventId === this.state.readMarkerEventId) { + if (eventId === this.state.readMarkerEventId || eventId === null) { return; } // in order to later figure out if the read marker is // above or below the visible timeline, we stash the timestamp. - TimelinePanel.roomReadMarkerTsMap[roomId] = eventTs; + TimelinePanel.roomReadMarkerTsMap[roomId ?? ""] = eventTs; if (inhibitSetState) { return; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index b4d022cab4a..9ae8aa7a456 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -1333,6 +1333,7 @@ export class UnwrappedEventTile extends React.Component { { timestamp } + { msgOption }
, reactionsRow, ]); diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx index c7a95b060ae..77c02bc032e 100644 --- a/src/components/views/settings/Notifications.tsx +++ b/src/components/views/settings/Notifications.tsx @@ -398,12 +398,13 @@ export default class Notifications extends React.PureComponent { }; private onClearNotificationsClicked = () => { - MatrixClientPeg.get().getRooms().forEach(r => { + const client = MatrixClientPeg.get(); + client.getRooms().forEach(r => { if (r.getUnreadNotificationCount() > 0) { const events = r.getLiveTimeline().getEvents(); if (events.length) { // noinspection JSIgnoredPromiseFromCall - MatrixClientPeg.get().sendReadReceipt(events[events.length - 1]); + client.sendReadReceipt(events[events.length - 1]); } } }); diff --git a/test/components/structures/TimelinePanel-test.tsx b/test/components/structures/TimelinePanel-test.tsx index 5d133fb7058..542f0c88878 100644 --- a/test/components/structures/TimelinePanel-test.tsx +++ b/test/components/structures/TimelinePanel-test.tsx @@ -42,7 +42,7 @@ const newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs [ReceiptType.FullyRead]: { [userId]: { ts: fullyReadTs } }, }, }; - return new MatrixEvent({ content: receiptContent, type: "m.receipt" }); + return new MatrixEvent({ content: receiptContent, type: EventType.Receipt }); }; const renderPanel = (room: Room, events: MatrixEvent[]): RenderResult => { @@ -154,7 +154,7 @@ describe('TimelinePanel', () => { }); renderPanel(room, events); - expect(client.setRoomReadMarkers).toHaveBeenCalledWith(room.roomId, null, events[0], events[0]); + expect(client.setRoomReadMarkers).toHaveBeenCalledWith(room.roomId, "", events[0], events[0]); }); it("does not send public read receipt when enabled", () => { @@ -169,7 +169,7 @@ describe('TimelinePanel', () => { }); renderPanel(room, events); - expect(client.setRoomReadMarkers).toHaveBeenCalledWith(room.roomId, null, null, events[0]); + expect(client.setRoomReadMarkers).toHaveBeenCalledWith(room.roomId, "", undefined, events[0]); }); }); }); From c182c1c7068c392b8d851c11f3bd4146273559e6 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 21 Sep 2022 18:46:28 +0200 Subject: [PATCH 006/189] Generalise VoiceRecording (#9304) --- src/audio/VoiceMessageRecording.ts | 166 +++++++++++++ src/audio/VoiceRecording.ts | 88 +------ .../audio_messages/LiveRecordingClock.tsx | 5 +- .../audio_messages/LiveRecordingWaveform.tsx | 5 +- .../views/rooms/MessageComposer.tsx | 9 +- .../views/rooms/VoiceRecordComposerTile.tsx | 7 +- src/stores/VoiceRecordingStore.ts | 10 +- test/audio/VoiceMessageRecording-test.ts | 221 ++++++++++++++++++ .../rooms/VoiceRecordComposerTile-test.tsx | 3 +- test/stores/VoiceRecordingStore-test.ts | 8 +- test/test-utils/test-utils.ts | 3 +- 11 files changed, 422 insertions(+), 103 deletions(-) create mode 100644 src/audio/VoiceMessageRecording.ts create mode 100644 test/audio/VoiceMessageRecording-test.ts diff --git a/src/audio/VoiceMessageRecording.ts b/src/audio/VoiceMessageRecording.ts new file mode 100644 index 00000000000..39951ff278e --- /dev/null +++ b/src/audio/VoiceMessageRecording.ts @@ -0,0 +1,166 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IEncryptedFile, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { SimpleObservable } from "matrix-widget-api"; + +import { uploadFile } from "../ContentMessages"; +import { IDestroyable } from "../utils/IDestroyable"; +import { Singleflight } from "../utils/Singleflight"; +import { Playback } from "./Playback"; +import { IRecordingUpdate, RecordingState, VoiceRecording } from "./VoiceRecording"; + +export interface IUpload { + mxc?: string; // for unencrypted uploads + encrypted?: IEncryptedFile; +} + +/** + * This class can be used to record a single voice message. + */ +export class VoiceMessageRecording implements IDestroyable { + private lastUpload: IUpload; + private buffer = new Uint8Array(0); // use this.audioBuffer to access + private playback: Playback; + + public constructor( + private matrixClient: MatrixClient, + private voiceRecording: VoiceRecording, + ) { + this.voiceRecording.onDataAvailable = this.onDataAvailable; + } + + public async start(): Promise { + if (this.lastUpload || this.hasRecording) { + throw new Error("Recording already prepared"); + } + + return this.voiceRecording.start(); + } + + public async stop(): Promise { + await this.voiceRecording.stop(); + return this.audioBuffer; + } + + public on(event: string | symbol, listener: (...args: any[]) => void): this { + this.voiceRecording.on(event, listener); + return this; + } + + public off(event: string | symbol, listener: (...args: any[]) => void): this { + this.voiceRecording.off(event, listener); + return this; + } + + public emit(event: string, ...args: any[]): boolean { + return this.voiceRecording.emit(event, ...args); + } + + public get hasRecording(): boolean { + return this.buffer.length > 0; + } + + public get isRecording(): boolean { + return this.voiceRecording.isRecording; + } + + /** + * 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.voiceRecording.amplitudes); // cast to ArrayBuffer proper; + }); + return this.playback; + } + + public async upload(inRoomId: string): Promise { + if (!this.hasRecording) { + throw new Error("No recording available to upload"); + } + + if (this.lastUpload) return this.lastUpload; + + try { + this.emit(RecordingState.Uploading); + const { url: mxc, file: encrypted } = await uploadFile( + this.matrixClient, + inRoomId, + new Blob( + [this.audioBuffer], + { + type: this.contentType, + }, + ), + ); + this.lastUpload = { mxc, encrypted }; + this.emit(RecordingState.Uploaded); + } catch (e) { + this.emit(RecordingState.Ended); + throw e; + } + return this.lastUpload; + } + + public get durationSeconds(): number { + return this.voiceRecording.durationSeconds; + } + + public get contentType(): string { + return this.voiceRecording.contentType; + } + + public get contentLength(): number { + return this.buffer.length; + } + + public get liveData(): SimpleObservable { + return this.voiceRecording.liveData; + } + + public get isSupported(): boolean { + return this.voiceRecording.isSupported; + } + + destroy(): void { + this.playback?.destroy(); + this.voiceRecording.destroy(); + } + + private onDataAvailable = (data: ArrayBuffer) => { + const buf = new Uint8Array(data); + const newBuf = new Uint8Array(this.buffer.length + buf.length); + newBuf.set(this.buffer, 0); + newBuf.set(buf, this.buffer.length); + this.buffer = newBuf; + }; + + 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); + } +} + +export const createVoiceMessageRecording = (matrixClient: MatrixClient) => { + return new VoiceMessageRecording(matrixClient, new VoiceRecording()); +}; diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts index d0b34493d86..e98e85aba5d 100644 --- a/src/audio/VoiceRecording.ts +++ b/src/audio/VoiceRecording.ts @@ -16,10 +16,8 @@ limitations under the License. import * as Recorder from 'opus-recorder'; import encoderPath from 'opus-recorder/dist/encoderWorker.min.js'; -import { MatrixClient } from "matrix-js-sdk/src/client"; import { SimpleObservable } from "matrix-widget-api"; import EventEmitter from "events"; -import { IEncryptedFile } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; import MediaDeviceHandler from "../MediaDeviceHandler"; @@ -27,9 +25,7 @@ import { IDestroyable } from "../utils/IDestroyable"; import { Singleflight } from "../utils/Singleflight"; import { PayloadEvent, WORKLET_NAME } from "./consts"; import { UPDATE_EVENT } from "../stores/AsyncStore"; -import { Playback } from "./Playback"; import { createAudioContext } from "./compat"; -import { uploadFile } from "../ContentMessages"; import { FixedRollingArray } from "../utils/FixedRollingArray"; import { clamp } from "../utils/numbers"; import mxRecorderWorkletPath from "./RecorderWorklet"; @@ -55,11 +51,6 @@ export enum RecordingState { Uploaded = "uploaded", } -export interface IUpload { - mxc?: string; // for unencrypted uploads - encrypted?: IEncryptedFile; -} - export class VoiceRecording extends EventEmitter implements IDestroyable { private recorder: Recorder; private recorderContext: AudioContext; @@ -67,26 +58,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private recorderStream: MediaStream; private recorderWorklet: AudioWorkletNode; private recorderProcessor: ScriptProcessorNode; - private buffer = new Uint8Array(0); // use this.audioBuffer to access - private lastUpload: IUpload; private recording = false; private observable: SimpleObservable; - private amplitudes: number[] = []; // at each second mark, generated - private playback: Playback; + public amplitudes: number[] = []; // at each second mark, generated private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0); - - public constructor(private client: MatrixClient) { - super(); - } + public onDataAvailable: (data: ArrayBuffer) => void; public get contentType(): string { return "audio/ogg"; } - public get contentLength(): number { - return this.buffer.length; - } - public get durationSeconds(): number { if (!this.recorder) throw new Error("Duration not available without a recording"); return this.recorderContext.currentTime; @@ -165,13 +146,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { encoderComplexity: 3, // 0-10, 10 is slow and high quality. resampleQuality: 3, // 0-10, 10 is slow and high quality }); - this.recorder.ondataavailable = (a: ArrayBuffer) => { - const buf = new Uint8Array(a); - const newBuf = new Uint8Array(this.buffer.length + buf.length); - newBuf.set(this.buffer, 0); - newBuf.set(buf, this.buffer.length); - this.buffer = newBuf; - }; + + // not using EventEmitter here because it leads to detached bufferes + this.recorder.ondataavailable = (data: ArrayBuffer) => this?.onDataAvailable(data); } catch (e) { logger.error("Error starting recording: ", e); if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely @@ -191,12 +168,6 @@ 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; @@ -206,10 +177,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return !!Recorder.isRecordingSupported(); } - public get hasRecording(): boolean { - return this.buffer.length > 0; - } - private onAudioProcess = (ev: AudioProcessingEvent) => { this.processAudioUpdate(ev.playbackTime); @@ -251,9 +218,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { }; public async start(): Promise { - if (this.lastUpload || this.hasRecording) { - throw new Error("Recording already prepared"); - } if (this.recording) { throw new Error("Recording already in progress"); } @@ -267,7 +231,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { this.emit(RecordingState.Started); } - public async stop(): Promise { + public async stop(): Promise { return Singleflight.for(this, "stop").do(async () => { if (!this.recording) { throw new Error("No recording to stop"); @@ -293,54 +257,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { this.recording = false; await this.recorder.close(); this.emit(RecordingState.Ended); - - return this.audioBuffer; }); } - /** - * 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() { // noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here this.stop(); this.removeAllListeners(); + this.onDataAvailable = undefined; Singleflight.forgetAllFor(this); // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here - this.playback?.destroy(); this.observable.close(); } - - public async upload(inRoomId: string): Promise { - if (!this.hasRecording) { - throw new Error("No recording available to upload"); - } - - if (this.lastUpload) return this.lastUpload; - - try { - this.emit(RecordingState.Uploading); - const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], { - type: this.contentType, - })); - this.lastUpload = { mxc, encrypted }; - this.emit(RecordingState.Uploaded); - } catch (e) { - this.emit(RecordingState.Ended); - throw e; - } - return this.lastUpload; - } } diff --git a/src/components/views/audio_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx index 34e4c559fec..10005d8b9aa 100644 --- a/src/components/views/audio_messages/LiveRecordingClock.tsx +++ b/src/components/views/audio_messages/LiveRecordingClock.tsx @@ -16,12 +16,13 @@ limitations under the License. import React from "react"; -import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording"; +import { IRecordingUpdate } from "../../../audio/VoiceRecording"; import Clock from "./Clock"; import { MarkedExecution } from "../../../utils/MarkedExecution"; +import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; interface IProps { - recorder: VoiceRecording; + recorder: VoiceMessageRecording; } interface IState { diff --git a/src/components/views/audio_messages/LiveRecordingWaveform.tsx b/src/components/views/audio_messages/LiveRecordingWaveform.tsx index c9c122c98ac..3a546c2a6d5 100644 --- a/src/components/views/audio_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/audio_messages/LiveRecordingWaveform.tsx @@ -16,13 +16,14 @@ limitations under the License. import React from "react"; -import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording"; +import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES } from "../../../audio/VoiceRecording"; import { arrayFastResample, arraySeed } from "../../../utils/arrays"; import Waveform from "./Waveform"; import { MarkedExecution } from "../../../utils/MarkedExecution"; +import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; interface IProps { - recorder: VoiceRecording; + recorder: VoiceMessageRecording; } interface IState { diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 3f75e9f16da..f9aaf211052 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -37,7 +37,7 @@ import ReplyPreview from "./ReplyPreview"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore"; -import { RecordingState, VoiceRecording } from "../../../audio/VoiceRecording"; +import { RecordingState } from "../../../audio/VoiceRecording"; import Tooltip, { Alignment } from "../elements/Tooltip"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import { E2EStatus } from '../../../utils/ShieldUtils'; @@ -53,6 +53,7 @@ import { ButtonEvent } from '../elements/AccessibleButton'; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; import { Features } from '../../../settings/Settings'; +import { VoiceMessageRecording } from '../../../audio/VoiceMessageRecording'; let instanceCount = 0; @@ -101,7 +102,7 @@ export default class MessageComposer extends React.Component { private ref: React.RefObject = createRef(); private instanceId: number; - private _voiceRecording: Optional; + private _voiceRecording: Optional; public static contextType = RoomContext; public context!: React.ContextType; @@ -133,11 +134,11 @@ export default class MessageComposer extends React.Component { SettingsStore.monitorSetting(Features.VoiceBroadcast, null); } - private get voiceRecording(): Optional { + private get voiceRecording(): Optional { return this._voiceRecording; } - private set voiceRecording(rec: Optional) { + private set voiceRecording(rec: Optional) { if (this._voiceRecording) { this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted); this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon); diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index b25d87a0cef..782cda9f4c0 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -23,7 +23,7 @@ import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { _t } from "../../../languageHandler"; -import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording"; +import { RecordingState } from "../../../audio/VoiceRecording"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform"; import LiveRecordingClock from "../audio_messages/LiveRecordingClock"; @@ -44,6 +44,7 @@ import { attachRelation } from "./SendMessageComposer"; import { addReplyToMessageContent } from "../../../utils/Reply"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import RoomContext from "../../../contexts/RoomContext"; +import { IUpload, VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; interface IProps { room: Room; @@ -53,7 +54,7 @@ interface IProps { } interface IState { - recorder?: VoiceRecording; + recorder?: VoiceMessageRecording; recordingPhase?: RecordingState; didUploadFail?: boolean; } @@ -250,7 +251,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent) { + private bindNewRecorder(recorder: Optional) { if (this.state.recorder) { this.state.recorder.off(UPDATE_EVENT, this.onRecordingUpdate); } diff --git a/src/stores/VoiceRecordingStore.ts b/src/stores/VoiceRecordingStore.ts index 29af171f6ec..ed2b480255d 100644 --- a/src/stores/VoiceRecordingStore.ts +++ b/src/stores/VoiceRecordingStore.ts @@ -22,12 +22,12 @@ import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; -import { VoiceRecording } from "../audio/VoiceRecording"; +import { createVoiceMessageRecording, VoiceMessageRecording } from "../audio/VoiceMessageRecording"; const SEPARATOR = "|"; interface IState { - [voiceRecordingId: string]: Optional; + [voiceRecordingId: string]: Optional; } export class VoiceRecordingStore extends AsyncStoreWithClient { @@ -63,7 +63,7 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { * @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to get the recording in. * @returns {Optional} The recording, if any. */ - public getActiveRecording(voiceRecordingId: string): Optional { + public getActiveRecording(voiceRecordingId: string): Optional { return this.state[voiceRecordingId]; } @@ -74,12 +74,12 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { * @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to start recording in. * @returns {VoiceRecording} The recording. */ - public startRecording(voiceRecordingId: string): VoiceRecording { + public startRecording(voiceRecordingId: string): VoiceMessageRecording { if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient"); if (!voiceRecordingId) throw new Error("Recording must be associated with a room"); if (this.state[voiceRecordingId]) throw new Error("A recording is already in progress"); - const recording = new VoiceRecording(this.matrixClient); + const recording = createVoiceMessageRecording(this.matrixClient); // noinspection JSIgnoredPromiseFromCall - we can safely run this async this.updateState({ ...this.state, [voiceRecordingId]: recording }); diff --git a/test/audio/VoiceMessageRecording-test.ts b/test/audio/VoiceMessageRecording-test.ts new file mode 100644 index 00000000000..5114045c471 --- /dev/null +++ b/test/audio/VoiceMessageRecording-test.ts @@ -0,0 +1,221 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { mocked } from "jest-mock"; +import { IAbortablePromise, IEncryptedFile, IUploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { createVoiceMessageRecording, VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording"; +import { RecordingState, VoiceRecording } from "../../src/audio/VoiceRecording"; +import { uploadFile } from "../../src/ContentMessages"; +import { stubClient } from "../test-utils"; +import { Playback } from "../../src/audio/Playback"; + +jest.mock("../../src/ContentMessages", () => ({ + uploadFile: jest.fn(), +})); + +jest.mock("../../src/audio/Playback", () => ({ + Playback: jest.fn(), +})); + +describe("VoiceMessageRecording", () => { + const roomId = "!room:example.com"; + const contentType = "test content type"; + const durationSeconds = 23; + const testBuf = new Uint8Array([1, 2, 3]); + const testAmplitudes = [4, 5, 6]; + + let voiceRecording: VoiceRecording; + let voiceMessageRecording: VoiceMessageRecording; + let client: MatrixClient; + + beforeEach(() => { + client = stubClient(); + voiceRecording = { + contentType, + durationSeconds, + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + isRecording: true, + isSupported: true, + liveData: jest.fn(), + amplitudes: testAmplitudes, + } as unknown as VoiceRecording; + voiceMessageRecording = new VoiceMessageRecording( + client, + voiceRecording, + ); + }); + + it("hasRecording should return false", () => { + expect(voiceMessageRecording.hasRecording).toBe(false); + }); + + it("createVoiceMessageRecording should return a VoiceMessageRecording", () => { + expect(createVoiceMessageRecording(client)).toBeInstanceOf(VoiceMessageRecording); + }); + + it("durationSeconds should return the VoiceRecording value", () => { + expect(voiceMessageRecording.durationSeconds).toBe(durationSeconds); + }); + + it("contentType should return the VoiceRecording value", () => { + expect(voiceMessageRecording.contentType).toBe(contentType); + }); + + it.each([true, false])("isRecording should return %s from VoiceRecording", (value: boolean) => { + // @ts-ignore + voiceRecording.isRecording = value; + expect(voiceMessageRecording.isRecording).toBe(value); + }); + + it.each([true, false])("isSupported should return %s from VoiceRecording", (value: boolean) => { + // @ts-ignore + voiceRecording.isSupported = value; + expect(voiceMessageRecording.isSupported).toBe(value); + }); + + it("should return liveData from VoiceRecording", () => { + expect(voiceMessageRecording.liveData).toBe(voiceRecording.liveData); + }); + + it("start should forward the call to VoiceRecording.start", async () => { + await voiceMessageRecording.start(); + expect(voiceRecording.start).toHaveBeenCalled(); + }); + + it("on should forward the call to VoiceRecording", () => { + const callback = () => {}; + const result = voiceMessageRecording.on("test on", callback); + expect(voiceRecording.on).toHaveBeenCalledWith("test on", callback); + expect(result).toBe(voiceMessageRecording); + }); + + it("off should forward the call to VoiceRecording", () => { + const callback = () => {}; + const result = voiceMessageRecording.off("test off", callback); + expect(voiceRecording.off).toHaveBeenCalledWith("test off", callback); + expect(result).toBe(voiceMessageRecording); + }); + + it("emit should forward the call to VoiceRecording", () => { + voiceMessageRecording.emit("test emit", 42); + expect(voiceRecording.emit).toHaveBeenCalledWith("test emit", 42); + }); + + it("upload should raise an error", async () => { + await expect(voiceMessageRecording.upload(roomId)) + .rejects + .toThrow("No recording available to upload"); + }); + + describe("when the first data has been received", () => { + const uploadUrl = "https://example.com/content123"; + const encryptedFile = {} as unknown as IEncryptedFile; + + beforeEach(() => { + voiceRecording.onDataAvailable(testBuf); + }); + + it("contentLength should return the buffer length", () => { + expect(voiceMessageRecording.contentLength).toBe(testBuf.length); + }); + + it("stop should return a copy of the data buffer", async () => { + const result = await voiceMessageRecording.stop(); + expect(voiceRecording.stop).toHaveBeenCalled(); + expect(result).toEqual(testBuf); + }); + + it("hasRecording should return true", () => { + expect(voiceMessageRecording.hasRecording).toBe(true); + }); + + describe("upload", () => { + let uploadFileClient: MatrixClient; + let uploadFileRoomId: string; + let uploadBlob: Blob; + + beforeEach(() => { + uploadFileClient = null; + uploadFileRoomId = null; + uploadBlob = null; + + mocked(uploadFile).mockImplementation(( + matrixClient: MatrixClient, + roomId: string, + file: File | Blob, + _progressHandler?: IUploadOpts["progressHandler"], + ): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> => { + uploadFileClient = matrixClient; + uploadFileRoomId = roomId; + uploadBlob = file; + // @ts-ignore + return Promise.resolve({ + url: uploadUrl, + file: encryptedFile, + }); + }); + }); + + it("should upload the file and trigger the upload events", async () => { + const result = await voiceMessageRecording.upload(roomId); + expect(voiceRecording.emit).toHaveBeenNthCalledWith(1, RecordingState.Uploading); + expect(voiceRecording.emit).toHaveBeenNthCalledWith(2, RecordingState.Uploaded); + + expect(result.mxc).toBe(uploadUrl); + expect(result.encrypted).toBe(encryptedFile); + + expect(mocked(uploadFile)).toHaveBeenCalled(); + expect(uploadFileClient).toBe(client); + expect(uploadFileRoomId).toBe(roomId); + expect(uploadBlob.type).toBe(contentType); + const blobArray = await uploadBlob.arrayBuffer(); + expect(new Uint8Array(blobArray)).toEqual(testBuf); + }); + + it("should reuse the result", async () => { + const result1 = await voiceMessageRecording.upload(roomId); + const result2 = await voiceMessageRecording.upload(roomId); + expect(result1).toBe(result2); + }); + }); + + describe("getPlayback", () => { + beforeEach(() => { + mocked(Playback).mockImplementation((buf: ArrayBuffer, seedWaveform) => { + expect(new Uint8Array(buf)).toEqual(testBuf); + expect(seedWaveform).toEqual(testAmplitudes); + return {} as Playback; + }); + }); + + it("should return a Playback with the data", () => { + voiceMessageRecording.getPlayback(); + expect(mocked(Playback)).toHaveBeenCalled(); + }); + + it("should reuse the result", () => { + const playback1 = voiceMessageRecording.getPlayback(); + const playback2 = voiceMessageRecording.getPlayback(); + expect(playback1).toBe(playback2); + }); + }); + }); +}); diff --git a/test/components/views/rooms/VoiceRecordComposerTile-test.tsx b/test/components/views/rooms/VoiceRecordComposerTile-test.tsx index 88cd97a2d58..77df519a8db 100644 --- a/test/components/views/rooms/VoiceRecordComposerTile-test.tsx +++ b/test/components/views/rooms/VoiceRecordComposerTile-test.tsx @@ -21,9 +21,10 @@ import { ISendEventResponse, MatrixClient, MsgType, Room } from "matrix-js-sdk/s import { mocked } from "jest-mock"; import VoiceRecordComposerTile from "../../../../src/components/views/rooms/VoiceRecordComposerTile"; -import { IUpload, VoiceRecording } from "../../../../src/audio/VoiceRecording"; +import { VoiceRecording } from "../../../../src/audio/VoiceRecording"; import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { IUpload } from "../../../../src/audio/VoiceMessageRecording"; jest.mock("../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), diff --git a/test/stores/VoiceRecordingStore-test.ts b/test/stores/VoiceRecordingStore-test.ts index 150307348a9..c675d8cc1ae 100644 --- a/test/stores/VoiceRecordingStore-test.ts +++ b/test/stores/VoiceRecordingStore-test.ts @@ -17,10 +17,10 @@ limitations under the License. import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { VoiceRecording } from '../../src/audio/VoiceRecording'; import { VoiceRecordingStore } from '../../src/stores/VoiceRecordingStore'; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import { flushPromises } from "../test-utils"; +import { VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording"; const stubClient = {} as undefined as MatrixClient; jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(stubClient); @@ -29,8 +29,8 @@ describe('VoiceRecordingStore', () => { const room1Id = '!room1:server.org'; const room2Id = '!room2:server.org'; const room3Id = '!room3:server.org'; - const room1Recording = { destroy: jest.fn() } as unknown as VoiceRecording; - const room2Recording = { destroy: jest.fn() } as unknown as VoiceRecording; + const room1Recording = { destroy: jest.fn() } as unknown as VoiceMessageRecording; + const room2Recording = { destroy: jest.fn() } as unknown as VoiceMessageRecording; const state = { [room1Id]: room1Recording, @@ -63,7 +63,7 @@ describe('VoiceRecordingStore', () => { await flushPromises(); - expect(result).toBeInstanceOf(VoiceRecording); + expect(result).toBeInstanceOf(VoiceMessageRecording); expect(store.getActiveRecording(room2Id)).toEqual(result); }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 029c88df8f5..2022d73a9c8 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -49,7 +49,7 @@ import MatrixClientBackedSettingsHandler from "../../src/settings/handlers/Matri * the react context, we can get rid of this and just inject a test client * via the context instead. */ -export function stubClient() { +export function stubClient(): MatrixClient { const client = createTestClient(); // stub out the methods in MatrixClientPeg @@ -63,6 +63,7 @@ export function stubClient() { // fast stub function rather than a sinon stub peg.get = function() { return client; }; MatrixClientBackedSettingsHandler.matrixClient = client; + return client; } /** From 516b4f0ff82c31d325d9d899091ed6165d45c9b1 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 21 Sep 2022 20:06:05 +0200 Subject: [PATCH 007/189] Add array concat util (#9306) --- src/audio/VoiceMessageRecording.ts | 6 ++---- src/utils/arrays.ts | 9 +++++++++ test/utils/arrays-test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/audio/VoiceMessageRecording.ts b/src/audio/VoiceMessageRecording.ts index 39951ff278e..2e101f903a5 100644 --- a/src/audio/VoiceMessageRecording.ts +++ b/src/audio/VoiceMessageRecording.ts @@ -18,6 +18,7 @@ import { IEncryptedFile, MatrixClient } from "matrix-js-sdk/src/matrix"; import { SimpleObservable } from "matrix-widget-api"; import { uploadFile } from "../ContentMessages"; +import { concat } from "../utils/arrays"; import { IDestroyable } from "../utils/IDestroyable"; import { Singleflight } from "../utils/Singleflight"; import { Playback } from "./Playback"; @@ -148,10 +149,7 @@ export class VoiceMessageRecording implements IDestroyable { private onDataAvailable = (data: ArrayBuffer) => { const buf = new Uint8Array(data); - const newBuf = new Uint8Array(this.buffer.length + buf.length); - newBuf.set(this.buffer, 0); - newBuf.set(buf, this.buffer.length); - this.buffer = newBuf; + this.buffer = concat(this.buffer, buf); }; private get audioBuffer(): Uint8Array { diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index a0fddde45cd..b82be21443a 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -304,3 +304,12 @@ export class GroupedArray { return new ArrayUtil(a); } } + +export const concat = (...arrays: Uint8Array[]): Uint8Array => { + return arrays.reduce((concatenatedSoFar: Uint8Array, toBeConcatenated: Uint8Array) => { + const concatenated = new Uint8Array(concatenatedSoFar.length + toBeConcatenated.length); + concatenated.set(concatenatedSoFar, 0); + concatenated.set(toBeConcatenated, concatenatedSoFar.length); + return concatenated; + }, new Uint8Array(0)); +}; diff --git a/test/utils/arrays-test.ts b/test/utils/arrays-test.ts index 7dabbb2981a..f0cc52e0a99 100644 --- a/test/utils/arrays-test.ts +++ b/test/utils/arrays-test.ts @@ -28,6 +28,7 @@ import { arrayIntersection, ArrayUtil, GroupedArray, + concat, } from "../../src/utils/arrays"; type TestParams = { input: number[], output: number[] }; @@ -375,5 +376,32 @@ describe('arrays', () => { expect(result.value).toEqual(output); }); }); + + describe("concat", () => { + const emptyArray = () => new Uint8Array(0); + const array1 = () => new Uint8Array([1, 2, 3]); + const array2 = () => new Uint8Array([4, 5, 6]); + const array3 = () => new Uint8Array([7, 8, 9]); + + it("should work for empty arrays", () => { + expect(concat(emptyArray(), emptyArray())).toEqual(emptyArray()); + }); + + it("should concat an empty and non-empty array", () => { + expect(concat(emptyArray(), array1())).toEqual(array1()); + }); + + it("should concat an non-empty and empty array", () => { + expect(concat(array1(), emptyArray())).toEqual(array1()); + }); + + it("should concat two arrays", () => { + expect(concat(array1(), array2())).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6])); + }); + + it("should concat three arrays", () => { + expect(concat(array1(), array2(), array3())).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9])); + }); + }); }); From 88c12cdaa5afdb588576c2e533b452b5da9a78f8 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 22 Sep 2022 09:42:07 +0200 Subject: [PATCH 008/189] Use display name instead of user ID when rendering power events (PSC-82) (#9295) Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/TextForEvent.tsx | 5 ++- test/TextForEvent-test.ts | 67 ++++++++++++++++----------------------- 2 files changed, 30 insertions(+), 42 deletions(-) diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 9310391e3e2..6be8dee3320 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -43,7 +43,6 @@ import { MatrixClientPeg } from "./MatrixClientPeg"; import { ROOM_SECURITY_TAB } from "./components/views/dialogs/RoomSettingsDialog"; import AccessibleButton from './components/views/elements/AccessibleButton'; import RightPanelStore from './stores/right-panel/RightPanelStore'; -import UserIdentifierCustomisations from './customisations/UserIdentifier'; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import { isLocationEvent } from './utils/EventUtils'; @@ -55,7 +54,7 @@ function getRoomMemberDisplayname(event: MatrixEvent, userId = event.getSender() const client = MatrixClientPeg.get(); const roomId = event.getRoomId(); const member = client.getRoom(roomId)?.getMember(userId); - return member?.rawDisplayName || userId || _t("Someone"); + return member?.name || member?.rawDisplayName || userId || _t("Someone"); } // These functions are frequently used just to check whether an event has @@ -467,7 +466,7 @@ function textForPowerEvent(event: MatrixEvent): () => string | null { } if (from === previousUserDefault && to === currentUserDefault) { return; } if (to !== from) { - const name = UserIdentifierCustomisations.getDisplayUserIdentifier(userId, { roomId: event.getRoomId() }); + const name = getRoomMemberDisplayname(event, userId); diffs.push({ userId, name, from, to }); } }); diff --git a/test/TextForEvent-test.ts b/test/TextForEvent-test.ts index c886c22b9ef..c99fb56571a 100644 --- a/test/TextForEvent-test.ts +++ b/test/TextForEvent-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix"; import TestRenderer from 'react-test-renderer'; import { ReactElement } from "react"; @@ -174,14 +174,17 @@ describe('TextForEvent', () => { const userA = { id: '@a', name: 'Alice', + rawDisplayName: 'Alice', }; const userB = { id: '@b', - name: 'Bob', + name: 'Bob (@b)', + rawDisplayName: 'Bob', }; const userC = { id: '@c', - name: 'Carl', + name: 'Bob (@c)', + rawDisplayName: 'Bob', }; interface PowerEventProps { usersDefault?: number; @@ -191,19 +194,23 @@ describe('TextForEvent', () => { } const mockPowerEvent = ({ usersDefault, prevDefault, users, prevUsers, - }: PowerEventProps): MatrixEvent => new MatrixEvent({ - type: EventType.RoomPowerLevels, - sender: userA.id, - state_key: "", - content: { - users_default: usersDefault, - users, - }, - prev_content: { - users: prevUsers, - users_default: prevDefault, - }, - }); + }: PowerEventProps): MatrixEvent => { + const mxEvent = new MatrixEvent({ + type: EventType.RoomPowerLevels, + sender: userA.id, + state_key: "", + content: { + users_default: usersDefault, + users, + }, + prev_content: { + users: prevUsers, + users_default: prevDefault, + }, + }); + mxEvent.sender = { name: userA.name } as RoomMember; + return mxEvent; + }; beforeAll(() => { mockClient = createTestClient(); @@ -256,7 +263,7 @@ describe('TextForEvent', () => { [userB.id]: 50, }, }); - const expectedText = "@a changed the power level of @b from Moderator to Admin."; + const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Admin."; expect(textForEvent(event)).toEqual(expectedText); }); @@ -271,7 +278,7 @@ describe('TextForEvent', () => { [userB.id]: 50, }, }); - const expectedText = "@a changed the power level of @b from Moderator to Default."; + const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Default."; expect(textForEvent(event)).toEqual(expectedText); }); @@ -284,7 +291,7 @@ describe('TextForEvent', () => { [userB.id]: 50, }, }); - const expectedText = "@a changed the power level of @b from Moderator to Custom (-1)."; + const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Custom (-1)."; expect(textForEvent(event)).toEqual(expectedText); }); @@ -299,27 +306,9 @@ describe('TextForEvent', () => { [userC.id]: 101, }, }); - const expectedText = - "@a changed the power level of @b from Moderator to Admin, @c from Custom (101) to Moderator."; - expect(textForEvent(event)).toEqual(expectedText); - }); - - it("uses userIdentifier customisation", () => { - (UserIdentifierCustomisations.getDisplayUserIdentifier as jest.Mock) - .mockImplementation(userId => 'customised ' + userId); - const event = mockPowerEvent({ - users: { - [userB.id]: 100, - }, - prevUsers: { - [userB.id]: 50, - }, - }); - // uses customised user id - const expectedText = "@a changed the power level of customised @b from Moderator to Admin."; + const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Admin," + + " Bob (@c) from Custom (101) to Moderator."; expect(textForEvent(event)).toEqual(expectedText); - expect(UserIdentifierCustomisations.getDisplayUserIdentifier) - .toHaveBeenCalledWith(userB.id, { roomId: event.getRoomId() }); }); }); From 56c95467de98dd8f25ca56d3f471fad480e4c574 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Sep 2022 15:08:14 +0100 Subject: [PATCH 009/189] Don't show feedback prompts when that UIFeature is disabled (#9305) --- src/components/structures/ThreadPanel.tsx | 4 +- src/components/views/beta/BetaCard.tsx | 3 +- .../dialogs/spotlight/SpotlightDialog.tsx | 4 +- .../views/spaces/SpaceCreateMenu.tsx | 5 +- .../UserOnboardingFeedback.tsx | 3 +- src/utils/Feedback.ts | 23 +++ .../structures/ThreadPanel-test.tsx | 86 +++++++---- .../__snapshots__/ThreadPanel-test.tsx.snap | 142 ++++++------------ test/components/views/beta/BetaCard-test.tsx | 80 ++++++++++ .../views/dialogs/SpotlightDialog-test.tsx | 32 ++++ test/test-utils/test-utils.ts | 2 + test/utils/Feedback-test.ts | 46 ++++++ 12 files changed, 303 insertions(+), 127 deletions(-) create mode 100644 src/utils/Feedback.ts create mode 100644 test/components/views/beta/BetaCard-test.tsx create mode 100644 test/utils/Feedback-test.ts diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 8ccb7fbdd46..245c604c0af 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -33,7 +33,6 @@ import Measured from '../views/elements/Measured'; import PosthogTrackers from "../../PosthogTrackers"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import { BetaPill } from '../views/beta/BetaCard'; -import SdkConfig from '../../SdkConfig'; import Modal from '../../Modal'; import BetaFeedbackDialog from '../views/dialogs/BetaFeedbackDialog'; import { Action } from '../../dispatcher/actions'; @@ -41,6 +40,7 @@ import { UserTab } from '../views/dialogs/UserTab'; import dis from '../../dispatcher/dispatcher'; import Spinner from "../views/elements/Spinner"; import Heading from '../views/typography/Heading'; +import { shouldShowFeedback } from "../../utils/Feedback"; interface IProps { roomId: string; @@ -234,7 +234,7 @@ const ThreadPanel: React.FC = ({ } }, [timelineSet, timelinePanel]); - const openFeedback = SdkConfig.get().bug_report_endpoint_url ? () => { + const openFeedback = shouldShowFeedback() ? () => { Modal.createDialog(BetaFeedbackDialog, { featureId: "feature_thread", }); diff --git a/src/components/views/beta/BetaCard.tsx b/src/components/views/beta/BetaCard.tsx index 3db52a0b69d..82f00e16977 100644 --- a/src/components/views/beta/BetaCard.tsx +++ b/src/components/views/beta/BetaCard.tsx @@ -28,6 +28,7 @@ import SettingsFlag from "../elements/SettingsFlag"; import { useFeatureEnabled } from "../../../hooks/useSettings"; import InlineSpinner from "../elements/InlineSpinner"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import { shouldShowFeedback } from "../../../utils/Feedback"; // XXX: Keep this around for re-use in future Betas @@ -88,7 +89,7 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => { } = info; let feedbackButton; - if (value && feedbackLabel && feedbackSubheading && SdkConfig.get().bug_report_endpoint_url) { + if (value && feedbackLabel && feedbackSubheading && shouldShowFeedback()) { feedbackButton = { Modal.createDialog(BetaFeedbackDialog, { featureId }); diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 85f4e6ed500..b04299869c1 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -60,7 +60,6 @@ import Modal from "../../../../Modal"; import { PosthogAnalytics } from "../../../../PosthogAnalytics"; import { getCachedRoomIDForAlias } from "../../../../RoomAliasCache"; import { showStartChatInviteDialog } from "../../../../RoomInvite"; -import SdkConfig from "../../../../SdkConfig"; import { SettingLevel } from "../../../../settings/SettingLevel"; import SettingsStore from "../../../../settings/SettingsStore"; import { BreadcrumbsStore } from "../../../../stores/BreadcrumbsStore"; @@ -93,6 +92,7 @@ import { RoomContextDetails } from "../../rooms/RoomContextDetails"; import { TooltipOption } from "./TooltipOption"; import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom"; import { useSlidingSyncRoomSearch } from "../../../../hooks/useSlidingSyncRoomSearch"; +import { shouldShowFeedback } from "../../../../utils/Feedback"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons @@ -1171,7 +1171,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n } }; - const openFeedback = SdkConfig.get().bug_report_endpoint_url ? () => { + const openFeedback = shouldShowFeedback() ? () => { Modal.createDialog(FeedbackDialog, { feature: "spotlight", }); diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index 1e919981e2a..f8c8e889ea2 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -31,14 +31,13 @@ import AccessibleButton from "../elements/AccessibleButton"; import Field from "../elements/Field"; import withValidation from "../elements/Validation"; import RoomAliasField from "../elements/RoomAliasField"; -import SdkConfig from "../../../SdkConfig"; import Modal from "../../../Modal"; import GenericFeatureFeedbackDialog from "../dialogs/GenericFeatureFeedbackDialog"; import SettingsStore from "../../../settings/SettingsStore"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { UIFeature } from "../../../settings/UIFeature"; +import { shouldShowFeedback } from "../../../utils/Feedback"; export const createSpace = async ( name: string, @@ -101,7 +100,7 @@ const nameToLocalpart = (name: string): string => { // XXX: Temporary for the Spaces release only export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => { - if (!SdkConfig.get().bug_report_endpoint_url || !SettingsStore.getValue(UIFeature.Feedback)) return null; + if (!shouldShowFeedback()) return null; return
{ _t("Spaces are a new feature.") } diff --git a/src/components/views/user-onboarding/UserOnboardingFeedback.tsx b/src/components/views/user-onboarding/UserOnboardingFeedback.tsx index b6bd03dfe86..a0b19c2fd87 100644 --- a/src/components/views/user-onboarding/UserOnboardingFeedback.tsx +++ b/src/components/views/user-onboarding/UserOnboardingFeedback.tsx @@ -22,9 +22,10 @@ import SdkConfig from "../../../SdkConfig"; import AccessibleButton from "../../views/elements/AccessibleButton"; import Heading from "../../views/typography/Heading"; import FeedbackDialog from "../dialogs/FeedbackDialog"; +import { shouldShowFeedback } from "../../../utils/Feedback"; export function UserOnboardingFeedback() { - if (!SdkConfig.get().bug_report_endpoint_url) { + if (!shouldShowFeedback()) { return null; } diff --git a/src/utils/Feedback.ts b/src/utils/Feedback.ts new file mode 100644 index 00000000000..1662fe3fe6a --- /dev/null +++ b/src/utils/Feedback.ts @@ -0,0 +1,23 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import SdkConfig from "../SdkConfig"; +import SettingsStore from "../settings/SettingsStore"; +import { UIFeature } from "../settings/UIFeature"; + +export function shouldShowFeedback(): boolean { + return SdkConfig.get().bug_report_endpoint_url && SettingsStore.getValue(UIFeature.Feedback); +} diff --git a/test/components/structures/ThreadPanel-test.tsx b/test/components/structures/ThreadPanel-test.tsx index 37928c560df..cad8331e360 100644 --- a/test/components/structures/ThreadPanel-test.tsx +++ b/test/components/structures/ThreadPanel-test.tsx @@ -15,68 +15,102 @@ limitations under the License. */ import React from 'react'; -// eslint-disable-next-line deprecate/import -import { shallow, mount } from "enzyme"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { mocked } from "jest-mock"; import 'focus-visible'; // to fix context menus -import { - ThreadFilterType, - ThreadPanelHeader, - ThreadPanelHeaderFilterOptionItem, -} from '../../../src/components/structures/ThreadPanel'; -import { ContextMenuButton } from '../../../src/accessibility/context_menu/ContextMenuButton'; -import ContextMenu from '../../../src/components/structures/ContextMenu'; +import ThreadPanel, { ThreadFilterType, ThreadPanelHeader } from '../../../src/components/structures/ThreadPanel'; import { _t } from '../../../src/languageHandler'; +import ResizeNotifier from '../../../src/utils/ResizeNotifier'; +import { RoomPermalinkCreator } from '../../../src/utils/permalinks/Permalinks'; +import { createTestClient, mkStubRoom } from '../../test-utils'; +import { shouldShowFeedback } from "../../../src/utils/Feedback"; +import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; + +jest.mock("../../../src/utils/Feedback"); describe('ThreadPanel', () => { + describe("Feedback prompt", () => { + const cli = createTestClient(); + const room = mkStubRoom("!room:server", "room", cli); + mocked(cli.getRoom).mockReturnValue(room); + + it("should show feedback prompt if feedback is enabled", () => { + mocked(shouldShowFeedback).mockReturnValue(true); + + render( + + ); + expect(screen.queryByText("Give feedback")).toBeTruthy(); + }); + + it("should hide feedback prompt if feedback is disabled", () => { + mocked(shouldShowFeedback).mockReturnValue(false); + + render( + + ); + expect(screen.queryByText("Give feedback")).toBeFalsy(); + }); + }); + describe('Header', () => { it('expect that All filter for ThreadPanelHeader properly renders Show: All threads', () => { - const wrapper = shallow( + const { asFragment } = render( undefined} />, ); - expect(wrapper).toMatchSnapshot(); + expect(asFragment()).toMatchSnapshot(); }); it('expect that My filter for ThreadPanelHeader properly renders Show: My threads', () => { - const wrapper = shallow( + const { asFragment } = render( undefined} />, ); - expect(wrapper).toMatchSnapshot(); + expect(asFragment()).toMatchSnapshot(); }); it('expect that ThreadPanelHeader properly opens a context menu when clicked on the button', () => { - const wrapper = mount( + const { container } = render( undefined} />, ); - const found = wrapper.find(ContextMenuButton); - expect(found).not.toBe(undefined); - expect(found).not.toBe(null); - expect(wrapper.exists(ContextMenu)).toEqual(false); - found.simulate('click'); - expect(wrapper.exists(ContextMenu)).toEqual(true); + const found = container.querySelector(".mx_ThreadPanel_dropdown"); + expect(found).toBeTruthy(); + expect(screen.queryByRole("menu")).toBeFalsy(); + fireEvent.click(found); + expect(screen.queryByRole("menu")).toBeTruthy(); }); it('expect that ThreadPanelHeader has the correct option selected in the context menu', () => { - const wrapper = mount( + const { container } = render( undefined} />, ); - wrapper.find(ContextMenuButton).simulate('click'); - const found = wrapper.find(ThreadPanelHeaderFilterOptionItem); - expect(found.length).toEqual(2); - const foundButton = found.find('[aria-checked=true]').first(); - expect(foundButton.text()).toEqual(`${_t("All threads")}${_t('Shows all threads from current room')}`); + fireEvent.click(container.querySelector(".mx_ThreadPanel_dropdown")); + const found = screen.queryAllByRole("menuitemradio"); + expect(found).toHaveLength(2); + const foundButton = screen.queryByRole("menuitemradio", { checked: true }); + expect(foundButton.textContent).toEqual(`${_t("All threads")}${_t('Shows all threads from current room')}`); expect(foundButton).toMatchSnapshot(); }); }); diff --git a/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap b/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap index 16b33009272..b6266893ddf 100644 --- a/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap +++ b/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap @@ -1,106 +1,64 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properly renders Show: All threads 1`] = ` -
- - Threads - - +
- Show: All threads - -
+

+ Threads +

+ +
+ `; exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly renders Show: My threads 1`] = ` -
- - Threads - - +
- Show: My threads - -
+

+ Threads +

+ +
+ `; exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option selected in the context menu 1`] = ` - - - - All threads - - - Shows all threads from current room - -
, - } - } - onClick={[Function]} - onFocus={[Function]} - role="menuitemradio" - tabIndex={0} - > -
- - All threads - - - Shows all threads from current room - -
-
- + + All threads + + + Shows all threads from current room + + `; diff --git a/test/components/views/beta/BetaCard-test.tsx b/test/components/views/beta/BetaCard-test.tsx new file mode 100644 index 00000000000..6ba57f99a8b --- /dev/null +++ b/test/components/views/beta/BetaCard-test.tsx @@ -0,0 +1,80 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { mocked } from "jest-mock"; +import { render, screen } from "@testing-library/react"; + +import { shouldShowFeedback } from "../../../../src/utils/Feedback"; +import BetaCard from "../../../../src/components/views/beta/BetaCard"; +import SettingsStore from "../../../../src/settings/SettingsStore"; + +jest.mock("../../../../src/utils/Feedback"); +jest.mock("../../../../src/settings/SettingsStore"); + +describe('', () => { + describe("Feedback prompt", () => { + const featureId = "featureId"; + + beforeEach(() => { + mocked(SettingsStore).getBetaInfo.mockReturnValue({ + title: "title", + caption: () => "caption", + feedbackLabel: "feedbackLabel", + feedbackSubheading: "feedbackSubheading", + }); + mocked(SettingsStore).getValue.mockReturnValue(true); + mocked(shouldShowFeedback).mockReturnValue(true); + }); + + it("should show feedback prompt", () => { + render(); + expect(screen.queryByText("Feedback")).toBeTruthy(); + }); + + it("should not show feedback prompt if beta is disabled", () => { + mocked(SettingsStore).getValue.mockReturnValue(false); + render(); + expect(screen.queryByText("Feedback")).toBeFalsy(); + }); + + it("should not show feedback prompt if label is unset", () => { + mocked(SettingsStore).getBetaInfo.mockReturnValue({ + title: "title", + caption: () => "caption", + feedbackSubheading: "feedbackSubheading", + }); + render(); + expect(screen.queryByText("Feedback")).toBeFalsy(); + }); + + it("should not show feedback prompt if subheading is unset", () => { + mocked(SettingsStore).getBetaInfo.mockReturnValue({ + title: "title", + caption: () => "caption", + feedbackLabel: "feedbackLabel", + }); + render(); + expect(screen.queryByText("Feedback")).toBeFalsy(); + }); + + it("should not show feedback prompt if feedback is disabled", () => { + mocked(shouldShowFeedback).mockReturnValue(false); + render(); + expect(screen.queryByText("Feedback")).toBeFalsy(); + }); + }); +}); diff --git a/test/components/views/dialogs/SpotlightDialog-test.tsx b/test/components/views/dialogs/SpotlightDialog-test.tsx index 3bb16bd54f7..b4c0ac2f7e1 100644 --- a/test/components/views/dialogs/SpotlightDialog-test.tsx +++ b/test/components/views/dialogs/SpotlightDialog-test.tsx @@ -29,6 +29,9 @@ import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoo import { DirectoryMember, startDmOnFirstMessage } from "../../../../src/utils/direct-messages"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { mkRoom, stubClient } from "../../../test-utils"; +import { shouldShowFeedback } from "../../../../src/utils/Feedback"; + +jest.mock("../../../../src/utils/Feedback"); jest.mock("../../../../src/utils/direct-messages", () => ({ // @ts-ignore @@ -138,6 +141,7 @@ describe("Spotlight Dialog", () => { getUserIdForRoomId: jest.fn(), } as unknown as DMRoomMap); }); + describe("should apply filters supplied via props", () => { it("without filter", async () => { const wrapper = mount( @@ -370,4 +374,32 @@ describe("Spotlight Dialog", () => { wrapper.unmount(); }); + + describe("Feedback prompt", () => { + it("should show feedback prompt if feedback is enabled", async () => { + mocked(shouldShowFeedback).mockReturnValue(true); + + const wrapper = mount( null} />); + await act(async () => { + await sleep(200); + }); + wrapper.update(); + + const content = wrapper.find(".mx_SpotlightDialog_footer"); + expect(content.childAt(0).text()).toBe("Results not as expected? Please give feedback."); + }); + + it("should hide feedback prompt if feedback is disabled", async () => { + mocked(shouldShowFeedback).mockReturnValue(false); + + const wrapper = mount( null} />); + await act(async () => { + await sleep(200); + }); + wrapper.update(); + + const content = wrapper.find(".mx_SpotlightDialog_footer"); + expect(content.text()).toBeFalsy(); + }); + }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 2022d73a9c8..aaf8bd95de3 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -444,6 +444,8 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl canInvite: jest.fn(), getThreads: jest.fn().mockReturnValue([]), eventShouldLiveIn: jest.fn().mockReturnValue({}), + createThreadsTimelineSets: jest.fn().mockReturnValue(new Promise(() => {})), + fetchRoomThreads: jest.fn().mockReturnValue(new Promise(() => {})), } as unknown as Room; } diff --git a/test/utils/Feedback-test.ts b/test/utils/Feedback-test.ts new file mode 100644 index 00000000000..64868b71316 --- /dev/null +++ b/test/utils/Feedback-test.ts @@ -0,0 +1,46 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { mocked } from "jest-mock"; + +import SdkConfig from "../../src/SdkConfig"; +import { shouldShowFeedback } from "../../src/utils/Feedback"; +import SettingsStore from "../../src/settings/SettingsStore"; + +jest.mock("../../src/SdkConfig"); +jest.mock("../../src/settings/SettingsStore"); + +describe("shouldShowFeedback", () => { + it("should return false if bug_report_endpoint_url is falsey", () => { + mocked(SdkConfig).get.mockReturnValue({ + bug_report_endpoint_url: null, + }); + expect(shouldShowFeedback()).toBeFalsy(); + }); + + it("should return false if UIFeature.Feedback is disabled", () => { + mocked(SettingsStore).getValue.mockReturnValue(false); + expect(shouldShowFeedback()).toBeFalsy(); + }); + + it("should return true if bug_report_endpoint_url is set and UIFeature.Feedback is true", () => { + mocked(SdkConfig).get.mockReturnValue({ + bug_report_endpoint_url: "https://rageshake.server", + }); + mocked(SettingsStore).getValue.mockReturnValue(true); + expect(shouldShowFeedback()).toBeTruthy(); + }); +}); From d321b5e55f1a82e949a5f2926a3f9c588b2a7590 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 22 Sep 2022 16:24:57 +0100 Subject: [PATCH 010/189] Refactor login flow types into matrix-js-sdk (#9232) Co-authored-by: Travis Ralston --- src/Login.ts | 48 ++++++++---------------------------------------- 1 file changed, 8 insertions(+), 40 deletions(-) diff --git a/src/Login.ts b/src/Login.ts index a16f570fa90..a6104dfdaff 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -19,54 +19,22 @@ limitations under the License. import { createClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; +import { ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth"; import { IMatrixClientCreds } from "./MatrixClientPeg"; import SecurityCustomisations from "./customisations/Security"; +export { + IdentityProviderBrand, + IIdentityProvider, + ISSOFlow, + LoginFlow, +} from "matrix-js-sdk/src/@types/auth"; + interface ILoginOptions { defaultDeviceDisplayName?: string; } -// TODO: Move this to JS SDK -interface IPasswordFlow { - type: "m.login.password"; -} - -export enum IdentityProviderBrand { - Gitlab = "gitlab", - Github = "github", - Apple = "apple", - Google = "google", - Facebook = "facebook", - Twitter = "twitter", -} - -export interface IIdentityProvider { - id: string; - name: string; - icon?: string; - brand?: IdentityProviderBrand | string; -} - -export interface ISSOFlow { - type: "m.login.sso" | "m.login.cas"; - // eslint-disable-next-line camelcase - identity_providers?: IIdentityProvider[]; -} - -export type LoginFlow = ISSOFlow | IPasswordFlow; - -// TODO: Move this to JS SDK -/* eslint-disable camelcase */ -interface ILoginParams { - identifier?: object; - password?: string; - token?: string; - device_id?: string; - initial_device_display_name?: string; -} -/* eslint-enable camelcase */ - export default class Login { private hsUrl: string; private isUrl: string; From 45556e666210454392014bf5017f81505db2f4f6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 23 Sep 2022 08:17:03 +0100 Subject: [PATCH 011/189] Move @testing-library/react to devDeps (#9309) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d3e6fde6b97..389478941f9 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^6.11.0", "@sentry/tracing": "^6.11.0", - "@testing-library/react": "^12.1.5", "@types/geojson": "^7946.0.8", "await-lock": "^2.1.0", "blurhash": "^1.1.3", @@ -139,6 +138,7 @@ "@percy/cypress": "^3.1.1", "@sentry/types": "^6.10.0", "@sinonjs/fake-timers": "^9.1.2", + "@testing-library/react": "^12.1.5", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", "@types/counterpart": "^0.18.1", From 12e3ba8e5ac883b329f851f134501399637fa94a Mon Sep 17 00:00:00 2001 From: Faye Duxovni Date: Fri, 23 Sep 2022 09:21:04 -0400 Subject: [PATCH 012/189] Make device ID copyable in device list (#9297) --- .../views/settings/devices/DeviceTile.tsx | 15 +----- .../__snapshots__/DevicesPanel-test.tsx.snap | 18 +++++++ .../CurrentDeviceSection-test.tsx.snap | 12 +++++ .../__snapshots__/DeviceTile-test.tsx.snap | 36 ++++++++++--- .../SelectableDeviceTile-test.tsx.snap | 18 ++++--- .../SessionManagerTab-test.tsx.snap | 54 ++++++++++--------- 6 files changed, 100 insertions(+), 53 deletions(-) diff --git a/src/components/views/settings/devices/DeviceTile.tsx b/src/components/views/settings/devices/DeviceTile.tsx index a23e1586db4..e48070ddbc7 100644 --- a/src/components/views/settings/devices/DeviceTile.tsx +++ b/src/components/views/settings/devices/DeviceTile.tsx @@ -19,8 +19,6 @@ import React, { Fragment } from "react"; import { Icon as InactiveIcon } from '../../../../../res/img/element-icons/settings/inactive.svg'; import { _t } from "../../../../languageHandler"; import { formatDate, formatRelativeTime } from "../../../../DateUtils"; -import TooltipTarget from "../../elements/TooltipTarget"; -import { Alignment } from "../../elements/Tooltip"; import Heading from "../../typography/Heading"; import { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "./filter"; import { DeviceWithVerification } from "./types"; @@ -32,18 +30,8 @@ export interface DeviceTileProps { } const DeviceTileName: React.FC<{ device: DeviceWithVerification }> = ({ device }) => { - if (device.display_name) { - return - - { device.display_name } - - ; - } return - { device.device_id } + { device.display_name || device.device_id } ; }; @@ -91,6 +79,7 @@ const DeviceTile: React.FC = ({ device, children, onClick }) => { id: 'isVerified', value: verificationStatus }, { id: 'lastActivity', value: lastActivity }, { id: 'lastSeenIp', value: device.last_seen_ip }, + { id: 'deviceId', value: device.device_id }, ]; return
diff --git a/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap b/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap index 2f2f3a7773e..0cdead4e6e3 100644 --- a/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap +++ b/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap @@ -141,6 +141,12 @@ exports[` renders device panel with devices 1`] = ` > Unverified + · + + device_1 +
renders device panel with devices 1`] = ` > Unverified + · + + device_2 +
renders device panel with devices 1`] = ` > Unverified + · + + device_3 +
renders device and correct security card when > Unverified + · + + alices_device +
renders device and correct security card when > Unverified + · + + alices_device +
renders a device with no metadata 1`] = ` > Unverified + · + + 123 +
renders a verified device with no metadata 1`] = ` > Unverified + · + + 123 +
renders display name with a tooltip 1`] = `
-
-

- My device -

-
+ My device +
separates metadata with a dot 1`] = ` > 1.2.3.4 + · + + 123 +
renders unselected device tile with checkbox 1
-
-

- My Device -

-
+ My Device +
renders current session section with a verified s
-
-

- Alices device -

-
+ Alices device +
renders current session section with an unverifie
-
-

- Alices device -

-
+ Alices device +
sets device verification status correctly 1`] = `
-
-

- Alices device -

-
+ Alices device +
Date: Sun, 25 Sep 2022 10:57:25 -0400 Subject: [PATCH 013/189] New group call experience: Room header call buttons (#9311) * Make useEventEmitterState more efficient By not invoking the initializing function on every render * Make useWidgets more efficient By not calling WidgetStore on every render * Add new group call experience Labs flag * Add viewingCall field to RoomViewStore state Currently has no effect, but in the future this will signal to RoomView to show the call or call lobby. * Add element_call.use_exclusively config flag As documented in element-web, this will tell the app to use Element Call exclusively for calls, disabling Jitsi and legacy 1:1 calls. * Make placeCall return a promise So that the UI can know when placeCall completes * Update start call buttons to new group call designs Since RoomView doesn't do anything with viewingCall yet, these buttons won't have any effect when starting native group calls, but the logic is at least all there and ready to be hooked up. * Allow calls to be detected if the new group call experience is enabled * Test the RoomHeader changes * Iterate code --- package.json | 1 + src/IConfigOptions.ts | 1 + src/LegacyCallHandler.tsx | 8 +- src/SdkConfig.ts | 1 + src/components/structures/RoomView.tsx | 21 +- .../views/right_panel/RoomSummaryCard.tsx | 2 +- src/components/views/rooms/RoomHeader.tsx | 320 ++++++++++-- src/dispatcher/payloads/ViewRoomPayload.ts | 1 + src/hooks/useEventEmitter.ts | 2 +- src/i18n/strings/en_EN.json | 9 +- src/models/Call.ts | 10 +- src/settings/Settings.tsx | 13 +- src/stores/RoomViewStore.tsx | 118 +++-- src/stores/WidgetEchoStore.ts | 2 +- .../views/rooms/RoomHeader-test.tsx | 468 +++++++++++++++++- yarn.lock | 155 +++++- 16 files changed, 1007 insertions(+), 125 deletions(-) diff --git a/package.json b/package.json index 389478941f9..15dc5b6f791 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "@percy/cypress": "^3.1.1", "@sentry/types": "^6.10.0", "@sinonjs/fake-timers": "^9.1.2", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 837d3050f35..b877cb90af5 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -118,6 +118,7 @@ export interface IConfigOptions { }; element_call: { url: string; + use_exclusively: boolean; }; logout_redirect_url?: string; diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index 624dc86a33f..a924388eadb 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -820,10 +820,10 @@ export default class LegacyCallHandler extends EventEmitter { } } - public placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): void { + public async placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): Promise { // We might be using managed hybrid widgets if (isManagedHybridWidgetEnabled()) { - addManagedHybridWidget(roomId); + await addManagedHybridWidget(roomId); return; } @@ -870,9 +870,9 @@ export default class LegacyCallHandler extends EventEmitter { } else if (members.length === 2) { logger.info(`Place ${type} call in ${roomId}`); - this.placeMatrixCall(roomId, type, transferee); + await this.placeMatrixCall(roomId, type, transferee); } else { // > 2 - this.placeJitsiCall(roomId, type); + await this.placeJitsiCall(roomId, type); } } diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index d466a050741..7a869827235 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -32,6 +32,7 @@ export const DEFAULTS: IConfigOptions = { }, element_call: { url: "https://call.element.io", + use_exclusively: false, }, // @ts-ignore - we deliberately use the camelCase version here so we trigger diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index fed78d76177..1c20195b685 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -30,7 +30,7 @@ import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; import { EventType } from 'matrix-js-sdk/src/@types/event'; import { RoomState, RoomStateEvent } from 'matrix-js-sdk/src/models/room-state'; import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; -import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { throttle } from "lodash"; import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { ClientEvent } from "matrix-js-sdk/src/client"; @@ -149,7 +149,7 @@ interface IRoomProps extends MatrixClientProps { enum MainSplitContentType { Timeline, MaximisedWidget, - Video, // immersive voip + Call, } export interface IRoomState { room?: Room; @@ -299,7 +299,6 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { e2eStatus={E2EStatus.Normal} onAppsClick={null} appsShown={false} - onCallPlaced={null} excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} @@ -350,7 +349,6 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement e2eStatus={E2EStatus.Normal} onAppsClick={null} appsShown={false} - onCallPlaced={null} excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} @@ -517,7 +515,7 @@ export class RoomView extends React.Component { private getMainSplitContentType = (room: Room) => { if (SettingsStore.getValue("feature_video_rooms") && isVideoRoom(room)) { - return MainSplitContentType.Video; + return MainSplitContentType.Call; } if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { return MainSplitContentType.MaximisedWidget; @@ -1660,10 +1658,6 @@ export class RoomView extends React.Component { return ret; } - private onCallPlaced = (type: CallType): void => { - LegacyCallHandler.instance.placeCall(this.state.room?.roomId, type); - }; - private onAppsClick = () => { dis.dispatch({ action: "appsDrawer", @@ -2330,7 +2324,7 @@ export class RoomView extends React.Component { const mainClasses = classNames("mx_RoomView", { mx_RoomView_inCall: Boolean(activeCall), - mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Video, + mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Call, }); const showChatEffects = SettingsStore.getValue('showChatEffects'); @@ -2371,7 +2365,7 @@ export class RoomView extends React.Component { { previewBar } ; break; - case MainSplitContentType.Video: { + case MainSplitContentType.Call: { mainSplitContentClassName = "mx_MainSplit_video"; mainSplitBody = <> @@ -2382,7 +2376,6 @@ export class RoomView extends React.Component { const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName); let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline]; - let onCallPlaced = this.onCallPlaced; let onAppsClick = this.onAppsClick; let onForgetClick = this.onForgetClick; let onSearchClick = this.onSearchClick; @@ -2399,13 +2392,12 @@ export class RoomView extends React.Component { onForgetClick = null; onSearchClick = null; break; - case MainSplitContentType.Video: + case MainSplitContentType.Call: excludedRightPanelPhaseButtons = [ RightPanelPhases.ThreadPanel, RightPanelPhases.PinnedMessages, RightPanelPhases.NotificationPanel, ]; - onCallPlaced = null; onAppsClick = null; onForgetClick = null; onSearchClick = null; @@ -2432,7 +2424,6 @@ export class RoomView extends React.Component { e2eStatus={this.state.e2eStatus} onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null} appsShown={this.state.showApps} - onCallPlaced={onCallPlaced} excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} showButtons={!this.viewsLocalRoom} enableRoomOptionsMenu={!this.viewsLocalRoom} diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index d2429f1a7be..05316ffff65 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -76,7 +76,7 @@ const Button: React.FC = ({ children, className, onClick }) => { }; export const useWidgets = (room: Room) => { - const [apps, setApps] = useState(WidgetStore.instance.getApps(room.roomId)); + const [apps, setApps] = useState(() => WidgetStore.instance.getApps(room.roomId)); const updateApps = useCallback(() => { // Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index d64d3d7e322..0d01e039c4f 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -15,12 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { FC, useState, useMemo, useCallback } from 'react'; import classNames from 'classnames'; import { throttle } from 'lodash'; -import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk/src/matrix'; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import type { Room } from "matrix-js-sdk/src/models/room"; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import defaultDispatcher from "../../../dispatcher/dispatcher"; @@ -30,13 +32,14 @@ import SettingsStore from "../../../settings/SettingsStore"; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import E2EIcon from './E2EIcon'; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; import { E2EStatus } from '../../../utils/ShieldUtils'; import { IOOBData } from '../../../stores/ThreepidInviteStore'; import { SearchScope } from './SearchBar'; -import { ContextMenuTooltipButton } from '../../structures/ContextMenu'; +import { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import RoomContextMenu from "../context_menus/RoomContextMenu"; import { contextMenuBelow } from './RoomTile'; import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore'; @@ -48,6 +51,272 @@ import { BetaPill } from "../beta/BetaCard"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; +import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler"; +import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings"; +import SdkConfig from "../../../SdkConfig"; +import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; +import { useWidgets } from "../right_panel/RoomSummaryCard"; +import { WidgetType } from "../../../widgets/WidgetType"; +import { useCall } from "../../../hooks/useCall"; +import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers"; +import { ElementCall } from "../../../models/Call"; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from "../context_menus/IconizedContextMenu"; +import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; + +class DisabledWithReason { + constructor(public readonly reason: string) { } +} + +interface VoiceCallButtonProps { + room: Room; + busy: boolean; + setBusy: (value: boolean) => void; + behavior: DisabledWithReason | "legacy_or_jitsi"; +} + +/** + * Button for starting voice calls, supporting only legacy 1:1 calls and Jitsi + * widgets. + */ +const VoiceCallButton: FC = ({ room, busy, setBusy, behavior }) => { + const { onClick, tooltip, disabled } = useMemo(() => { + if (behavior instanceof DisabledWithReason) { + return { + onClick: () => {}, + tooltip: behavior.reason, + disabled: true, + }; + } else { // behavior === "legacy_or_jitsi" + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + setBusy(true); + await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Voice); + setBusy(false); + }, + disabled: false, + }; + } + }, [behavior, room, setBusy]); + + return ; +}; + +interface VideoCallButtonProps { + room: Room; + busy: boolean; + setBusy: (value: boolean) => void; + behavior: DisabledWithReason | "legacy_or_jitsi" | "element" | "jitsi_or_element"; +} + +/** + * Button for starting video calls, supporting both legacy 1:1 calls, Jitsi + * widgets, and native group calls. If multiple calling options are available, + * this shows a menu to pick between them. + */ +const VideoCallButton: FC = ({ room, busy, setBusy, behavior }) => { + const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu(); + + const startLegacyCall = useCallback(async () => { + setBusy(true); + await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Video); + setBusy(false); + }, [setBusy, room]); + + const startElementCall = useCallback(() => { + setBusy(true); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + metricsTrigger: undefined, + }); + setBusy(false); + }, [setBusy, room]); + + const { onClick, tooltip, disabled } = useMemo(() => { + if (behavior instanceof DisabledWithReason) { + return { + onClick: () => {}, + tooltip: behavior.reason, + disabled: true, + }; + } else if (behavior === "legacy_or_jitsi") { + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + await startLegacyCall(); + }, + disabled: false, + }; + } else if (behavior === "element") { + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + startElementCall(); + }, + disabled: false, + }; + } else { // behavior === "jitsi_or_element" + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + openMenu(); + }, + disabled: false, + }; + } + }, [behavior, startLegacyCall, startElementCall, openMenu]); + + const onJitsiClick = useCallback(async (ev: ButtonEvent) => { + ev.preventDefault(); + closeMenu(); + await startLegacyCall(); + }, [closeMenu, startLegacyCall]); + + const onElementClick = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + closeMenu(); + startElementCall(); + }, [closeMenu, startElementCall]); + + let menu: JSX.Element | null = null; + if (menuOpen) { + const buttonRect = buttonRef.current!.getBoundingClientRect(); + menu = + + + + + ; + } + + return <> + + { menu } + ; +}; + +interface CallButtonsProps { + room: Room; +} + +// The header buttons for placing calls have become stupidly complex, so here +// they are as a separate component +const CallButtons: FC = ({ room }) => { + const [busy, setBusy] = useState(false); + const showButtons = useSettingValue("showCallButtonsInComposer"); + const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); + const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); + const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]); + const useElementCallExclusively = useMemo(() => SdkConfig.get("element_call").use_exclusively, []); + + const hasLegacyCall = useEventEmitterState( + LegacyCallHandler.instance, + LegacyCallHandlerEvent.CallsChanged, + useCallback(() => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, [room]), + ); + + const widgets = useWidgets(room); + const hasJitsiWidget = useMemo(() => widgets.some(widget => WidgetType.JITSI.matches(widget.type)), [widgets]); + + const hasGroupCall = useCall(room.roomId) !== null; + + const [functionalMembers, mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState( + room, + RoomStateEvent.Update, + useCallback(() => [ + getJoinedNonFunctionalMembers(room), + room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client), + room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client), + ], [room]), + ); + + const makeVoiceCallButton = (behavior: VoiceCallButtonProps["behavior"]): JSX.Element => + ; + const makeVideoCallButton = (behavior: VideoCallButtonProps["behavior"]): JSX.Element => + ; + + if (isVideoRoom || !showButtons) { + return null; + } else if (groupCallsEnabled) { + if (useElementCallExclusively) { + if (hasGroupCall) { + return makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))); + } else if (mayCreateElementCalls) { + return makeVideoCallButton("element"); + } else { + return makeVideoCallButton( + new DisabledWithReason(_t("You do not have permission to start video calls")), + ); + } + } else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))) } + ; + } else if (functionalMembers.length <= 1) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + ; + } else if (functionalMembers.length === 2) { + return <> + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton("legacy_or_jitsi") } + ; + } else if (mayEditWidgets) { + return <> + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton(mayCreateElementCalls ? "jitsi_or_element" : "legacy_or_jitsi") } + ; + } else { + const videoCallBehavior = mayCreateElementCalls + ? "element" + : new DisabledWithReason(_t("You do not have permission to start video calls")); + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls"))) } + { makeVideoCallButton(videoCallBehavior) } + ; + } + } else if (hasLegacyCall || hasJitsiWidget) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))) } + ; + } else if (functionalMembers.length <= 1) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + ; + } else if (functionalMembers.length === 2 || mayEditWidgets) { + return <> + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton("legacy_or_jitsi") } + ; + } else { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls"))) } + { makeVideoCallButton(new DisabledWithReason(_t("You do not have permission to start video calls"))) } + ; + } +}; export interface ISearchInfo { searchTerm: string; @@ -55,15 +324,14 @@ export interface ISearchInfo { searchCount: number; } -interface IProps { +export interface IProps { room: Room; oobData?: IOOBData; inRoom: boolean; - onSearchClick: () => void; - onInviteClick: () => void; - onForgetClick: () => void; - onCallPlaced: (type: CallType) => void; - onAppsClick: () => void; + onSearchClick: (() => void) | null; + onInviteClick: (() => void) | null; + onForgetClick: (() => void) | null; + onAppsClick: (() => void) | null; e2eStatus: E2EStatus; appsShown: boolean; searchInfo: ISearchInfo; @@ -89,7 +357,7 @@ export default class RoomHeader extends React.Component { static contextType = RoomContext; public context!: React.ContextType; - constructor(props, context) { + constructor(props: IProps, context: IState) { super(props, context); const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room); notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate); @@ -141,30 +409,14 @@ export default class RoomHeader extends React.Component { }; private onContextMenuCloseClick = () => { - this.setState({ contextMenuPosition: null }); + this.setState({ contextMenuPosition: undefined }); }; private renderButtons(): JSX.Element[] { const buttons: JSX.Element[] = []; - if (this.props.inRoom && - this.props.onCallPlaced && - !this.context.tombstone && - SettingsStore.getValue("showCallButtonsInComposer") - ) { - const voiceCallButton = this.props.onCallPlaced(CallType.Voice)} - title={_t("Voice call")} - key="voice" - />; - const videoCallButton = this.props.onCallPlaced(CallType.Video)} - title={_t("Video call")} - key="video" - />; - buttons.push(voiceCallButton, videoCallButton); + if (this.props.inRoom && !this.context.tombstone) { + buttons.push(); } if (this.props.onForgetClick) { @@ -212,8 +464,8 @@ export default class RoomHeader extends React.Component { return buttons; } - private renderName(oobName) { - let contextMenu: JSX.Element; + private renderName(oobName: string) { + let contextMenu: JSX.Element | null = null; if (this.state.contextMenuPosition && this.props.room) { contextMenu = ( { } public render() { - let searchStatus = null; + let searchStatus: JSX.Element | null = null; // don't display the search count until the search completes and // gives us a valid (possibly zero) searchCount. @@ -291,7 +543,7 @@ export default class RoomHeader extends React.Component { className="mx_RoomHeader_topic" />; - let roomAvatar; + let roomAvatar: JSX.Element | null = null; if (this.props.room) { roomAvatar = { />; } - let buttons; + let buttons: JSX.Element | null = null; if (this.props.showButtons) { buttons =
diff --git a/src/dispatcher/payloads/ViewRoomPayload.ts b/src/dispatcher/payloads/ViewRoomPayload.ts index cd62f7ca3fd..e497939ff04 100644 --- a/src/dispatcher/payloads/ViewRoomPayload.ts +++ b/src/dispatcher/payloads/ViewRoomPayload.ts @@ -47,6 +47,7 @@ export interface ViewRoomPayload extends Pick { forceTimeline?: boolean; // Whether to override default behaviour to end up at a timeline show_room_tile?: boolean; // Whether to ensure that the room tile is visible in the room list clear_search?: boolean; // Whether to clear the room list search + view_call?: boolean; // Whether to view the call or call lobby for the room deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action diff --git a/src/hooks/useEventEmitter.ts b/src/hooks/useEventEmitter.ts index 46a7d8f184c..ff1592028ae 100644 --- a/src/hooks/useEventEmitter.ts +++ b/src/hooks/useEventEmitter.ts @@ -87,7 +87,7 @@ export function useEventEmitterState( eventName: string | symbol, fn: Mapper, ): T { - const [value, setValue] = useState(fn()); + const [value, setValue] = useState(fn); const handler = useCallback((...args: any[]) => { setValue(fn(...args)); }, [fn]); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1534c4d51f1..5c1359d499d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -865,6 +865,7 @@ "Spaces": "Spaces", "Widgets": "Widgets", "Rooms": "Rooms", + "Voice & Video": "Voice & Video", "Moderation": "Moderation", "Analytics": "Analytics", "Message Previews": "Message Previews", @@ -910,6 +911,7 @@ "Send read receipts": "Send read receipts", "Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync mode (under active development, cannot be disabled)", "Element Call video rooms": "Element Call video rooms", + "New group call experience": "New group call experience", "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", "Favourite Messages (under active development)": "Favourite Messages (under active development)", "Voice broadcast (under active development)": "Voice broadcast (under active development)", @@ -1591,7 +1593,6 @@ "No Microphones detected": "No Microphones detected", "Camera": "Camera", "No Webcams detected": "No Webcams detected", - "Voice & Video": "Voice & Video", "This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers", "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.", "Upgrade this space to the recommended room version": "Upgrade this space to the recommended room version", @@ -1868,6 +1869,12 @@ "Room %(name)s": "Room %(name)s", "Recently visited rooms": "Recently visited rooms", "No recently visited rooms": "No recently visited rooms", + "Video call (Jitsi)": "Video call (Jitsi)", + "Video call (Element Call)": "Video call (Element Call)", + "Ongoing call": "Ongoing call", + "You do not have permission to start video calls": "You do not have permission to start video calls", + "There's no one here to call": "There's no one here to call", + "You do not have permission to start voice calls": "You do not have permission to start voice calls", "Forget room": "Forget room", "Hide Widgets": "Hide Widgets", "Show Widgets": "Show Widgets", diff --git a/src/models/Call.ts b/src/models/Call.ts index 9b11261e85d..bc8bb6a65a2 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -635,11 +635,13 @@ export class ElementCall extends Call { } public static get(room: Room): ElementCall | null { - // Only supported in video rooms (for now) + // Only supported in the new group call experience or in video rooms if ( - SettingsStore.getValue("feature_video_rooms") - && SettingsStore.getValue("feature_element_call_video_rooms") - && room.isCallRoom() + SettingsStore.getValue("feature_group_calls") || ( + SettingsStore.getValue("feature_video_rooms") + && SettingsStore.getValue("feature_element_call_video_rooms") + && room.isCallRoom() + ) ) { const groupCalls = ElementCall.CALL_EVENT_TYPE.names.flatMap(eventType => room.currentState.getStateEvents(eventType), diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 0567d10fb89..5220f9d0604 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -92,6 +92,7 @@ export enum LabGroup { Spaces, Widgets, Rooms, + VoiceAndVideo, Moderation, Analytics, MessagePreviews, @@ -111,6 +112,7 @@ export const labGroupNames: Record = { [LabGroup.Spaces]: _td("Spaces"), [LabGroup.Widgets]: _td("Widgets"), [LabGroup.Rooms]: _td("Rooms"), + [LabGroup.VoiceAndVideo]: _td("Voice & Video"), [LabGroup.Moderation]: _td("Moderation"), [LabGroup.Analytics]: _td("Analytics"), [LabGroup.MessagePreviews]: _td("Message Previews"), @@ -191,7 +193,7 @@ export type ISetting = IBaseSetting | IFeature; export const SETTINGS: {[setting: string]: ISetting} = { "feature_video_rooms": { isFeature: true, - labsGroup: LabGroup.Rooms, + labsGroup: LabGroup.VoiceAndVideo, displayName: _td("Video rooms"), supportedLevels: LEVELS_FEATURE, default: false, @@ -426,11 +428,18 @@ export const SETTINGS: {[setting: string]: ISetting} = { "feature_element_call_video_rooms": { isFeature: true, supportedLevels: LEVELS_FEATURE, - labsGroup: LabGroup.Rooms, + labsGroup: LabGroup.VoiceAndVideo, displayName: _td("Element Call video rooms"), controller: new ReloadOnChangeController(), default: false, }, + "feature_group_calls": { + isFeature: true, + supportedLevels: LEVELS_FEATURE, + labsGroup: LabGroup.VoiceAndVideo, + displayName: _td("New group call experience"), + default: false, + }, "feature_location_share_live": { isFeature: true, labsGroup: LabGroup.Messaging, diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 3c127275a25..3db9c084342 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -53,38 +53,75 @@ import { UPDATE_EVENT } from "./AsyncStore"; const NUM_JOIN_RETRY = 5; -const INITIAL_STATE = { - // Whether we're joining the currently viewed room (see isJoining()) - joining: false, - // Any error that has occurred during joining - joinError: null as Error, - // The room ID of the room currently being viewed - roomId: null as string, - // The room ID being subscribed to (in Sliding Sync) - subscribingRoomId: null as string, +interface State { + /** + * Whether we're joining the currently viewed (see isJoining()) + */ + joining: boolean; + /** + * Any error that has occurred during joining + */ + joinError: Error | null; + /** + * The ID of the room currently being viewed + */ + roomId: string | null; + /** + * The ID of the room being subscribed to (in Sliding Sync) + */ + subscribingRoomId: string | null; + /** + * The event to scroll to when the room is first viewed + */ + initialEventId: string | null; + initialEventPixelOffset: number | null; + /** + * Whether to highlight the initial event + */ + isInitialEventHighlighted: boolean; + /** + * Whether to scroll the initial event into view + */ + initialEventScrollIntoView: boolean; + /** + * The alias of the room (or null if not originally specified in view_room) + */ + roomAlias: string | null; + /** + * Whether the current room is loading + */ + roomLoading: boolean; + /** + * Any error that has occurred during loading + */ + roomLoadError: MatrixError | null; + replyingToEvent: MatrixEvent | null; + shouldPeek: boolean; + viaServers: string[]; + wasContextSwitch: boolean; + /** + * Whether we're viewing a call or call lobby in this room + */ + viewingCall: boolean; +} - // The event to scroll to when the room is first viewed - initialEventId: null as string, - initialEventPixelOffset: null as number, - // Whether to highlight the initial event +const INITIAL_STATE: State = { + joining: false, + joinError: null, + roomId: null, + subscribingRoomId: null, + initialEventId: null, + initialEventPixelOffset: null, isInitialEventHighlighted: false, - // whether to scroll `event_id` into view initialEventScrollIntoView: true, - - // The room alias of the room (or null if not originally specified in view_room) - roomAlias: null as string, - // Whether the current room is loading + roomAlias: null, roomLoading: false, - // Any error that has occurred during loading - roomLoadError: null as MatrixError, - - replyingToEvent: null as MatrixEvent, - + roomLoadError: null, + replyingToEvent: null, shouldPeek: false, - - viaServers: [] as string[], - + viaServers: [], wasContextSwitch: false, + viewingCall: false, }; type Listener = (isActive: boolean) => void; @@ -98,7 +135,7 @@ export class RoomViewStore extends EventEmitter { // the app. We need to eagerly create the instance. public static readonly instance = new RoomViewStore(defaultDispatcher); - private state = INITIAL_STATE; // initialize state + private state: State = INITIAL_STATE; // initialize state private dis: MatrixDispatcher; private dispatchToken: string; @@ -120,7 +157,7 @@ export class RoomViewStore extends EventEmitter { this.emit(roomId, isActive); } - private setState(newState: Partial): void { + private setState(newState: Partial): void { // If values haven't changed, there's nothing to do. // This only tries a shallow comparison, so unchanged objects will slip // through, but that's probably okay for now. @@ -172,6 +209,7 @@ export class RoomViewStore extends EventEmitter { roomAlias: null, viaServers: [], wasContextSwitch: false, + viewingCall: false, }); break; case Action.ViewRoomError: @@ -286,6 +324,7 @@ export class RoomViewStore extends EventEmitter { roomLoadError: null, viaServers: payload.via_servers, wasContextSwitch: payload.context_switch, + viewingCall: payload.view_call ?? false, }); // set this room as the room subscription. We need to await for it as this will fetch // all room state for this room, which is required before we get the state below. @@ -303,11 +342,11 @@ export class RoomViewStore extends EventEmitter { return; } - const newState = { + const newState: Partial = { roomId: payload.room_id, - roomAlias: payload.room_alias, - initialEventId: payload.event_id, - isInitialEventHighlighted: payload.highlighted, + roomAlias: payload.room_alias ?? null, + initialEventId: payload.event_id ?? null, + isInitialEventHighlighted: payload.highlighted ?? false, initialEventScrollIntoView: payload.scroll_into_view ?? true, roomLoading: false, roomLoadError: null, @@ -317,8 +356,12 @@ export class RoomViewStore extends EventEmitter { joining: payload.joining || false, // Reset replyingToEvent because we don't want cross-room because bad UX replyingToEvent: null, - viaServers: payload.via_servers, - wasContextSwitch: payload.context_switch, + viaServers: payload.via_servers ?? [], + wasContextSwitch: payload.context_switch ?? false, + viewingCall: payload.view_call ?? ( + // Reset to false when switching rooms + payload.room_id === this.state.roomId ? this.state.viewingCall : false + ), }; // Allow being given an event to be replied to when switching rooms but sanity check its for this room @@ -351,13 +394,14 @@ export class RoomViewStore extends EventEmitter { roomId: null, initialEventId: null, initialEventPixelOffset: null, - isInitialEventHighlighted: null, + isInitialEventHighlighted: false, initialEventScrollIntoView: true, roomAlias: payload.room_alias, roomLoading: true, roomLoadError: null, viaServers: payload.via_servers, wasContextSwitch: payload.context_switch, + viewingCall: payload.view_call ?? false, }); try { const result = await MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias); @@ -577,4 +621,8 @@ export class RoomViewStore extends EventEmitter { public getWasContextSwitch(): boolean { return this.state.wasContextSwitch; } + + public isViewingCall(): boolean { + return this.state.viewingCall; + } } diff --git a/src/stores/WidgetEchoStore.ts b/src/stores/WidgetEchoStore.ts index 2923d46b09e..ac524eb4cf6 100644 --- a/src/stores/WidgetEchoStore.ts +++ b/src/stores/WidgetEchoStore.ts @@ -111,7 +111,7 @@ class WidgetEchoStore extends EventEmitter { } } -let singletonWidgetEchoStore = null; +let singletonWidgetEchoStore: WidgetEchoStore | null = null; if (!singletonWidgetEchoStore) { singletonWidgetEchoStore = new WidgetEchoStore(); } diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index baaf85eb446..7181f143c3e 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -17,23 +17,48 @@ limitations under the License. import React from 'react'; // eslint-disable-next-line deprecate/import import { mount, ReactWrapper } from 'enzyme'; -import { Room, PendingEventOrdering, MatrixEvent, MatrixClient } from 'matrix-js-sdk/src/matrix'; - -import * as TestUtils from '../../../test-utils'; +import { render, screen, act, fireEvent, waitFor, getByRole } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { mocked, Mocked } from "jest-mock"; +import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; + +import type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { + stubClient, + mkRoomMember, + setupAsyncStoreWithClient, + resetAsyncStoreWithClient, + mockPlatformPeg, +} from "../../../test-utils"; import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; import DMRoomMap from '../../../../src/utils/DMRoomMap'; -import RoomHeader from '../../../../src/components/views/rooms/RoomHeader'; +import RoomHeader, { IProps as RoomHeaderProps } from "../../../../src/components/views/rooms/RoomHeader"; import { SearchScope } from '../../../../src/components/views/rooms/SearchBar'; import { E2EStatus } from '../../../../src/utils/ShieldUtils'; import { mkEvent } from '../../../test-utils'; import { IRoomState } from "../../../../src/components/structures/RoomView"; import RoomContext from '../../../../src/contexts/RoomContext'; - -describe('RoomHeader', () => { +import SdkConfig from "../../../../src/SdkConfig"; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import { ElementCall, JitsiCall } from "../../../../src/models/Call"; +import { CallStore } from "../../../../src/stores/CallStore"; +import LegacyCallHandler from "../../../../src/LegacyCallHandler"; +import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../src/dispatcher/actions"; +import WidgetStore from "../../../../src/stores/WidgetStore"; + +describe('RoomHeader (Enzyme)', () => { it('shows the room avatar in a room with only ourselves', () => { // When we render a non-DM room with 1 person in it const room = createRoom({ name: "X Room", isDm: false, userIds: [] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -48,7 +73,7 @@ describe('RoomHeader', () => { // When we render a non-DM room with 2 people in it const room = createRoom( { name: "Y Room", isDm: false, userIds: ["other"] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -62,7 +87,7 @@ describe('RoomHeader', () => { it('shows the room avatar in a room with >2 people', () => { // When we render a non-DM room with 3 people in it const room = createRoom({ name: "Z Room", isDm: false, userIds: ["other1", "other2"] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -76,7 +101,7 @@ describe('RoomHeader', () => { it('shows the room avatar in a DM with only ourselves', () => { // When we render a non-DM room with 1 person in it const room = createRoom({ name: "Z Room", isDm: true, userIds: [] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -93,7 +118,7 @@ describe('RoomHeader', () => { // When we render a DM room with only 2 people in it const room = createRoom({ name: "Y Room", isDm: true, userIds: ["other"] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then we use the other user's avatar as our room's image avatar const image = findImg(rendered, ".mx_BaseAvatar_image"); @@ -106,8 +131,9 @@ describe('RoomHeader', () => { it('shows the room avatar in a DM with >2 people', () => { // When we render a DM room with 3 people in it const room = createRoom({ - name: "Z Room", isDm: true, userIds: ["other1", "other2"] }); - const rendered = render(room); + name: "Z Room", isDm: true, userIds: ["other1", "other2"], + }); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -119,8 +145,8 @@ describe('RoomHeader', () => { }); it("renders call buttons normally", () => { - const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room); + const room = createRoom({ name: "Room", isDm: false, userIds: ["other"] }); + const wrapper = mountHeader(room); expect(wrapper.find('[aria-label="Voice call"]').hostNodes()).toHaveLength(1); expect(wrapper.find('[aria-label="Video call"]').hostNodes()).toHaveLength(1); @@ -128,7 +154,7 @@ describe('RoomHeader', () => { it("hides call buttons when the room is tombstoned", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room, {}, { + const wrapper = mountHeader(room, {}, { tombstone: mkEvent({ event: true, type: "m.room.tombstone", @@ -146,25 +172,25 @@ describe('RoomHeader', () => { it("should render buttons if not passing showButtons (default true)", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room); + const wrapper = mountHeader(room); expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(1); }); it("should not render buttons if passing showButtons = false", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room, { showButtons: false }); + const wrapper = mountHeader(room, { showButtons: false }); expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(0); }); it("should render the room options context menu if not passing enableRoomOptionsMenu (default true)", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room); + const wrapper = mountHeader(room); expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(1); }); it("should not render the room options context menu if passing enableRoomOptionsMenu = false", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room, { enableRoomOptionsMenu: false }); + const wrapper = mountHeader(room, { enableRoomOptionsMenu: false }); expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(0); }); }); @@ -176,7 +202,7 @@ interface IRoomCreationInfo { } function createRoom(info: IRoomCreationInfo) { - TestUtils.stubClient(); + stubClient(); const client: MatrixClient = MatrixClientPeg.get(); const roomId = '!1234567890:domain'; @@ -210,15 +236,15 @@ function createRoom(info: IRoomCreationInfo) { return room; } -function render(room: Room, propsOverride = {}, roomContext?: Partial): ReactWrapper { +function mountHeader(room: Room, propsOverride = {}, roomContext?: Partial): ReactWrapper { const props = { room, inRoom: true, - onSearchClick: () => {}, + onSearchClick: () => { }, onInviteClick: null, - onForgetClick: () => {}, + onForgetClick: () => { }, onCallPlaced: (_type) => { }, - onAppsClick: () => {}, + onAppsClick: () => { }, e2eStatus: E2EStatus.Normal, appsShown: true, searchInfo: { @@ -307,3 +333,395 @@ function findImg(wrapper: ReactWrapper, selector: string): ReactWrapper { expect(els).toHaveLength(1); return els.at(0); } + +describe("RoomHeader (React Testing Library)", () => { + let client: Mocked; + let room: Room; + let alice: RoomMember; + let bob: RoomMember; + let carol: RoomMember; + + beforeEach(async () => { + mockPlatformPeg({ supportsJitsiScreensharing: () => true }); + + stubClient(); + client = mocked(MatrixClientPeg.get()); + client.getUserId.mockReturnValue("@alice:example.org"); + + room = new Room("!1:example.org", client, "@alice:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + room.currentState.setStateEvents([mkCreationEvent(room.roomId, "@alice:example.org")]); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => { + if (roomId !== room.roomId) throw new Error("Unknown room"); + const event = mkEvent({ + event: true, + type: eventType, + room: roomId, + user: alice.userId, + skey: stateKey, + content, + }); + room.addLiveEvents([event]); + return { event_id: event.getId() }; + }); + + alice = mkRoomMember(room.roomId, "@alice:example.org"); + bob = mkRoomMember(room.roomId, "@bob:example.org"); + carol = mkRoomMember(room.roomId, "@carol:example.org"); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + + await Promise.all([CallStore.instance, WidgetStore.instance].map( + store => setupAsyncStoreWithClient(store, client), + )); + }); + + afterEach(async () => { + await Promise.all([CallStore.instance, WidgetStore.instance].map(resetAsyncStoreWithClient)); + client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); + jest.restoreAllMocks(); + SdkConfig.put({}); + }); + + const mockRoomType = (type: string) => { + jest.spyOn(room, "getType").mockReturnValue(type); + }; + const mockRoomMembers = (members: RoomMember[]) => { + jest.spyOn(room, "getJoinedMembers").mockReturnValue(members); + jest.spyOn(room, "getMember").mockImplementation( + userId => members.find(member => member.userId === userId) ?? null, + ); + }; + const mockEnabledSettings = (settings: string[]) => { + jest.spyOn(SettingsStore, "getValue").mockImplementation( + settingName => settings.includes(settingName), + ); + }; + const mockEventPowerLevels = (events: { [eventType: string]: number }) => { + room.currentState.setStateEvents([ + mkEvent({ + event: true, + type: EventType.RoomPowerLevels, + room: room.roomId, + user: alice.userId, + skey: "", + content: { events, state_default: 0 }, + }), + ]); + }; + const mockLegacyCall = () => { + jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue({} as unknown as MatrixCall); + }; + + const renderHeader = (props: Partial = {}, roomContext: Partial = {}) => { + render( + + { }} + onInviteClick={null} + onForgetClick={() => { }} + onAppsClick={() => { }} + e2eStatus={E2EStatus.Normal} + appsShown={true} + searchInfo={{ + searchTerm: "", + searchScope: SearchScope.Room, + searchCount: 0, + }} + {...props} + /> + , + ); + }; + + it("hides call buttons in video rooms", () => { + mockRoomType(RoomType.UnstableCall); + mockEnabledSettings(["showCallButtonsInComposer", "feature_video_rooms", "feature_element_call_video_rooms"]); + + renderHeader(); + expect(screen.queryByRole("button", { name: /call/i })).toBeNull(); + }); + + it("hides call buttons if showCallButtonsInComposer is disabled", () => { + mockEnabledSettings([]); + + renderHeader(); + expect(screen.queryByRole("button", { name: /call/i })).toBeNull(); + }); + + it( + "hides the voice call button and disables the video call button if configured to use Element Call exclusively " + + "and there's an ongoing call", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + await ElementCall.create(room); + + renderHeader(); + expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }, + ); + + it( + "hides the voice call button and starts an Element call when the video call button is pressed if configured to " + + "use Element Call exclusively", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + + renderHeader(); + expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + defaultDispatcher.unregister(dispatcherRef); + }, + ); + + it( + "hides the voice call button and disables the video call button if configured to use Element Call exclusively " + + "and the user lacks permission", + () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 }); + + renderHeader(); + expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }, + ); + + it("disables call buttons in the new group call experience if there's an ongoing Element call", async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + await ElementCall.create(room); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons in the new group call experience if there's an ongoing legacy 1:1 call", () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockLegacyCall(); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons in the new group call experience if there's an existing Jitsi widget", async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + await JitsiCall.create(room); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons in the new group call experience if there's no other members", () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it( + "starts a legacy 1:1 call when call buttons are pressed in the new group call experience if there's 1 other " + + "member", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob]); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }, + ); + + it( + "creates a Jitsi widget when call buttons are pressed in the new group call experience if the user lacks " + + "permission to start Element calls", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 }); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }, + ); + + it( + "creates a Jitsi widget when the voice call button is pressed and shows a menu when the video call button is " + + "pressed in the new group call experience", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + // First try creating a Jitsi widget from the menu + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /jitsi/i })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + + // Then try starting an Element call from the menu + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /element/i })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + defaultDispatcher.unregister(dispatcherRef); + }, + ); + + it( + "disables the voice call button and starts an Element call when the video call button is pressed in the new " + + "group call experience if the user lacks permission to edit widgets", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + defaultDispatcher.unregister(dispatcherRef); + }, + ); + + it("disables call buttons in the new group call experience if the user lacks permission", () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100, "im.vector.modular.widgets": 100 }); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons if there's an ongoing legacy 1:1 call", () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockLegacyCall(); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons if there's an existing Jitsi widget", async () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + await JitsiCall.create(room); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons if there's no other members", () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("starts a legacy 1:1 call when call buttons are pressed if there's 1 other member", async () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockRoomMembers([alice, bob]); + mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); // Just to verify that it doesn't try to use Jitsi + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }); + + it("creates a Jitsi widget when call buttons are pressed", async () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockRoomMembers([alice, bob, carol]); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }); + + it("disables call buttons if the user lacks permission", () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); +}); diff --git a/yarn.lock b/yarn.lock index b05ebda5522..e0043f44098 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27,6 +27,11 @@ dependencies: tunnel "^0.0.6" +"@adobe/css-tools@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.0.1.tgz#b38b444ad3aa5fedbb15f2f746dcd934226a12dd" + integrity sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g== + "@ampproject/remapping@^2.1.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" @@ -1259,6 +1264,13 @@ dependencies: jest-get-type "^28.0.2" +"@jest/expect-utils@^29.0.3": + version "29.0.3" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.0.3.tgz#f5bb86f5565bf2dacfca31ccbd887684936045b2" + integrity sha512-i1xUkau7K/63MpdwiRqaxgZOjxYs4f0WMTGJnYwUKubsNRZSeQbLorS7+I4uXVF9KQ5r61BUPAUMZ7Lf66l64Q== + dependencies: + jest-get-type "^29.0.0" + "@jest/fake-timers@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-27.5.1.tgz#76979745ce0579c8a94a4678af7a748eda8ada74" @@ -1318,6 +1330,13 @@ dependencies: "@sinclair/typebox" "^0.24.1" +"@jest/schemas@^29.0.0": + version "29.0.0" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.0.0.tgz#5f47f5994dd4ef067fb7b4188ceac45f77fe952a" + integrity sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA== + dependencies: + "@sinclair/typebox" "^0.24.1" + "@jest/source-map@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-27.5.1.tgz#6608391e465add4205eae073b55e7f279e04e8cf" @@ -1423,6 +1442,18 @@ "@types/yargs" "^17.0.8" chalk "^4.0.0" +"@jest/types@^29.0.3": + version "29.0.3" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.0.3.tgz#0be78fdddb1a35aeb2041074e55b860561c8ef63" + integrity sha512-coBJmOQvurXjN1Hh5PzF7cmsod0zLIOXpP8KD161mqNlroMhLcwpODiEzi7ZsRl5Z/AIuxpeNm8DCl43F4kz8A== + dependencies: + "@jest/schemas" "^29.0.0" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + "@jridgewell/gen-mapping@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" @@ -1922,6 +1953,21 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/jest-dom@^5.16.5": + version "5.16.5" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz#3912846af19a29b2dbf32a6ae9c31ef52580074e" + integrity sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA== + dependencies: + "@adobe/css-tools" "^4.0.1" + "@babel/runtime" "^7.9.2" + "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^5.0.0" + chalk "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.5.6" + lodash "^4.17.15" + redent "^3.0.0" + "@testing-library/react@^12.1.5": version "12.1.5" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" @@ -2090,6 +2136,14 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@*": + version "29.0.3" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.0.3.tgz#b61a5ed100850686b8d3c5e28e3a1926b2001b59" + integrity sha512-F6ukyCTwbfsEX5F2YmVYmM5TcTHy1q9P5rWlRbrk56KyMh3v9xRGUO3aa8+SkvMi0SHXtASJv1283enXimC0Og== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + "@types/jest@^26.0.20": version "26.0.24" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.24.tgz#943d11976b16739185913a1936e0de0c4a7d595a" @@ -2259,6 +2313,13 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/testing-library__jest-dom@^5.9.1": + version "5.14.5" + resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz#d113709c90b3c75fdb127ec338dad7d5f86c974f" + integrity sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ== + dependencies: + "@types/jest" "*" + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" @@ -3154,6 +3215,14 @@ chalk@^2.0.0: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0, chalk@^4.1.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -3547,6 +3616,11 @@ css-what@^6.1.0: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + csscolorparser@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/csscolorparser/-/csscolorparser-1.0.3.tgz#b34f391eea4da8f3e98231e2ccd8df9c041f171b" @@ -3815,6 +3889,11 @@ diff-sequences@^28.1.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-28.1.1.tgz#9989dc731266dc2903457a70e996f3a041913ac6" integrity sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw== +diff-sequences@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.0.0.tgz#bae49972ef3933556bcb0800b72e8579d19d9e4f" + integrity sha512-7Qe/zd1wxSDL4D/X/FPjOMB+ZMDt71W94KYaq05I2l0oQqgXgs7s4ftYYmV38gBSrPz2vcygxfs1xn0FT+rKNA== + dijkstrajs@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257" @@ -3846,7 +3925,7 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.5.9: +dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9: version "0.5.14" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz#56082f71b1dc7aac69d83c4285eef39c15d93f56" integrity sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg== @@ -4524,6 +4603,17 @@ expect@^28.1.0: jest-message-util "^28.1.3" jest-util "^28.1.3" +expect@^29.0.0: + version "29.0.3" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.0.3.tgz#6be65ddb945202f143c4e07c083f4f39f3bd326f" + integrity sha512-t8l5DTws3212VbmPL+tBFXhjRHLmctHB0oQbL8eUc6S7NzZtYUhycrFO9mkxA0ZUC6FAWdNi7JchJSkODtcu1Q== + dependencies: + "@jest/expect-utils" "^29.0.3" + jest-get-type "^29.0.0" + jest-matcher-utils "^29.0.3" + jest-message-util "^29.0.3" + jest-util "^29.0.3" + ext@^1.1.2: version "1.6.0" resolved "https://registry.yarnpkg.com/ext/-/ext-1.6.0.tgz#3871d50641e874cc172e2b53f919842d19db4c52" @@ -5860,6 +5950,16 @@ jest-diff@^28.1.3: jest-get-type "^28.0.2" pretty-format "^28.1.3" +jest-diff@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.0.3.tgz#41cc02409ad1458ae1bf7684129a3da2856341ac" + integrity sha512-+X/AIF5G/vX9fWK+Db9bi9BQas7M9oBME7egU7psbn4jlszLFCu0dW63UgeE6cs/GANq4fLaT+8sGHQQ0eCUfg== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.0.0" + jest-get-type "^29.0.0" + pretty-format "^29.0.3" + jest-docblock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.5.1.tgz#14092f364a42c6108d42c33c8cf30e058e25f6c0" @@ -5926,6 +6026,11 @@ jest-get-type@^28.0.2: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-28.0.2.tgz#34622e628e4fdcd793d46db8a242227901fcf203" integrity sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA== +jest-get-type@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.0.0.tgz#843f6c50a1b778f7325df1129a0fd7aa713aef80" + integrity sha512-83X19z/HuLKYXYHskZlBAShO7UfLFXu/vWajw9ZNJASN32li8yHMaVGAQqxFW1RCFOkB7cubaL6FaJVQqqJLSw== + jest-haste-map@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" @@ -6018,6 +6123,16 @@ jest-matcher-utils@^28.1.3: jest-get-type "^28.0.2" pretty-format "^28.1.3" +jest-matcher-utils@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.0.3.tgz#b8305fd3f9e27cdbc210b21fc7dbba92d4e54560" + integrity sha512-RsR1+cZ6p1hDV4GSCQTg+9qjeotQCgkaleIKLK7dm+U4V/H2bWedU3RAtLm8+mANzZ7eDV33dMar4pejd7047w== + dependencies: + chalk "^4.0.0" + jest-diff "^29.0.3" + jest-get-type "^29.0.0" + pretty-format "^29.0.3" + jest-message-util@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-27.5.1.tgz#bdda72806da10d9ed6425e12afff38cd1458b6cf" @@ -6048,6 +6163,21 @@ jest-message-util@^28.1.3: slash "^3.0.0" stack-utils "^2.0.3" +jest-message-util@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.0.3.tgz#f0254e1ffad21890c78355726202cc91d0a40ea8" + integrity sha512-7T8JiUTtDfppojosORAflABfLsLKMLkBHSWkjNQrjIltGoDzNGn7wEPOSfjqYAGTYME65esQzMJxGDjuLBKdOg== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.0.3" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.0.3" + slash "^3.0.0" + stack-utils "^2.0.3" + jest-mock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" @@ -6243,6 +6373,18 @@ jest-util@^28.1.3: graceful-fs "^4.2.9" picomatch "^2.2.3" +jest-util@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.0.3.tgz#06d1d77f9a1bea380f121897d78695902959fbc0" + integrity sha512-Q0xaG3YRG8QiTC4R6fHjHQPaPpz9pJBEi0AeOE4mQh/FuWOijFjGXMMOfQEaU9i3z76cNR7FobZZUQnL6IyfdQ== + dependencies: + "@jest/types" "^29.0.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + jest-validate@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-27.5.1.tgz#9197d54dc0bdb52260b8db40b46ae668e04df067" @@ -6636,7 +6778,7 @@ lodash.truncate@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== -lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: +lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -7579,6 +7721,15 @@ pretty-format@^28.1.3: ansi-styles "^5.0.0" react-is "^18.0.0" +pretty-format@^29.0.0, pretty-format@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.0.3.tgz#23d5f8cabc9cbf209a77d49409d093d61166a811" + integrity sha512-cHudsvQr1K5vNVLbvYF/nv3Qy/F/BcEKxGuIeMiVMRHxPOO1RxXooP8g/ZrwAp7Dx+KdMZoOc7NxLHhMrP2f9Q== + dependencies: + "@jest/schemas" "^29.0.0" + ansi-styles "^5.0.0" + react-is "^18.0.0" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" From 8e719d57a2ca09787f415b9eabb937ffe857f4c0 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 26 Sep 2022 15:29:38 +0200 Subject: [PATCH 014/189] Add voice broadcast recording body (#9316) * Add voice broadcast recording body * Change icon element; update css variables * Update Icon-test snapshots --- package.json | 1 + res/css/_components.pcss | 3 + res/css/components/atoms/_Icon.pcss | 34 ++++ res/css/voice-broadcast/atoms/_LiveBadge.pcss | 27 +++ .../_VoiceBroadcastRecordingBody.pcss | 29 +++ res/themes/dark/css/_dark.pcss | 5 + res/themes/legacy-dark/css/_legacy-dark.pcss | 5 + .../legacy-light/css/_legacy-light.pcss | 5 + res/themes/light/css/_light.pcss | 5 + src/components/atoms/Icon.tsx | 69 +++++++ src/components/structures/MessagePanel.tsx | 12 +- .../views/messages/MessageEvent.tsx | 6 + .../views/rooms/MessageComposer.tsx | 23 ++- .../views/rooms/SendMessageComposer.tsx | 1 + src/events/EventTileFactory.tsx | 5 + src/i18n/strings/en_EN.json | 1 + .../components/VoiceBroadcastBody.tsx | 70 +++++++ .../components/atoms/LiveBadge.tsx | 27 +++ src/voice-broadcast/components/index.ts | 19 ++ .../molecules/VoiceBroadcastRecordingBody.tsx | 56 ++++++ src/voice-broadcast/index.ts | 26 +++ src/voice-broadcast/utils/index.ts | 17 ++ .../shouldDisplayAsVoiceBroadcastTile.ts | 27 +++ test/components/atoms/Icon-test.tsx | 47 +++++ .../atoms/__snapshots__/Icon-test.tsx.snap | 34 ++++ .../components/VoiceBroadcastBody-test.tsx | 182 ++++++++++++++++++ .../components/atoms/LiveBadge-test.tsx | 27 +++ .../__snapshots__/LiveBadge-test.tsx.snap | 17 ++ .../VoiceBroadcastRecordingBody-test.tsx | 99 ++++++++++ .../VoiceBroadcastRecordingBody-test.tsx.snap | 33 ++++ .../shouldDisplayAsVoiceBroadcastTile-test.ts | 149 ++++++++++++++ yarn.lock | 5 + 32 files changed, 1059 insertions(+), 7 deletions(-) create mode 100644 res/css/components/atoms/_Icon.pcss create mode 100644 res/css/voice-broadcast/atoms/_LiveBadge.pcss create mode 100644 res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss create mode 100644 src/components/atoms/Icon.tsx create mode 100644 src/voice-broadcast/components/VoiceBroadcastBody.tsx create mode 100644 src/voice-broadcast/components/atoms/LiveBadge.tsx create mode 100644 src/voice-broadcast/components/index.ts create mode 100644 src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx create mode 100644 src/voice-broadcast/utils/index.ts create mode 100644 src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts create mode 100644 test/components/atoms/Icon-test.tsx create mode 100644 test/components/atoms/__snapshots__/Icon-test.tsx.snap create mode 100644 test/voice-broadcast/components/VoiceBroadcastBody-test.tsx create mode 100644 test/voice-broadcast/components/atoms/LiveBadge-test.tsx create mode 100644 test/voice-broadcast/components/atoms/__snapshots__/LiveBadge-test.tsx.snap create mode 100644 test/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody-test.tsx create mode 100644 test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingBody-test.tsx.snap create mode 100644 test/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile-test.ts diff --git a/package.json b/package.json index 15dc5b6f791..14c9b297c43 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "@sinonjs/fake-timers": "^9.1.2", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^14.4.3", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", "@types/counterpart": "^0.18.1", diff --git a/res/css/_components.pcss b/res/css/_components.pcss index e4b9c999f01..cfcf88a4d7a 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -4,6 +4,7 @@ @import "./_font-sizes.pcss"; @import "./_font-weights.pcss"; @import "./_spacing.pcss"; +@import "./components/atoms/_Icon.pcss"; @import "./components/views/beacon/_BeaconListItem.pcss"; @import "./components/views/beacon/_BeaconStatus.pcss"; @import "./components/views/beacon/_BeaconStatusTooltip.pcss"; @@ -357,3 +358,5 @@ @import "./views/voip/_LegacyCallViewSidebar.pcss"; @import "./views/voip/_PiPContainer.pcss"; @import "./views/voip/_VideoFeed.pcss"; +@import "./voice-broadcast/atoms/_LiveBadge.pcss"; +@import "./voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss"; diff --git a/res/css/components/atoms/_Icon.pcss b/res/css/components/atoms/_Icon.pcss new file mode 100644 index 00000000000..08a72d3d5ba --- /dev/null +++ b/res/css/components/atoms/_Icon.pcss @@ -0,0 +1,34 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_Icon { + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; +} + +.mx_Icon_16 { + height: 16px; + width: 16px; +} + +.mx_Icon_accent { + background-color: $accent; +} + +.mx_Icon_live-badge { + background-color: #fff; +} diff --git a/res/css/voice-broadcast/atoms/_LiveBadge.pcss b/res/css/voice-broadcast/atoms/_LiveBadge.pcss new file mode 100644 index 00000000000..6da1f041a17 --- /dev/null +++ b/res/css/voice-broadcast/atoms/_LiveBadge.pcss @@ -0,0 +1,27 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_LiveBadge { + align-items: center; + background-color: $alert; + border-radius: 2px; + color: $live-badge-color; + display: flex; + font-size: $font-12px; + font-weight: $font-semi-bold; + gap: $spacing-4; + padding: 2px 4px; +} diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss new file mode 100644 index 00000000000..13e3104c9ac --- /dev/null +++ b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss @@ -0,0 +1,29 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_VoiceBroadcastRecordingBody { + align-items: flex-start; + background-color: $quinary-content; + border-radius: 8px; + display: inline-flex; + gap: $spacing-8; + padding: 12px; +} + +.mx_VoiceBroadcastRecordingBody_title { + font-size: $font-12px; + font-weight: $font-semi-bold; +} diff --git a/res/themes/dark/css/_dark.pcss b/res/themes/dark/css/_dark.pcss index b17010f2757..6fbdb661655 100644 --- a/res/themes/dark/css/_dark.pcss +++ b/res/themes/dark/css/_dark.pcss @@ -206,6 +206,11 @@ $location-live-secondary-color: #deddfd; } /* ******************** */ +/* Voice Broadcast */ +/* ******************** */ +$live-badge-color: #ffffff; +/* ******************** */ + /* One-off colors */ /* ******************** */ $progressbar-bg-color: $system; diff --git a/res/themes/legacy-dark/css/_legacy-dark.pcss b/res/themes/legacy-dark/css/_legacy-dark.pcss index 49043b648d9..92767ded998 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.pcss +++ b/res/themes/legacy-dark/css/_legacy-dark.pcss @@ -203,6 +203,11 @@ $location-live-color: #5c56f5; $location-live-secondary-color: #deddfd; /* ******************** */ +/* Voice Broadcast */ +/* ******************** */ +$live-badge-color: #ffffff; +/* ******************** */ + /* ***** Mixins! ***** */ @define-mixin mx_DialogButton { diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 4380bef4081..6972d5a20bb 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -303,6 +303,11 @@ $location-live-color: #5c56f5; $location-live-secondary-color: #deddfd; /* ******************** */ +/* Voice Broadcast */ +/* ******************** */ +$live-badge-color: #ffffff; +/* ******************** */ + /* ***** Mixins! ***** */ @define-mixin mx_DialogButton { diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index 630ca8c70d8..708b943c7c2 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -337,6 +337,11 @@ $location-live-color: #5c56f5; $location-live-secondary-color: #deddfd; /* ******************** */ +/* Voice Broadcast */ +/* ******************** */ +$live-badge-color: #ffffff; +/* ******************** */ + /* Mixins */ /* ******************** */ @define-mixin mx_DialogButton { diff --git a/src/components/atoms/Icon.tsx b/src/components/atoms/Icon.tsx new file mode 100644 index 00000000000..bb6ea61524a --- /dev/null +++ b/src/components/atoms/Icon.tsx @@ -0,0 +1,69 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import liveIcon from "../../../res/img/element-icons/live.svg"; + +export enum IconType { + Live, +} + +const iconTypeMap = new Map([ + [IconType.Live, liveIcon], +]); + +export enum IconColour { + Accent = "accent", + LiveBadge = "live-badge", +} + +export enum IconSize { + S16 = "16", +} + +interface IconProps { + colour?: IconColour; + size?: IconSize; + type: IconType; +} + +export const Icon: React.FC = ({ + size = IconSize.S16, + colour = IconColour.Accent, + type, + ...rest +}) => { + const classes = [ + "mx_Icon", + `mx_Icon_${size}`, + `mx_Icon_${colour}`, + ]; + + const styles: React.CSSProperties = { + maskImage: `url("${iconTypeMap.get(type)}")`, + }; + + return ( + + ); +}; diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 0dbd10cb467..223eb0a6dbf 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -59,6 +59,7 @@ import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker"; import { haveRendererForEvent } from "../../events/EventTileFactory"; import { editorRoomKey } from "../../Editing"; import { hasThreadSummary } from "../../utils/EventUtils"; +import { VoiceBroadcastInfoEventType } from '../../voice-broadcast'; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -1091,11 +1092,20 @@ class CreationGrouper extends BaseGrouper { && (ev.getStateKey() !== createEvent.getSender() || ev.getContent()["membership"] !== "join")) { return false; } + + const eventType = ev.getType(); + // beacons are not part of room creation configuration // should be shown in timeline - if (M_BEACON_INFO.matches(ev.getType())) { + if (M_BEACON_INFO.matches(eventType)) { + return false; + } + + if (VoiceBroadcastInfoEventType === eventType) { + // always show voice broadcast info events in timeline return false; } + if (ev.isState() && ev.getSender() === createEvent.getSender()) { return true; } diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index d2d207eec3e..f93cd710171 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -42,6 +42,7 @@ import MLocationBody from "./MLocationBody"; import MjolnirBody from "./MjolnirBody"; import MBeaconBody from "./MBeaconBody"; import { IEventTileOps } from "../rooms/EventTile"; +import { VoiceBroadcastBody, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from '../../../voice-broadcast'; // onMessageAllowed is handled internally interface IProps extends Omit { @@ -74,6 +75,7 @@ const baseEvTypes = new Map>>([ [M_POLL_START.altName, MPollBody], [M_BEACON_INFO.name, MBeaconBody], [M_BEACON_INFO.altName, MBeaconBody], + [VoiceBroadcastInfoEventType, VoiceBroadcastBody], ]); export default class MessageEvent extends React.Component implements IMediaBody, IOperableEventTile { @@ -171,6 +173,10 @@ export default class MessageEvent extends React.Component implements IMe ) { BodyType = MLocationBody; } + + if (type === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started) { + BodyType = VoiceBroadcastBody; + } } if (SettingsStore.getValue("feature_mjolnir")) { diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index f9aaf211052..f752386650d 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -54,6 +54,11 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; import { Features } from '../../../settings/Settings'; import { VoiceMessageRecording } from '../../../audio/VoiceMessageRecording'; +import { + VoiceBroadcastInfoEventContent, + VoiceBroadcastInfoEventType, + VoiceBroadcastInfoState, +} from '../../../voice-broadcast'; let instanceCount = 0; @@ -503,12 +508,18 @@ export default class MessageComposer extends React.Component { showStickersButton={this.showStickersButton} toggleButtonMenu={this.toggleButtonMenu} showVoiceBroadcastButton={this.showVoiceBroadcastButton} - onStartVoiceBroadcastClick={() => { - // Sends a voice message. To be replaced by voice broadcast during development. - this.voiceRecordingButton.current?.onRecordStartEndClick(); - if (this.context.narrow) { - this.toggleButtonMenu(); - } + onStartVoiceBroadcastClick={async () => { + const client = MatrixClientPeg.get(); + client.sendStateEvent( + this.props.room.roomId, + VoiceBroadcastInfoEventType, + { + state: VoiceBroadcastInfoState.Started, + chunk_length: 300, + } as VoiceBroadcastInfoEventContent, + client.getUserId(), + ); + this.toggleButtonMenu(); }} /> } { showSendButton && ( diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 6750343a0df..8c423663f5d 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -88,6 +88,7 @@ export function createMessageContent( model = unescapeMessage(model); const body = textSerialize(model); + const content: IContent = { msgtype: isEmote ? "m.emote" : "m.text", body: body, diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index 53b9049c009..d1702aac98b 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -43,6 +43,7 @@ import { getMessageModerationState, MessageModerationState } from "../utils/Even import HiddenBody from "../components/views/messages/HiddenBody"; import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; +import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps { @@ -220,6 +221,10 @@ export function pickFactory( return MessageEventFactory; } + if (shouldDisplayAsVoiceBroadcastTile(mxEvent)) { + return MessageEventFactory; + } + if (SINGULAR_STATE_EVENTS.has(evType) && mxEvent.getStateKey() !== '') { return noEventFactoryFactory(); // improper event type to render } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5c1359d499d..9bd1dd8124f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -636,6 +636,7 @@ "Send %(msgtype)s messages as you in your active room": "Send %(msgtype)s messages as you in your active room", "See %(msgtype)s messages posted to this room": "See %(msgtype)s messages posted to this room", "See %(msgtype)s messages posted to your active room": "See %(msgtype)s messages posted to your active room", + "Live": "Live", "Cannot reach homeserver": "Cannot reach homeserver", "Ensure you have a stable internet connection, or get in touch with the server admin": "Ensure you have a stable internet connection, or get in touch with the server admin", "Your %(brand)s is misconfigured": "Your %(brand)s is misconfigured", diff --git a/src/voice-broadcast/components/VoiceBroadcastBody.tsx b/src/voice-broadcast/components/VoiceBroadcastBody.tsx new file mode 100644 index 00000000000..40bbbd17682 --- /dev/null +++ b/src/voice-broadcast/components/VoiceBroadcastBody.tsx @@ -0,0 +1,70 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState, VoiceBroadcastRecordingBody } from ".."; +import { IBodyProps } from "../../components/views/messages/IBodyProps"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; + +/** + * Temporary component to display voice broadcasts. + * XXX: To be refactored to some fancy store/hook/controller architecture. + */ +export const VoiceBroadcastBody: React.FC = ({ + getRelationsForEvent, + mxEvent, +}) => { + const client = MatrixClientPeg.get(); + const relations = getRelationsForEvent?.( + mxEvent.getId(), + RelationType.Reference, + VoiceBroadcastInfoEventType, + ); + const relatedEvents = relations?.getRelations(); + const live = !relatedEvents?.find((event: MatrixEvent) => { + return event.getContent()?.state === VoiceBroadcastInfoState.Stopped; + }); + + const stopVoiceBroadcast = () => { + if (!live) return; + + client.sendStateEvent( + mxEvent.getRoomId(), + VoiceBroadcastInfoEventType, + { + state: VoiceBroadcastInfoState.Stopped, + ["m.relates_to"]: { + rel_type: RelationType.Reference, + event_id: mxEvent.getId(), + }, + }, + client.getUserId(), + ); + }; + + const room = client.getRoom(mxEvent.getRoomId()); + const senderId = mxEvent.getSender(); + const sender = mxEvent.sender; + return ; +}; diff --git a/src/voice-broadcast/components/atoms/LiveBadge.tsx b/src/voice-broadcast/components/atoms/LiveBadge.tsx new file mode 100644 index 00000000000..cd2a16e797d --- /dev/null +++ b/src/voice-broadcast/components/atoms/LiveBadge.tsx @@ -0,0 +1,27 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; +import { _t } from "../../../languageHandler"; + +export const LiveBadge: React.FC = () => { + return
+ + { _t("Live") } +
; +}; diff --git a/src/voice-broadcast/components/index.ts b/src/voice-broadcast/components/index.ts new file mode 100644 index 00000000000..e98500a5d71 --- /dev/null +++ b/src/voice-broadcast/components/index.ts @@ -0,0 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export * from "./atoms/LiveBadge"; +export * from "./molecules/VoiceBroadcastRecordingBody"; +export * from "./VoiceBroadcastBody"; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx new file mode 100644 index 00000000000..13ea504ac43 --- /dev/null +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx @@ -0,0 +1,56 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { MouseEventHandler } from "react"; +import { RoomMember } from "matrix-js-sdk/src/matrix"; + +import { LiveBadge } from "../.."; +import MemberAvatar from "../../../components/views/avatars/MemberAvatar"; + +interface VoiceBroadcastRecordingBodyProps { + live: boolean; + member: RoomMember; + onClick: MouseEventHandler; + title: string; + userId: string; +} + +export const VoiceBroadcastRecordingBody: React.FC = ({ + live, + member, + onClick, + title, + userId, +}) => { + const liveBadge = live + ? + : null; + + return ( +
+ +
+
+ { title } +
+
+ { liveBadge } +
+ ); +}; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 473bbf73ef1..8f6312a7754 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -14,4 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ +/** + * Voice Broadcast module + * {@link https://github.com/vector-im/element-meta/discussions/632} + */ + +import { RelationType } from "matrix-js-sdk/src/matrix"; + +export * from "./components"; +export * from "./utils"; + export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info"; + +export enum VoiceBroadcastInfoState { + Started = "started", + Paused = "paused", + Running = "running", + Stopped = "stopped", +} + +export interface VoiceBroadcastInfoEventContent { + state: VoiceBroadcastInfoState; + chunk_length: number; + ["m.relates_to"]?: { + rel_type: RelationType; + event_id: string; + }; +} diff --git a/src/voice-broadcast/utils/index.ts b/src/voice-broadcast/utils/index.ts new file mode 100644 index 00000000000..9fb93c73b1e --- /dev/null +++ b/src/voice-broadcast/utils/index.ts @@ -0,0 +1,17 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export * from "./shouldDisplayAsVoiceBroadcastTile"; diff --git a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts new file mode 100644 index 00000000000..ef55eed3bbf --- /dev/null +++ b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts @@ -0,0 +1,27 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; + +export const shouldDisplayAsVoiceBroadcastTile = (event: MatrixEvent) => ( + event.getType?.() === VoiceBroadcastInfoEventType + && ( + event.getContent?.()?.state === VoiceBroadcastInfoState.Started + || event.isRedacted() + ) +); diff --git a/test/components/atoms/Icon-test.tsx b/test/components/atoms/Icon-test.tsx new file mode 100644 index 00000000000..57e6e3990c4 --- /dev/null +++ b/test/components/atoms/Icon-test.tsx @@ -0,0 +1,47 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { render } from "@testing-library/react"; + +import { Icon, IconColour, IconSize, IconType } from "../../../src/components/atoms/Icon"; + +describe("Icon", () => { + it.each([ + IconColour.Accent, + IconColour.LiveBadge, + ])("should render the colour %s", (colour: IconColour) => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it.each([ + IconSize.S16, + ])("should render the size %s", (size: IconSize) => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/test/components/atoms/__snapshots__/Icon-test.tsx.snap b/test/components/atoms/__snapshots__/Icon-test.tsx.snap new file mode 100644 index 00000000000..c30b4ba3323 --- /dev/null +++ b/test/components/atoms/__snapshots__/Icon-test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Icon should render the colour accent 1`] = ` +
+