diff --git a/src/events/RelationsHelper.ts b/src/events/RelationsHelper.ts new file mode 100644 index 0000000000..b211d03862 --- /dev/null +++ b/src/events/RelationsHelper.ts @@ -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 { MatrixClient, MatrixEvent, MatrixEventEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { Relations, RelationsEvent } from "matrix-js-sdk/src/models/relations"; +import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; + +import { IDestroyable } from "../utils/IDestroyable"; + +export enum RelationsHelperEvent { + Add = "add", +} + +interface EventMap { + [RelationsHelperEvent.Add]: (event: MatrixEvent) => void; +} + +/** + * Helper class that manages a specific event type relation for an event. + * Just create an instance and listen for new events for that relation. + * Optionally receive the current events by calling emitCurrent(). + * Clean up everything by calling destroy(). + */ +export class RelationsHelper + extends TypedEventEmitter + implements IDestroyable { + private relations?: Relations; + + public constructor( + private event: MatrixEvent, + private relationType: RelationType, + private relationEventType: string, + private client: MatrixClient, + ) { + super(); + this.setUpRelations(); + } + + private setUpRelations = (): void => { + this.setRelations(); + + if (this.relations) { + this.relations.on(RelationsEvent.Add, this.onRelationsAdd); + } else { + this.event.once(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + } + }; + + private onRelationsCreated = (): void => { + this.setRelations(); + + if (this.relations) { + this.relations.on(RelationsEvent.Add, this.onRelationsAdd); + this.emitCurrent(); + } else { + this.event.once(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + } + }; + + private setRelations(): void { + const room = this.client.getRoom(this.event.getRoomId()); + this.relations = room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent( + this.event.getId(), + this.relationType, + this.relationEventType, + ); + } + + private onRelationsAdd = (event: MatrixEvent): void => { + this.emit(RelationsHelperEvent.Add, event); + }; + + public emitCurrent(): void { + this.relations?.getRelations()?.forEach(e => this.emit(RelationsHelperEvent.Add, e)); + } + + public destroy(): void { + this.removeAllListeners(); + this.event.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + + if (this.relations) { + this.relations.off(RelationsEvent.Add, this.onRelationsAdd); + } + } +} diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx index ed9f5bef65..8edc5d0d9a 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx @@ -31,6 +31,7 @@ export const VoiceBroadcastPlaybackBody: React.FC { const { + live, room, sender, toggle, @@ -40,7 +41,7 @@ export const VoiceBroadcastPlaybackBody: React.FC { }, ); + const [playbackInfoState, setPlaybackInfoState] = useState(playback.getInfoState()); + useTypedEventEmitter( + playback, + VoiceBroadcastPlaybackEvent.InfoStateChanged, + (state: VoiceBroadcastInfoState) => { + setPlaybackInfoState(state); + }, + ); + return { + live: playbackInfoState !== VoiceBroadcastInfoState.Stopped, room: room, sender: playback.infoEvent.sender, toggle: playbackToggle, diff --git a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts index 5abc87e01f..f8f213ada5 100644 --- a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts +++ b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts @@ -18,20 +18,19 @@ import { EventType, MatrixClient, MatrixEvent, - MatrixEventEvent, MsgType, RelationType, } from "matrix-js-sdk/src/matrix"; -import { Relations, RelationsEvent } from "matrix-js-sdk/src/models/relations"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; import { Playback, PlaybackState } from "../../audio/Playback"; import { PlaybackManager } from "../../audio/PlaybackManager"; -import { getReferenceRelationsForEvent } from "../../events"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { MediaEventHelper } from "../../utils/MediaEventHelper"; import { IDestroyable } from "../../utils/IDestroyable"; -import { VoiceBroadcastChunkEventType } from ".."; +import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; +import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; +import { getReferenceRelationsForEvent } from "../../events"; export enum VoiceBroadcastPlaybackState { Paused, @@ -42,29 +41,55 @@ export enum VoiceBroadcastPlaybackState { export enum VoiceBroadcastPlaybackEvent { LengthChanged = "length_changed", StateChanged = "state_changed", + InfoStateChanged = "info_state_changed", } interface EventMap { [VoiceBroadcastPlaybackEvent.LengthChanged]: (length: number) => void; [VoiceBroadcastPlaybackEvent.StateChanged]: (state: VoiceBroadcastPlaybackState) => void; + [VoiceBroadcastPlaybackEvent.InfoStateChanged]: (state: VoiceBroadcastInfoState) => void; } export class VoiceBroadcastPlayback extends TypedEventEmitter implements IDestroyable { private state = VoiceBroadcastPlaybackState.Stopped; + private infoState: VoiceBroadcastInfoState; private chunkEvents = new Map(); - /** Holds the playback qeue with a 1-based index (sequence number) */ + /** Holds the playback queue with a 1-based index (sequence number) */ private queue: Playback[] = []; private currentlyPlaying: Playback; - private relations: Relations; + private lastInfoEvent: MatrixEvent; + private chunkRelationHelper: RelationsHelper; + private infoRelationHelper: RelationsHelper; public constructor( public readonly infoEvent: MatrixEvent, private client: MatrixClient, ) { super(); - this.setUpRelations(); + this.addInfoEvent(this.infoEvent); + this.setUpRelationsHelper(); + } + + private setUpRelationsHelper(): void { + this.infoRelationHelper = new RelationsHelper( + this.infoEvent, + RelationType.Reference, + VoiceBroadcastInfoEventType, + this.client, + ); + this.infoRelationHelper.on(RelationsHelperEvent.Add, this.addInfoEvent); + this.infoRelationHelper.emitCurrent(); + + this.chunkRelationHelper = new RelationsHelper( + this.infoEvent, + RelationType.Reference, + EventType.RoomMessage, + this.client, + ); + this.chunkRelationHelper.on(RelationsHelperEvent.Add, this.addChunkEvent); + this.chunkRelationHelper.emitCurrent(); } private addChunkEvent(event: MatrixEvent): boolean { @@ -81,41 +106,21 @@ export class VoiceBroadcastPlayback return true; } - private setUpRelations(): void { - const relations = getReferenceRelationsForEvent(this.infoEvent, EventType.RoomMessage, this.client); - - if (!relations) { - // No related events, yet. Set up relation watcher. - this.infoEvent.on(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + private addInfoEvent = (event: MatrixEvent): void => { + if (this.lastInfoEvent && this.lastInfoEvent.getTs() >= event.getTs()) { + // Only handle newer events return; } - this.relations = relations; - relations.getRelations()?.forEach(e => this.addChunkEvent(e)); - relations.on(RelationsEvent.Add, this.onRelationsEventAdd); + const state = event.getContent()?.state; - if (this.chunkEvents.size > 0) { - this.emitLengthChanged(); - } - } - - private onRelationsEventAdd = (event: MatrixEvent) => { - if (this.addChunkEvent(event)) { - this.emitLengthChanged(); - } - }; - - private emitLengthChanged(): void { - this.emit(VoiceBroadcastPlaybackEvent.LengthChanged, this.chunkEvents.size); - } - - private onRelationsCreated = (relationType: string) => { - if (relationType !== RelationType.Reference) { + if (!Object.values(VoiceBroadcastInfoState).includes(state)) { + // Do not handle unknown voice broadcast states return; } - this.infoEvent.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); - this.setUpRelations(); + this.lastInfoEvent = event; + this.setInfoState(state); }; private async loadChunks(): Promise { @@ -173,7 +178,7 @@ export class VoiceBroadcastPlayback } this.setState(VoiceBroadcastPlaybackState.Playing); - // index of the first schunk is the first sequence number + // index of the first chunk is the first sequence number const first = this.queue[1]; this.currentlyPlaying = first; await first.play(); @@ -238,17 +243,27 @@ export class VoiceBroadcastPlayback this.emit(VoiceBroadcastPlaybackEvent.StateChanged, state); } + public getInfoState(): VoiceBroadcastInfoState { + return this.infoState; + } + + private setInfoState(state: VoiceBroadcastInfoState): void { + if (this.infoState === state) { + return; + } + + this.infoState = state; + this.emit(VoiceBroadcastPlaybackEvent.InfoStateChanged, state); + } + private destroyQueue(): void { this.queue.forEach(p => p.destroy()); this.queue = []; } public destroy(): void { - if (this.relations) { - this.relations.off(RelationsEvent.Add, this.onRelationsEventAdd); - } - - this.infoEvent.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + this.chunkRelationHelper.destroy(); + this.infoRelationHelper.destroy(); this.removeAllListeners(); this.destroyQueue(); } diff --git a/test/events/RelationsHelper-test.ts b/test/events/RelationsHelper-test.ts new file mode 100644 index 0000000000..3d9c256216 --- /dev/null +++ b/test/events/RelationsHelper-test.ts @@ -0,0 +1,150 @@ +/* +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 { + EventTimelineSet, + EventType, + MatrixClient, + MatrixEvent, + MatrixEventEvent, + RelationType, + Room, +} from "matrix-js-sdk/src/matrix"; +import { Relations } from "matrix-js-sdk/src/models/relations"; +import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container"; + +import { RelationsHelper, RelationsHelperEvent } from "../../src/events/RelationsHelper"; +import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; + +describe("RelationsHelper", () => { + const roomId = "!room:example.com"; + let event: MatrixEvent; + let relatedEvent1: MatrixEvent; + let relatedEvent2: MatrixEvent; + let room: Room; + let client: MatrixClient; + let relationsHelper: RelationsHelper; + let onAdd: (event: MatrixEvent) => void; + let timelineSet: EventTimelineSet; + let relationsContainer: RelationsContainer; + let relations: Relations; + let relationsOnAdd: (event: MatrixEvent) => void; + + beforeEach(() => { + client = stubClient(); + room = mkStubRoom(roomId, "test room", client); + mocked(client.getRoom).mockImplementation((getRoomId: string) => { + if (getRoomId === roomId) { + return room; + } + }); + event = mkEvent({ + event: true, + type: EventType.RoomMessage, + room: roomId, + user: client.getUserId(), + content: {}, + }); + relatedEvent1 = mkEvent({ + event: true, + type: EventType.RoomMessage, + room: roomId, + user: client.getUserId(), + content: {}, + }); + relatedEvent2 = mkEvent({ + event: true, + type: EventType.RoomMessage, + room: roomId, + user: client.getUserId(), + content: {}, + }); + onAdd = jest.fn(); + // TODO Michael W: create test utils, remove casts + relationsContainer = { + getChildEventsForEvent: jest.fn(), + } as unknown as RelationsContainer; + relations = { + getRelations: jest.fn(), + on: jest.fn().mockImplementation((type, l) => relationsOnAdd = l), + } as unknown as Relations; + timelineSet = { + relations: relationsContainer, + } as unknown as EventTimelineSet; + }); + + describe("when there is an event without relations", () => { + beforeEach(() => { + relationsHelper = new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); + relationsHelper.on(RelationsHelperEvent.Add, onAdd); + }); + + describe("emitCurrent", () => { + beforeEach(() => { + relationsHelper.emitCurrent(); + }); + + it("should not emit any event", () => { + expect(onAdd).not.toHaveBeenCalled(); + }); + }); + + describe("and relations are created and a new event appears", () => { + beforeEach(() => { + mocked(room.getUnfilteredTimelineSet).mockReturnValue(timelineSet); + mocked(relationsContainer.getChildEventsForEvent).mockReturnValue(relations); + mocked(relations.getRelations).mockReturnValue([relatedEvent1]); + event.emit(MatrixEventEvent.RelationsCreated, RelationType.Reference, EventType.RoomMessage); + relationsOnAdd(relatedEvent2); + }); + + it("should emit the new event", () => { + expect(onAdd).toHaveBeenCalledWith(relatedEvent2); + }); + }); + }); + + describe("when there is an event with relations", () => { + beforeEach(() => { + mocked(room.getUnfilteredTimelineSet).mockReturnValue(timelineSet); + mocked(relationsContainer.getChildEventsForEvent).mockReturnValue(relations); + mocked(relations.getRelations).mockReturnValue([relatedEvent1]); + relationsHelper = new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); + relationsHelper.on(RelationsHelperEvent.Add, onAdd); + }); + + describe("emitCurrent", () => { + beforeEach(() => { + relationsHelper.emitCurrent(); + }); + + it("should emit the related event", () => { + expect(onAdd).toHaveBeenCalledWith(relatedEvent1); + }); + }); + + describe("and a new event appears", () => { + beforeEach(() => { + relationsOnAdd(relatedEvent2); + }); + + it("should emit the new event", () => { + expect(onAdd).toHaveBeenCalledWith(relatedEvent2); + }); + }); + }); +}); diff --git a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap index 37daccc1c5..b2515a78c2 100644 --- a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap +++ b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap @@ -45,6 +45,17 @@ exports[`VoiceBroadcastPlaybackBody when rendering a broadcast should render as Voice broadcast +
+