From 474f464e48e93ad55d35017713424729f9a1b12f Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 6 Dec 2022 10:56:29 +0100 Subject: [PATCH] Voice broadcast indicator in room list (#9709) --- res/css/_components.pcss | 1 + .../atoms/_VoiceBroadcastRoomSubtitle.pcss | 22 ++++ src/components/views/rooms/RoomTile.tsx | 44 ++++--- .../atoms/VoiceBroadcastRoomSubtitle.tsx | 27 ++++ .../hooks/useHasRoomLiveVoiceBroadcast.ts | 35 ++++++ src/voice-broadcast/index.ts | 2 + test/components/views/rooms/RoomList-test.tsx | 2 +- test/components/views/rooms/RoomTile-test.tsx | 115 +++++++++++++++--- .../__snapshots__/RoomTile-test.tsx.snap | 81 ++++++++++++ test/test-utils/console.ts | 2 +- 10 files changed, 295 insertions(+), 36 deletions(-) create mode 100644 res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss create mode 100644 src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx create mode 100644 src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts create mode 100644 test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 7f21752d4a..2630ad1bc7 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -375,4 +375,5 @@ @import "./voice-broadcast/atoms/_PlaybackControlButton.pcss"; @import "./voice-broadcast/atoms/_VoiceBroadcastControl.pcss"; @import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss"; +@import "./voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss"; @import "./voice-broadcast/molecules/_VoiceBroadcastBody.pcss"; diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss new file mode 100644 index 0000000000..570a30e6f6 --- /dev/null +++ b/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss @@ -0,0 +1,22 @@ +/* +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_RoomTile .mx_RoomTile_titleContainer .mx_RoomTile_subtitle.mx_RoomTile_subtitle--voice-broadcast { + align-items: center; + color: $alert; + display: flex; + gap: $spacing-4; +} diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 68f4dfe4de..d19efb7d1f 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -48,20 +48,25 @@ import { RoomTileCallSummary } from "./RoomTileCallSummary"; import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu"; import { CallStore, CallStoreEvent } from "../../../stores/CallStore"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import { useHasRoomLiveVoiceBroadcast, VoiceBroadcastRoomSubtitle } from "../../../voice-broadcast"; -interface IProps { +interface Props { room: Room; showMessagePreview: boolean; isMinimized: boolean; tag: TagID; } +interface ClassProps extends Props { + hasLiveVoiceBroadcast: boolean; +} + type PartialDOMRect = Pick; -interface IState { +interface State { selected: boolean; - notificationsMenuPosition: PartialDOMRect; - generalMenuPosition: PartialDOMRect; + notificationsMenuPosition: PartialDOMRect | null; + generalMenuPosition: PartialDOMRect | null; call: Call | null; messagePreview?: string; } @@ -76,13 +81,13 @@ export const contextMenuBelow = (elementRect: PartialDOMRect) => { return { left, top, chevronFace }; }; -export default class RoomTile extends React.PureComponent { - private dispatcherRef: string; +export class RoomTile extends React.PureComponent { + private dispatcherRef?: string; private roomTileRef = createRef(); private notificationState: NotificationState; private roomProps: RoomEchoChamber; - constructor(props: IProps) { + constructor(props: ClassProps) { super(props); this.state = { @@ -120,7 +125,7 @@ export default class RoomTile extends React.PureComponent { return !this.props.isMinimized && this.props.showMessagePreview; } - public componentDidUpdate(prevProps: Readonly, prevState: Readonly) { + public componentDidUpdate(prevProps: Readonly, prevState: Readonly) { const showMessageChanged = prevProps.showMessagePreview !== this.props.showMessagePreview; const minimizedChanged = prevProps.isMinimized !== this.props.isMinimized; if (showMessageChanged || minimizedChanged) { @@ -169,7 +174,7 @@ export default class RoomTile extends React.PureComponent { this.onRoomPreviewChanged, ); this.props.room.off(RoomEvent.Name, this.onRoomNameUpdate); - defaultDispatcher.unregister(this.dispatcherRef); + if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate); this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate); CallStore.instance.off(CallStoreEvent.Call, this.onCallChanged); @@ -218,12 +223,14 @@ export default class RoomTile extends React.PureComponent { ev.stopPropagation(); const action = getKeyBindingsManager().getAccessibilityAction(ev); + const clearSearch = ([KeyBindingAction.Enter, KeyBindingAction.Space] as Array) + .includes(action); defaultDispatcher.dispatch({ action: Action.ViewRoom, show_room_tile: true, // make sure the room is visible in the list room_id: this.props.room.roomId, - clear_search: [KeyBindingAction.Enter, KeyBindingAction.Space].includes(action), + clear_search: clearSearch, metricsTrigger: "RoomList", metricsViaKeyboard: ev.type !== "click", }); @@ -233,7 +240,7 @@ export default class RoomTile extends React.PureComponent { this.setState({ selected: isActive }); }; - private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => { + private onNotificationsMenuOpenClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -246,7 +253,7 @@ export default class RoomTile extends React.PureComponent { this.setState({ notificationsMenuPosition: null }); }; - private onGeneralMenuOpenClick = (ev: React.MouseEvent) => { + private onGeneralMenuOpenClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -271,7 +278,7 @@ export default class RoomTile extends React.PureComponent { this.setState({ generalMenuPosition: null }); }; - private renderNotificationsMenu(isActive: boolean): React.ReactElement { + private renderNotificationsMenu(isActive: boolean): React.ReactElement | null { if (MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Archived || !this.showContextMenu || this.props.isMinimized ) { @@ -313,7 +320,7 @@ export default class RoomTile extends React.PureComponent { ); } - private renderGeneralMenu(): React.ReactElement { + private renderGeneralMenu(): React.ReactElement | null { if (!this.showContextMenu) return null; // no menu to show return ( @@ -379,6 +386,8 @@ export default class RoomTile extends React.PureComponent { ); + } else if (this.props.hasLiveVoiceBroadcast) { + subtitle = ; } else if (this.showMessagePreview && this.state.messagePreview) { subtitle = (
{ ); } } + +const RoomTileHOC: React.FC = (props: Props) => { + const hasLiveVoiceBroadcast = useHasRoomLiveVoiceBroadcast(props.room); + return ; +}; + +export default RoomTileHOC; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx new file mode 100644 index 0000000000..4c6356ba2b --- /dev/null +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.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 as LiveIcon } from "../../../../res/img/element-icons/live.svg"; +import { _t } from "../../../languageHandler"; + +export const VoiceBroadcastRoomSubtitle = () => { + return
+ + { _t("Live") } +
; +}; diff --git a/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts b/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts new file mode 100644 index 0000000000..6db5ed789e --- /dev/null +++ b/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts @@ -0,0 +1,35 @@ +/* +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 { useState } from "react"; +import { Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; + +import { hasRoomLiveVoiceBroadcast } from "../utils/hasRoomLiveVoiceBroadcast"; +import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; + +export const useHasRoomLiveVoiceBroadcast = (room: Room) => { + const [hasLiveVoiceBroadcast, setHasLiveVoiceBroadcast] = useState(hasRoomLiveVoiceBroadcast(room).hasBroadcast); + + useTypedEventEmitter( + room.currentState, + RoomStateEvent.Update, + () => { + setHasLiveVoiceBroadcast(hasRoomLiveVoiceBroadcast(room).hasBroadcast); + }, + ); + + return hasLiveVoiceBroadcast; +}; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 21e1bdd4af..9bb2dfd4c0 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -29,12 +29,14 @@ export * from "./components/VoiceBroadcastBody"; export * from "./components/atoms/LiveBadge"; export * from "./components/atoms/VoiceBroadcastControl"; export * from "./components/atoms/VoiceBroadcastHeader"; +export * from "./components/atoms/VoiceBroadcastRoomSubtitle"; export * from "./components/molecules/VoiceBroadcastPlaybackBody"; export * from "./components/molecules/VoiceBroadcastPreRecordingPip"; export * from "./components/molecules/VoiceBroadcastRecordingBody"; export * from "./components/molecules/VoiceBroadcastRecordingPip"; export * from "./hooks/useCurrentVoiceBroadcastPreRecording"; export * from "./hooks/useCurrentVoiceBroadcastRecording"; +export * from "./hooks/useHasRoomLiveVoiceBroadcast"; export * from "./hooks/useVoiceBroadcastRecording"; export * from "./stores/VoiceBroadcastPlaybacksStore"; export * from "./stores/VoiceBroadcastPreRecordingStore"; diff --git a/test/components/views/rooms/RoomList-test.tsx b/test/components/views/rooms/RoomList-test.tsx index 6fa3fe22cf..cb5ddb1ffa 100644 --- a/test/components/views/rooms/RoomList-test.tsx +++ b/test/components/views/rooms/RoomList-test.tsx @@ -32,7 +32,7 @@ import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-l import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore"; import RoomList from "../../../../src/components/views/rooms/RoomList"; import RoomSublist from "../../../../src/components/views/rooms/RoomSublist"; -import RoomTile from "../../../../src/components/views/rooms/RoomTile"; +import { RoomTile } from "../../../../src/components/views/rooms/RoomTile"; import { getMockClientWithEventEmitter, mockClientMethodsUser } from '../../../test-utils'; import ResizeNotifier from '../../../../src/utils/ResizeNotifier'; diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index cf1ae59d09..4a3aa95937 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -15,12 +15,13 @@ limitations under the License. */ import React from "react"; -import { render, screen, act } from "@testing-library/react"; +import { render, screen, act, RenderResult } from "@testing-library/react"; import { mocked, Mocked } from "jest-mock"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { Widget } from "matrix-widget-api"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { ClientWidgetApi } from "matrix-widget-api"; @@ -30,6 +31,7 @@ import { MockedCall, useMockedCalls, setupAsyncStoreWithClient, + filterConsole, } from "../../../test-utils"; import { CallStore } from "../../../../src/stores/CallStore"; import RoomTile from "../../../../src/components/views/rooms/RoomTile"; @@ -39,38 +41,79 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import PlatformPeg from "../../../../src/PlatformPeg"; import BasePlatform from "../../../../src/BasePlatform"; import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; +import { VoiceBroadcastInfoState } from "../../../../src/voice-broadcast"; +import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils"; describe("RoomTile", () => { jest.spyOn(PlatformPeg, "get") .mockReturnValue({ overrideBrowserShortcuts: () => false } as unknown as BasePlatform); useMockedCalls(); + const setUpVoiceBroadcast = (state: VoiceBroadcastInfoState): void => { + voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent( + room.roomId, + state, + client.getUserId(), + client.getDeviceId(), + ); + + act(() => { + room.currentState.setStateEvents([voiceBroadcastInfoEvent]); + }); + }; + + const renderRoomTile = (): void => { + renderResult = render( + , + ); + }; + let client: Mocked; + let restoreConsole: () => void; + let voiceBroadcastInfoEvent: MatrixEvent; + let room: Room; + let renderResult: RenderResult; beforeEach(() => { + restoreConsole = filterConsole( + // irrelevant for this test + "Room !1:example.org does not have an m.room.create event", + ); + stubClient(); client = mocked(MatrixClientPeg.get()); DMRoomMap.makeShared(); + + room = new Room("!1:example.org", client, "@alice:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + + renderRoomTile(); }); afterEach(() => { + restoreConsole(); jest.clearAllMocks(); }); - describe("call subtitle", () => { - let room: Room; + it("should render the room", () => { + expect(renderResult.container).toMatchSnapshot(); + }); + + describe("when a call starts", () => { let call: MockedCall; let widget: Widget; beforeEach(() => { - room = new Room("!1:example.org", client, "@alice:example.org", { - pendingEventOrdering: PendingEventOrdering.Detached, - }); - - client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); - client.getRooms.mockReturnValue([room]); - client.reEmitter.reEmit(room, [RoomStateEvent.Events]); - setupAsyncStoreWithClient(CallStore.instance, client); setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); @@ -83,18 +126,10 @@ describe("RoomTile", () => { WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, } as unknown as ClientWidgetApi); - - render( - , - ); }); afterEach(() => { + renderResult.unmount(); call.destroy(); client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); @@ -147,5 +182,45 @@ describe("RoomTile", () => { act(() => { call.participants = new Map(); }); expect(screen.queryByLabelText(/participant/)).toBe(null); }); + + describe("and a live broadcast starts", () => { + beforeEach(() => { + setUpVoiceBroadcast(VoiceBroadcastInfoState.Started); + }); + + it("should still render the call subtitle", () => { + expect(screen.queryByText("Video")).toBeInTheDocument(); + expect(screen.queryByText("Live")).not.toBeInTheDocument(); + }); + }); + }); + + describe("when a live voice broadcast starts", () => { + beforeEach(() => { + setUpVoiceBroadcast(VoiceBroadcastInfoState.Started); + }); + + it("should render the »Live« subtitle", () => { + expect(screen.queryByText("Live")).toBeInTheDocument(); + }); + + describe("and the broadcast stops", () => { + beforeEach(() => { + const stopEvent = mkVoiceBroadcastInfoStateEvent( + room.roomId, + VoiceBroadcastInfoState.Stopped, + client.getUserId(), + client.getDeviceId(), + voiceBroadcastInfoEvent, + ); + act(() => { + room.currentState.setStateEvents([stopEvent]); + }); + }); + + it("should not render the »Live« subtitle", () => { + expect(screen.queryByText("Live")).not.toBeInTheDocument(); + }); + }); }); }); diff --git a/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap new file mode 100644 index 0000000000..b4114bcb53 --- /dev/null +++ b/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RoomTile should render the room 1`] = ` +
+
+
+ + + + +
+
+
+ + !1:​example.org + +
+
+ + +`; diff --git a/test/test-utils/console.ts b/test/test-utils/console.ts index ff1ea0be09..f73c42568a 100644 --- a/test/test-utils/console.ts +++ b/test/test-utils/console.ts @@ -39,7 +39,7 @@ export const filterConsole = (...ignoreList: string[]): () => void => { return; } - originalFunction(data); + originalFunction(...data); }; }