Voice Broadcast live state / extract `RelationsHelper` (#9432)
* Extract RelationsHelper * Make RelationsHelper.relations optionalpull/28788/head^2
							parent
							
								
									e38c9e036c
								
							
						
					
					
						commit
						1b74782854
					
				|  | @ -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<RelationsHelperEvent, EventMap> | ||||
|     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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -31,6 +31,7 @@ export const VoiceBroadcastPlaybackBody: React.FC<VoiceBroadcastPlaybackBodyProp | |||
|     playback, | ||||
| }) => { | ||||
|     const { | ||||
|         live, | ||||
|         room, | ||||
|         sender, | ||||
|         toggle, | ||||
|  | @ -40,7 +41,7 @@ export const VoiceBroadcastPlaybackBody: React.FC<VoiceBroadcastPlaybackBodyProp | |||
|     return ( | ||||
|         <div className="mx_VoiceBroadcastPlaybackBody"> | ||||
|             <VoiceBroadcastHeader | ||||
|                 live={false} | ||||
|                 live={live} | ||||
|                 sender={sender} | ||||
|                 room={room} | ||||
|                 showBroadcast={true} | ||||
|  |  | |||
|  | @ -19,6 +19,7 @@ import { useState } from "react"; | |||
| import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; | ||||
| import { MatrixClientPeg } from "../../MatrixClientPeg"; | ||||
| import { | ||||
|     VoiceBroadcastInfoState, | ||||
|     VoiceBroadcastPlayback, | ||||
|     VoiceBroadcastPlaybackEvent, | ||||
|     VoiceBroadcastPlaybackState, | ||||
|  | @ -40,7 +41,17 @@ export const useVoiceBroadcastPlayback = (playback: VoiceBroadcastPlayback) => { | |||
|         }, | ||||
|     ); | ||||
| 
 | ||||
|     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, | ||||
|  |  | |||
|  | @ -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<VoiceBroadcastPlaybackEvent, EventMap> | ||||
|     implements IDestroyable { | ||||
|     private state = VoiceBroadcastPlaybackState.Stopped; | ||||
|     private infoState: VoiceBroadcastInfoState; | ||||
|     private chunkEvents = new Map<string, MatrixEvent>(); | ||||
|     /** 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<void> { | ||||
|  | @ -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(); | ||||
|     } | ||||
|  |  | |||
|  | @ -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); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  | @ -45,6 +45,17 @@ exports[`VoiceBroadcastPlaybackBody when rendering a broadcast should render as | |||
|           Voice broadcast | ||||
|         </div> | ||||
|       </div> | ||||
|       <div | ||||
|         class="mx_LiveBadge" | ||||
|       > | ||||
|         <i | ||||
|           aria-hidden="true" | ||||
|           class="mx_Icon mx_Icon_16 mx_Icon_live-badge" | ||||
|           role="presentation" | ||||
|           style="mask-image: url(\\"image-file-stub\\");" | ||||
|         /> | ||||
|         Live | ||||
|       </div> | ||||
|     </div> | ||||
|     <div | ||||
|       class="mx_VoiceBroadcastPlaybackBody_controls" | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 Michael Weimann
						Michael Weimann