From 57e1822add8cfbd2efdb739ffef7205d0c8c5c0a Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 12 Dec 2022 16:03:56 +0100 Subject: [PATCH] Add voice broadcast ended message (#9728) --- src/TextForEvent.tsx | 26 ++--- src/events/EventTileFactory.tsx | 4 +- src/i18n/strings/en_EN.json | 2 + src/utils/EventUtils.ts | 11 +++ src/voice-broadcast/index.ts | 2 + ...houldDisplayAsVoiceBroadcastStoppedText.ts | 25 +++++ .../textForVoiceBroadcastStoppedEvent.tsx | 55 +++++++++++ test/events/EventTileFactory-test.ts | 24 ++++- test/utils/EventUtils-test.ts | 20 ++++ ...orVoiceBroadcastStoppedEvent-test.tsx.snap | 42 ++++++++ ...textForVoiceBroadcastStoppedEvent-test.tsx | 98 +++++++++++++++++++ 11 files changed, 286 insertions(+), 23 deletions(-) create mode 100644 src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastStoppedText.ts create mode 100644 src/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent.tsx create mode 100644 test/voice-broadcast/utils/__snapshots__/textForVoiceBroadcastStoppedEvent-test.tsx.snap create mode 100644 test/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent-test.tsx diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 361edcb1e2..dfd738c503 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -37,15 +37,14 @@ import SettingsStore from "./settings/SettingsStore"; import { ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./mjolnir/BanList"; import { WIDGET_LAYOUT_EVENT_TYPE } from "./stores/widgets/WidgetLayoutStore"; import { RightPanelPhases } from './stores/right-panel/RightPanelStorePhases'; -import { Action } from './dispatcher/actions'; import defaultDispatcher from './dispatcher/dispatcher'; 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 { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; -import { isLocationEvent } from './utils/EventUtils'; +import { highlightEvent, isLocationEvent } from './utils/EventUtils'; import { ElementCall } from "./models/Call"; +import { textForVoiceBroadcastStoppedEvent, VoiceBroadcastInfoEventType } from './voice-broadcast'; export function getSenderName(event: MatrixEvent): string { return event.sender?.name ?? event.getSender() ?? _t("Someone"); @@ -497,16 +496,6 @@ function textForPowerEvent(event: MatrixEvent): () => string | null { }); } -const onPinnedOrUnpinnedMessageClick = (messageId: string, roomId: string): void => { - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - event_id: messageId, - highlighted: true, - room_id: roomId, - metricsTrigger: undefined, // room doesn't change - }); -}; - const onPinnedMessagesClick = (): void => { RightPanelStore.instance.setCard({ phase: RightPanelPhases.PinnedMessages }, false); }; @@ -533,7 +522,7 @@ function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => Render { senderName }, { "a": (sub) => - onPinnedOrUnpinnedMessageClick(messageId, roomId)}> + highlightEvent(roomId, messageId)}> { sub } , "b": (sub) => @@ -561,7 +550,7 @@ function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => Render { senderName }, { "a": (sub) => - onPinnedOrUnpinnedMessageClick(messageId, roomId)}> + highlightEvent(roomId, messageId)}> { sub } , "b": (sub) => @@ -765,7 +754,7 @@ function textForPollEndEvent(event: MatrixEvent): () => string | null { }); } -type Renderable = string | JSX.Element | null; +type Renderable = string | React.ReactNode | null; interface IHandlers { [type: string]: @@ -801,6 +790,7 @@ const stateHandlers: IHandlers = { // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) 'im.vector.modular.widgets': textForWidgetEvent, [WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent, + [VoiceBroadcastInfoEventType]: textForVoiceBroadcastStoppedEvent, }; // Add all the Mjolnir stuff to the renderer @@ -832,8 +822,8 @@ export function hasText(ev: MatrixEvent, showHiddenEvents?: boolean): boolean { * to avoid hitting the settings store */ export function textForEvent(ev: MatrixEvent): string; -export function textForEvent(ev: MatrixEvent, allowJSX: true, showHiddenEvents?: boolean): string | JSX.Element; -export function textForEvent(ev: MatrixEvent, allowJSX = false, showHiddenEvents?: boolean): string | JSX.Element { +export function textForEvent(ev: MatrixEvent, allowJSX: true, showHiddenEvents?: boolean): string | React.ReactNode; +export function textForEvent(ev: MatrixEvent, allowJSX = false, showHiddenEvents?: boolean): string | React.ReactNode { const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; return handler?.(ev, allowJSX, showHiddenEvents)?.() || ''; } diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index fb1c822596..c023916fab 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -47,7 +47,7 @@ import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile"; import { ElementCall } from "../models/Call"; -import { VoiceBroadcastChunkEventType } from "../voice-broadcast"; +import { shouldDisplayAsVoiceBroadcastStoppedText, VoiceBroadcastChunkEventType } from "../voice-broadcast"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps { @@ -232,6 +232,8 @@ export function pickFactory( if (shouldDisplayAsVoiceBroadcastTile(mxEvent)) { return MessageEventFactory; + } else if (shouldDisplayAsVoiceBroadcastStoppedText(mxEvent)) { + return TextualEventFactory; } if (SINGULAR_STATE_EVENTS.has(evType) && mxEvent.getStateKey() !== '') { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c3caeead0e..a740446d36 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -648,6 +648,8 @@ "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.", "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.", "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.", + "You ended a voice broadcast": "You ended a voice broadcast", + "%(senderName)s ended a voice broadcast": "%(senderName)s ended a voice broadcast", "Stop live broadcasting?": "Stop live broadcasting?", "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.", "Yes, stop broadcast": "Yes, stop broadcast", diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index 69c3351def..b3eb2abb45 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -31,6 +31,7 @@ import defaultDispatcher from "../dispatcher/dispatcher"; import { TimelineRenderingType } from "../contexts/RoomContext"; import { launchPollEditor } from "../components/views/messages/MPollBody"; import { Action } from "../dispatcher/actions"; +import { ViewRoomPayload } from '../dispatcher/payloads/ViewRoomPayload'; /** * Returns whether an event should allow actions like reply, reactions, edit, etc. @@ -292,3 +293,13 @@ export function hasThreadSummary(event: MatrixEvent): boolean { export function canPinEvent(event: MatrixEvent): boolean { return !M_BEACON_INFO.matches(event.getType()); } + +export const highlightEvent = (roomId: string, eventId: string): void => { + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + event_id: eventId, + highlighted: true, + room_id: roomId, + metricsTrigger: undefined, // room doesn't change + }); +}; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 9bb2dfd4c0..f71ce077ad 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -50,7 +50,9 @@ export * from "./utils/hasRoomLiveVoiceBroadcast"; export * from "./utils/findRoomLiveVoiceBroadcastFromUserAndDevice"; export * from "./utils/shouldDisplayAsVoiceBroadcastRecordingTile"; export * from "./utils/shouldDisplayAsVoiceBroadcastTile"; +export * from "./utils/shouldDisplayAsVoiceBroadcastStoppedText"; export * from "./utils/startNewVoiceBroadcastRecording"; +export * from "./utils/textForVoiceBroadcastStoppedEvent"; export * from "./utils/VoiceBroadcastResumer"; export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info"; diff --git a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastStoppedText.ts b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastStoppedText.ts new file mode 100644 index 0000000000..81219b044a --- /dev/null +++ b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastStoppedText.ts @@ -0,0 +1,25 @@ +/* +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 shouldDisplayAsVoiceBroadcastStoppedText = (event: MatrixEvent): boolean => ( + event.getType() === VoiceBroadcastInfoEventType + && event.getContent()?.state === VoiceBroadcastInfoState.Stopped + && !event.isRedacted() +); diff --git a/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent.tsx b/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent.tsx new file mode 100644 index 0000000000..5c42c3e17e --- /dev/null +++ b/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent.tsx @@ -0,0 +1,55 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ReactNode } from "react"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import AccessibleButton from "../../components/views/elements/AccessibleButton"; +import { highlightEvent } from "../../utils/EventUtils"; +import { getSenderName } from "../../TextForEvent"; +import { _t } from "../../languageHandler"; + +export const textForVoiceBroadcastStoppedEvent = (event: MatrixEvent): () => ReactNode => { + return (): ReactNode => { + const ownUserId = MatrixClientPeg.get()?.getUserId(); + const startEventId = event.getRelation()?.event_id; + const roomId = event.getRoomId(); + + const templateTags = { + a: (text: string) => startEventId && roomId + ? ( + highlightEvent(roomId, startEventId)} + > + { text } + + ) + : text, + }; + + if (ownUserId && ownUserId === event.getSender()) { + return _t("You ended a voice broadcast", {}, templateTags); + } + + return _t( + "%(senderName)s ended a voice broadcast", + { senderName: getSenderName(event) }, + templateTags, + ); + }; +}; diff --git a/test/events/EventTileFactory-test.ts b/test/events/EventTileFactory-test.ts index 9625ffe448..4a57e4d5ff 100644 --- a/test/events/EventTileFactory-test.ts +++ b/test/events/EventTileFactory-test.ts @@ -14,22 +14,30 @@ limitations under the License. import { EventType, MatrixClient, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; import { JSONEventFactory, pickFactory } from "../../src/events/EventTileFactory"; -import { VoiceBroadcastChunkEventType } from "../../src/voice-broadcast"; +import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoState } from "../../src/voice-broadcast"; import { createTestClient, mkEvent } from "../test-utils"; +import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils"; const roomId = "!room:example.com"; describe("pickFactory", () => { + let voiceBroadcastStoppedEvent: MatrixEvent; let voiceBroadcastChunkEvent: MatrixEvent; let audioMessageEvent: MatrixEvent; let client: MatrixClient; beforeAll(() => { client = createTestClient(); + voiceBroadcastStoppedEvent = mkVoiceBroadcastInfoStateEvent( + "!room:example.com", + VoiceBroadcastInfoState.Stopped, + client.getUserId()!, + client.deviceId!, + ); voiceBroadcastChunkEvent = mkEvent({ event: true, type: EventType.RoomMessage, - user: client.getUserId(), + user: client.getUserId()!, room: roomId, content: { msgtype: MsgType.Audio, @@ -39,7 +47,7 @@ describe("pickFactory", () => { audioMessageEvent = mkEvent({ event: true, type: EventType.RoomMessage, - user: client.getUserId(), + user: client.getUserId()!, room: roomId, content: { msgtype: MsgType.Audio, @@ -52,7 +60,7 @@ describe("pickFactory", () => { type: EventType.RoomPowerLevels, state_key: "", content: {}, - sender: client.getUserId(), + sender: client.getUserId()!, room_id: roomId, }); expect(pickFactory(event, client, true)).toBe(JSONEventFactory); @@ -63,6 +71,10 @@ describe("pickFactory", () => { expect(pickFactory(voiceBroadcastChunkEvent, client, true)).toBeInstanceOf(Function); }); + it("should return a Function for a voice broadcast stopped event", () => { + expect(pickFactory(voiceBroadcastStoppedEvent, client, true)).toBeInstanceOf(Function); + }); + it("should return a function for an audio message event", () => { expect(pickFactory(audioMessageEvent, client, true)).toBeInstanceOf(Function); }); @@ -73,6 +85,10 @@ describe("pickFactory", () => { expect(pickFactory(voiceBroadcastChunkEvent, client, false)).toBeUndefined(); }); + it("should return a Function for a voice broadcast stopped event", () => { + expect(pickFactory(voiceBroadcastStoppedEvent, client, true)).toBeInstanceOf(Function); + }); + it("should return a function for an audio message event", () => { expect(pickFactory(audioMessageEvent, client, false)).toBeInstanceOf(Function); }); diff --git a/test/utils/EventUtils-test.ts b/test/utils/EventUtils-test.ts index bf72dcd9fa..f47e4c1e78 100644 --- a/test/utils/EventUtils-test.ts +++ b/test/utils/EventUtils-test.ts @@ -35,11 +35,16 @@ import { canEditOwnEvent, fetchInitialEvent, findEditableEvent, + highlightEvent, isContentActionable, isLocationEvent, isVoiceMessage, } from "../../src/utils/EventUtils"; import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent, stubClient } from "../test-utils"; +import dis from "../../src/dispatcher/dispatcher"; +import { Action } from "../../src/dispatcher/actions"; + +jest.mock("../../src/dispatcher/dispatcher"); describe('EventUtils', () => { const userId = '@user:server'; @@ -440,4 +445,19 @@ describe('EventUtils', () => { })).toBeUndefined(); }); }); + + describe("highlightEvent", () => { + const eventId = "$zLg9jResFQmMO_UKFeWpgLgOgyWrL8qIgLgZ5VywrCQ"; + + it("should dispatch an action to view the event", () => { + highlightEvent(roomId, eventId); + expect(dis.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + event_id: eventId, + highlighted: true, + room_id: roomId, + metricsTrigger: undefined, + }); + }); + }); }); diff --git a/test/voice-broadcast/utils/__snapshots__/textForVoiceBroadcastStoppedEvent-test.tsx.snap b/test/voice-broadcast/utils/__snapshots__/textForVoiceBroadcastStoppedEvent-test.tsx.snap new file mode 100644 index 0000000000..cf1e93db13 --- /dev/null +++ b/test/voice-broadcast/utils/__snapshots__/textForVoiceBroadcastStoppedEvent-test.tsx.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`textForVoiceBroadcastStoppedEvent should render other users broadcast as expected 1`] = ` +
+
+ @other:example.com ended a voice broadcast +
+
+`; + +exports[`textForVoiceBroadcastStoppedEvent should render own broadcast as expected 1`] = ` +
+
+ You ended a voice broadcast +
+
+`; + +exports[`textForVoiceBroadcastStoppedEvent should render without login as expected 1`] = ` +
+
+ @other:example.com ended a voice broadcast +
+
+`; + +exports[`textForVoiceBroadcastStoppedEvent when rendering an event with relation to the start event should render events with relation to the start event 1`] = ` +
+
+ + You ended a + + +
+
+`; diff --git a/test/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent-test.tsx b/test/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent-test.tsx new file mode 100644 index 0000000000..03fe525abc --- /dev/null +++ b/test/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent-test.tsx @@ -0,0 +1,98 @@ +/* +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, RenderResult, screen } from "@testing-library/react"; +import userEvent from '@testing-library/user-event'; +import { mocked } from "jest-mock"; +import { MatrixClient, RelationType } from "matrix-js-sdk/src/matrix"; + +import { textForVoiceBroadcastStoppedEvent, VoiceBroadcastInfoState } from "../../../src/voice-broadcast"; +import { stubClient } from "../../test-utils"; +import { mkVoiceBroadcastInfoStateEvent } from "./test-utils"; +import dis from "../../../src/dispatcher/dispatcher"; +import { Action } from "../../../src/dispatcher/actions"; + +jest.mock("../../../src/dispatcher/dispatcher"); + +describe("textForVoiceBroadcastStoppedEvent", () => { + const otherUserId = "@other:example.com"; + const roomId = "!room:example.com"; + let client: MatrixClient; + + const renderText = (senderId: string, startEventId?: string) => { + const event = mkVoiceBroadcastInfoStateEvent( + roomId, + VoiceBroadcastInfoState.Stopped, + senderId, + client.deviceId!, + ); + + if (startEventId) { + event.getContent()["m.relates_to"] = { + rel_type: RelationType.Reference, + event_id: startEventId, + }; + } + + return render(
{ textForVoiceBroadcastStoppedEvent(event)() }
); + }; + + beforeEach(() => { + client = stubClient(); + }); + + it("should render own broadcast as expected", () => { + expect(renderText(client.getUserId()!).container).toMatchSnapshot(); + }); + + it("should render other users broadcast as expected", () => { + expect(renderText(otherUserId).container).toMatchSnapshot(); + }); + + it("should render without login as expected", () => { + mocked(client.getUserId).mockReturnValue(null); + expect(renderText(otherUserId).container).toMatchSnapshot(); + }); + + describe("when rendering an event with relation to the start event", () => { + let result: RenderResult; + + beforeEach(() => { + result = renderText(client.getUserId()!, "$start-id"); + }); + + it("should render events with relation to the start event", () => { + expect(result.container).toMatchSnapshot(); + }); + + describe("and clicking the link", () => { + beforeEach(async () => { + await userEvent.click(screen.getByRole("button")); + }); + + it("should dispatch an action to highlight the event", () => { + expect(dis.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + event_id: "$start-id", + highlighted: true, + room_id: roomId, + metricsTrigger: undefined, // room doesn't change + }); + }); + }); + }); +});