diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 1ed5ff5377..05fca50c93 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -37,7 +37,6 @@ export * from "./stores/VoiceBroadcastRecordingsStore"; export * from "./utils/getChunkLength"; export * from "./utils/hasRoomLiveVoiceBroadcast"; export * from "./utils/findRoomLiveVoiceBroadcastFromUserAndDevice"; -export * from "./utils/resumeVoiceBroadcastInRoom"; export * from "./utils/shouldDisplayAsVoiceBroadcastRecordingTile"; export * from "./utils/shouldDisplayAsVoiceBroadcastTile"; export * from "./utils/startNewVoiceBroadcastRecording"; diff --git a/src/voice-broadcast/utils/VoiceBroadcastResumer.ts b/src/voice-broadcast/utils/VoiceBroadcastResumer.ts index c8b3407451..be949d0eab 100644 --- a/src/voice-broadcast/utils/VoiceBroadcastResumer.ts +++ b/src/voice-broadcast/utils/VoiceBroadcastResumer.ts @@ -14,43 +14,87 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ClientEvent, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, MatrixClient, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix"; +import { SyncState } from "matrix-js-sdk/src/sync"; +import { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; import { IDestroyable } from "../../utils/IDestroyable"; import { findRoomLiveVoiceBroadcastFromUserAndDevice } from "./findRoomLiveVoiceBroadcastFromUserAndDevice"; -import { resumeVoiceBroadcastInRoom } from "./resumeVoiceBroadcastInRoom"; +/** + * Handles voice broadcasts on app resume (after logging in, reload, crash…). + */ export class VoiceBroadcastResumer implements IDestroyable { - private seenRooms = new Set(); - private userId: string; - private deviceId: string; - public constructor( private client: MatrixClient, ) { - this.client.on(ClientEvent.Room, this.onRoom); - this.userId = this.client.getUserId(); - this.deviceId = this.client.getDeviceId(); + if (client.isInitialSyncComplete()) { + this.resume(); + } else { + // wait for initial sync + client.on(ClientEvent.Sync, this.onClientSync); + } } - private onRoom = (room: Room): void => { - if (this.seenRooms.has(room.roomId)) return; - - this.seenRooms.add(room.roomId); - - const infoEvent = findRoomLiveVoiceBroadcastFromUserAndDevice( - room, - this.userId, - this.deviceId, - ); - - if (infoEvent) { - resumeVoiceBroadcastInRoom(infoEvent, room, this.client); + private onClientSync = () => { + if (this.client.getSyncState() === SyncState.Syncing) { + this.client.off(ClientEvent.Sync, this.onClientSync); + this.resume(); } }; + private resume(): void { + const userId = this.client.getUserId(); + const deviceId = this.client.getDeviceId(); + + if (!userId || !deviceId) { + // Resuming a voice broadcast only makes sense if there is a user. + return; + } + + this.client.getRooms().forEach((room: Room) => { + const infoEvent = findRoomLiveVoiceBroadcastFromUserAndDevice(room, userId, deviceId); + + if (infoEvent) { + // Found a live broadcast event from current device; stop it. + // Stopping it is a temporary solution (see PSF-1669). + this.sendStopVoiceBroadcastStateEvent(infoEvent); + return false; + } + }); + } + + private sendStopVoiceBroadcastStateEvent(infoEvent: MatrixEvent): void { + const userId = this.client.getUserId(); + const deviceId = this.client.getDeviceId(); + const roomId = infoEvent.getRoomId(); + + if (!userId || !deviceId || !roomId) { + // We can only send a state event if we know all the IDs. + return; + } + + const content: VoiceBroadcastInfoEventContent = { + device_id: deviceId, + state: VoiceBroadcastInfoState.Stopped, + }; + + // all events should reference the started event + const referencedEventId = infoEvent.getContent()?.state === VoiceBroadcastInfoState.Started + ? infoEvent.getId() + : infoEvent.getContent()?.["m.relates_to"]?.event_id; + + if (referencedEventId) { + content["m.relates_to"] = { + rel_type: RelationType.Reference, + event_id: referencedEventId, + }; + } + + this.client.sendStateEvent(roomId, VoiceBroadcastInfoEventType, content, userId); + } + destroy(): void { - this.client.off(ClientEvent.Room, this.onRoom); - this.seenRooms = new Set(); + this.client.off(ClientEvent.Sync, this.onClientSync); } } diff --git a/src/voice-broadcast/utils/resumeVoiceBroadcastInRoom.ts b/src/voice-broadcast/utils/resumeVoiceBroadcastInRoom.ts deleted file mode 100644 index f365fce226..0000000000 --- a/src/voice-broadcast/utils/resumeVoiceBroadcastInRoom.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoState, VoiceBroadcastRecording } from ".."; -import { VoiceBroadcastRecordingsStore } from "../stores/VoiceBroadcastRecordingsStore"; - -export const resumeVoiceBroadcastInRoom = (latestInfoEvent: MatrixEvent, room: Room, client: MatrixClient) => { - // voice broadcasts are based on their started event, try to find it - const infoEvent = latestInfoEvent.getContent()?.state === VoiceBroadcastInfoState.Started - ? latestInfoEvent - : room.findEventById(latestInfoEvent.getRelation()?.event_id); - - if (!infoEvent) { - return; - } - - const recording = new VoiceBroadcastRecording(infoEvent, client, VoiceBroadcastInfoState.Paused); - VoiceBroadcastRecordingsStore.instance().setCurrent(recording); -}; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 97a17e3b70..ca07edb0bd 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -143,7 +143,7 @@ export function createTestClient(): MatrixClient { sendTyping: jest.fn().mockResolvedValue({}), sendMessage: jest.fn().mockResolvedValue({}), sendStateEvent: jest.fn().mockResolvedValue(undefined), - getSyncState: () => "SYNCING", + getSyncState: jest.fn().mockReturnValue("SYNCING"), generateClientSecret: () => "t35tcl1Ent5ECr3T", isGuest: jest.fn().mockReturnValue(false), getRoomHierarchy: jest.fn().mockReturnValue({ diff --git a/test/voice-broadcast/utils/VoiceBroadcastResumer-test.ts b/test/voice-broadcast/utils/VoiceBroadcastResumer-test.ts index 4583590342..b166bbb9bd 100644 --- a/test/voice-broadcast/utils/VoiceBroadcastResumer-test.ts +++ b/test/voice-broadcast/utils/VoiceBroadcastResumer-test.ts @@ -15,40 +15,78 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { ClientEvent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, MatrixClient, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix"; +import { SyncState } from "matrix-js-sdk/src/sync"; import { - findRoomLiveVoiceBroadcastFromUserAndDevice, - resumeVoiceBroadcastInRoom, + VoiceBroadcastInfoEventContent, + VoiceBroadcastInfoEventType, VoiceBroadcastInfoState, VoiceBroadcastResumer, } from "../../../src/voice-broadcast"; import { stubClient } from "../../test-utils"; import { mkVoiceBroadcastInfoStateEvent } from "./test-utils"; -jest.mock("../../../src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice"); -jest.mock("../../../src/voice-broadcast/utils/resumeVoiceBroadcastInRoom"); - describe("VoiceBroadcastResumer", () => { const roomId = "!room:example.com"; let client: MatrixClient; let room: Room; let resumer: VoiceBroadcastResumer; - let infoEvent: MatrixEvent; + let startedInfoEvent: MatrixEvent; + let pausedInfoEvent: MatrixEvent; + + const itShouldNotSendAStateEvent = (): void => { + it("should not send a state event", () => { + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + }; + + const itShouldSendAStoppedStateEvent = (): void => { + it("should send a stopped state event", () => { + expect(client.sendStateEvent).toHaveBeenCalledWith( + startedInfoEvent.getRoomId(), + VoiceBroadcastInfoEventType, + { + "device_id": client.getDeviceId(), + "state": VoiceBroadcastInfoState.Stopped, + "m.relates_to": { + rel_type: RelationType.Reference, + event_id: startedInfoEvent.getId(), + }, + } as VoiceBroadcastInfoEventContent, + client.getUserId(), + ); + }); + }; + + const itShouldDeregisterFromTheClient = () => { + it("should deregister from the client", () => { + expect(client.off).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function)); + }); + }; beforeEach(() => { client = stubClient(); jest.spyOn(client, "off"); - room = new Room(roomId, client, client.getUserId()); - mocked(client.getRoom).mockImplementation((getRoomId: string) => { + room = new Room(roomId, client, client.getUserId()!); + mocked(client.getRoom).mockImplementation((getRoomId: string | undefined) => { if (getRoomId === roomId) return room; + + return null; }); - resumer = new VoiceBroadcastResumer(client); - infoEvent = mkVoiceBroadcastInfoStateEvent( + mocked(client.getRooms).mockReturnValue([room]); + startedInfoEvent = mkVoiceBroadcastInfoStateEvent( roomId, VoiceBroadcastInfoState.Started, - client.getUserId(), - client.getDeviceId(), + client.getUserId()!, + client.getDeviceId()!, + ); + pausedInfoEvent = mkVoiceBroadcastInfoStateEvent( + roomId, + VoiceBroadcastInfoState.Paused, + client.getUserId()!, + client.getDeviceId()!, + startedInfoEvent, ); }); @@ -56,56 +94,95 @@ describe("VoiceBroadcastResumer", () => { jest.clearAllMocks(); }); - describe("when there is no info event", () => { + describe("when the initial sync is completed", () => { beforeEach(() => { - client.emit(ClientEvent.Room, room); + mocked(client.isInitialSyncComplete).mockReturnValue(true); }); - it("should not resume a broadcast", () => { - expect(resumeVoiceBroadcastInRoom).not.toHaveBeenCalled(); - }); - }); - - describe("when there is an info event", () => { - beforeEach(() => { - mocked(findRoomLiveVoiceBroadcastFromUserAndDevice).mockImplementation(( - findRoom: Room, - userId: string, - deviceId: string, - ) => { - if (findRoom === room && userId === client.getUserId() && deviceId === client.getDeviceId()) { - return infoEvent; - } - }); - client.emit(ClientEvent.Room, room); - }); - - it("should resume a broadcast", () => { - expect(resumeVoiceBroadcastInRoom).toHaveBeenCalledWith( - infoEvent, - room, - client, - ); - }); - - describe("and emitting a room event again", () => { + describe("and there is no info event", () => { beforeEach(() => { - client.emit(ClientEvent.Room, room); + resumer = new VoiceBroadcastResumer(client); }); - it("should not resume the broadcast again", () => { - expect(resumeVoiceBroadcastInRoom).toHaveBeenCalledTimes(1); + itShouldNotSendAStateEvent(); + + describe("and calling destroy", () => { + beforeEach(() => { + resumer.destroy(); + }); + + itShouldDeregisterFromTheClient(); }); }); + + describe("and there is a started info event", () => { + beforeEach(() => { + room.currentState.setStateEvents([startedInfoEvent]); + }); + + describe("and the client knows about the user and device", () => { + beforeEach(() => { + resumer = new VoiceBroadcastResumer(client); + }); + + itShouldSendAStoppedStateEvent(); + }); + + describe("and the client doesn't know about the user", () => { + beforeEach(() => { + mocked(client.getUserId).mockReturnValue(null); + resumer = new VoiceBroadcastResumer(client); + }); + + itShouldNotSendAStateEvent(); + }); + + describe("and the client doesn't know about the device", () => { + beforeEach(() => { + mocked(client.getDeviceId).mockReturnValue(null); + resumer = new VoiceBroadcastResumer(client); + }); + + itShouldNotSendAStateEvent(); + }); + }); + + describe("and there is a paused info event", () => { + beforeEach(() => { + room.currentState.setStateEvents([pausedInfoEvent]); + resumer = new VoiceBroadcastResumer(client); + }); + + itShouldSendAStoppedStateEvent(); + }); }); - describe("when calling destroy", () => { + describe("when the initial sync is not completed", () => { beforeEach(() => { - resumer.destroy(); + room.currentState.setStateEvents([pausedInfoEvent]); + mocked(client.isInitialSyncComplete).mockReturnValue(false); + mocked(client.getSyncState).mockReturnValue(SyncState.Prepared); + resumer = new VoiceBroadcastResumer(client); }); - it("should deregister from the client", () => { - expect(client.off).toHaveBeenCalledWith(ClientEvent.Room, expect.any(Function)); + itShouldNotSendAStateEvent(); + + describe("and a sync event appears", () => { + beforeEach(() => { + client.emit(ClientEvent.Sync, SyncState.Prepared, SyncState.Stopped); + }); + + itShouldNotSendAStateEvent(); + + describe("and the initial sync completed and a sync event appears", () => { + beforeEach(() => { + mocked(client.getSyncState).mockReturnValue(SyncState.Syncing); + client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Prepared); + }); + + itShouldSendAStoppedStateEvent(); + itShouldDeregisterFromTheClient(); + }); }); }); }); diff --git a/test/voice-broadcast/utils/resumeVoiceBroadcastInRoom-test.ts b/test/voice-broadcast/utils/resumeVoiceBroadcastInRoom-test.ts deleted file mode 100644 index 8d435fb62e..0000000000 --- a/test/voice-broadcast/utils/resumeVoiceBroadcastInRoom-test.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { mocked } from "jest-mock"; -import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; - -import { - resumeVoiceBroadcastInRoom, - VoiceBroadcastInfoState, - VoiceBroadcastRecording, - VoiceBroadcastRecordingsStore, -} from "../../../src/voice-broadcast"; -import { stubClient } from "../../test-utils"; -import { mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils"; - -const mockRecording = jest.fn(); - -jest.mock("../../../src/voice-broadcast/models/VoiceBroadcastRecording", () => ({ - ...jest.requireActual("../../../src/voice-broadcast/models/VoiceBroadcastRecording") as object, - VoiceBroadcastRecording: jest.fn().mockImplementation(() => mockRecording), -})); - -describe("resumeVoiceBroadcastInRoom", () => { - let client: MatrixClient; - const roomId = "!room:example.com"; - let room: Room; - let startedInfoEvent: MatrixEvent; - let stoppedInfoEvent: MatrixEvent; - - const itShouldStartAPausedRecording = () => { - it("should start a paused recording", () => { - expect(VoiceBroadcastRecording).toHaveBeenCalledWith( - startedInfoEvent, - client, - VoiceBroadcastInfoState.Paused, - ); - expect(VoiceBroadcastRecordingsStore.instance().setCurrent).toHaveBeenCalledWith(mockRecording); - }); - }; - - beforeEach(() => { - client = stubClient(); - room = new Room(roomId, client, client.getUserId()); - jest.spyOn(room, "findEventById"); - jest.spyOn(VoiceBroadcastRecordingsStore.instance(), "setCurrent").mockImplementation(); - - startedInfoEvent = mkVoiceBroadcastInfoStateEvent( - roomId, - VoiceBroadcastInfoState.Started, - client.getUserId(), - client.getDeviceId(), - ); - - stoppedInfoEvent = mkVoiceBroadcastInfoStateEvent( - roomId, - VoiceBroadcastInfoState.Stopped, - client.getUserId(), - client.getDeviceId(), - startedInfoEvent, - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe("when called with a stopped info event", () => { - describe("and there is a related event", () => { - beforeEach(() => { - mocked(room.findEventById).mockReturnValue(startedInfoEvent); - resumeVoiceBroadcastInRoom(stoppedInfoEvent, room, client); - }); - - itShouldStartAPausedRecording(); - }); - - describe("and there is no related event", () => { - beforeEach(() => { - mocked(room.findEventById).mockReturnValue(null); - resumeVoiceBroadcastInRoom(stoppedInfoEvent, room, client); - }); - - it("should not start a broadcast", () => { - expect(VoiceBroadcastRecording).not.toHaveBeenCalled(); - expect(VoiceBroadcastRecordingsStore.instance().setCurrent).not.toHaveBeenCalled(); - }); - }); - }); - - describe("when called with a started info event", () => { - beforeEach(() => { - resumeVoiceBroadcastInRoom(startedInfoEvent, room, client); - }); - - itShouldStartAPausedRecording(); - }); -});