Use server side relations for voice broadcasts (#9534)

This commit is contained in:
Michael Weimann 2022-11-07 15:19:49 +01:00 committed by GitHub
parent 3747464b41
commit 36a574a14f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 396 additions and 192 deletions

View file

@ -38,6 +38,8 @@ export class RelationsHelper
extends TypedEventEmitter<RelationsHelperEvent, EventMap> extends TypedEventEmitter<RelationsHelperEvent, EventMap>
implements IDestroyable { implements IDestroyable {
private relations?: Relations; private relations?: Relations;
private eventId: string;
private roomId: string;
public constructor( public constructor(
private event: MatrixEvent, private event: MatrixEvent,
@ -46,6 +48,21 @@ export class RelationsHelper
private client: MatrixClient, private client: MatrixClient,
) { ) {
super(); super();
const eventId = event.getId();
if (!eventId) {
throw new Error("unable to create RelationsHelper: missing event ID");
}
const roomId = event.getRoomId();
if (!roomId) {
throw new Error("unable to create RelationsHelper: missing room ID");
}
this.eventId = eventId;
this.roomId = roomId;
this.setUpRelations(); this.setUpRelations();
} }
@ -73,7 +90,7 @@ export class RelationsHelper
private setRelations(): void { private setRelations(): void {
const room = this.client.getRoom(this.event.getRoomId()); const room = this.client.getRoom(this.event.getRoomId());
this.relations = room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent( this.relations = room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent(
this.event.getId(), this.eventId,
this.relationType, this.relationType,
this.relationEventType, this.relationEventType,
); );
@ -87,6 +104,32 @@ export class RelationsHelper
this.relations?.getRelations()?.forEach(e => this.emit(RelationsHelperEvent.Add, e)); this.relations?.getRelations()?.forEach(e => this.emit(RelationsHelperEvent.Add, e));
} }
public getCurrent(): MatrixEvent[] {
return this.relations?.getRelations() || [];
}
/**
* Fetches all related events from the server and emits them.
*/
public async emitFetchCurrent(): Promise<void> {
let nextBatch: string | undefined = undefined;
do {
const response = await this.client.relations(
this.roomId,
this.eventId,
this.relationType,
this.relationEventType,
{
from: nextBatch,
limit: 50,
},
);
nextBatch = response?.nextBatch;
response?.events.forEach(e => this.emit(RelationsHelperEvent.Add, e));
} while (nextBatch);
}
public destroy(): void { public destroy(): void {
this.removeAllListeners(); this.removeAllListeners();
this.event.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); this.event.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated);

View file

@ -32,7 +32,6 @@ import { MediaEventHelper } from "../../utils/MediaEventHelper";
import { IDestroyable } from "../../utils/IDestroyable"; import { IDestroyable } from "../../utils/IDestroyable";
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "..";
import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper";
import { getReferenceRelationsForEvent } from "../../events";
import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents"; import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents";
export enum VoiceBroadcastPlaybackState { export enum VoiceBroadcastPlaybackState {
@ -89,15 +88,27 @@ export class VoiceBroadcastPlayback
this.setUpRelationsHelper(); this.setUpRelationsHelper();
} }
private setUpRelationsHelper(): void { private async setUpRelationsHelper(): Promise<void> {
this.infoRelationHelper = new RelationsHelper( this.infoRelationHelper = new RelationsHelper(
this.infoEvent, this.infoEvent,
RelationType.Reference, RelationType.Reference,
VoiceBroadcastInfoEventType, VoiceBroadcastInfoEventType,
this.client, this.client,
); );
this.infoRelationHelper.on(RelationsHelperEvent.Add, this.addInfoEvent); this.infoRelationHelper.getCurrent().forEach(this.addInfoEvent);
this.infoRelationHelper.emitCurrent();
if (this.infoState !== VoiceBroadcastInfoState.Stopped) {
// Only required if not stopped. Stopped is the final state.
this.infoRelationHelper.on(RelationsHelperEvent.Add, this.addInfoEvent);
try {
await this.infoRelationHelper.emitFetchCurrent();
} catch (err) {
logger.warn("error fetching server side relation for voice broadcast info", err);
// fall back to local events
this.infoRelationHelper.emitCurrent();
}
}
this.chunkRelationHelper = new RelationsHelper( this.chunkRelationHelper = new RelationsHelper(
this.infoEvent, this.infoEvent,
@ -106,7 +117,15 @@ export class VoiceBroadcastPlayback
this.client, this.client,
); );
this.chunkRelationHelper.on(RelationsHelperEvent.Add, this.addChunkEvent); this.chunkRelationHelper.on(RelationsHelperEvent.Add, this.addChunkEvent);
this.chunkRelationHelper.emitCurrent();
try {
// TODO Michael W: only fetch events if needed, blocked by PSF-1708
await this.chunkRelationHelper.emitFetchCurrent();
} catch (err) {
logger.warn("error fetching server side relation for voice broadcast chunks", err);
// fall back to local events
this.chunkRelationHelper.emitCurrent();
}
} }
private addChunkEvent = async (event: MatrixEvent): Promise<boolean> => { private addChunkEvent = async (event: MatrixEvent): Promise<boolean> => {
@ -150,23 +169,18 @@ export class VoiceBroadcastPlayback
this.setInfoState(state); this.setInfoState(state);
}; };
private async loadChunks(): Promise<void> { private async enqueueChunks(): Promise<void> {
const relations = getReferenceRelationsForEvent(this.infoEvent, EventType.RoomMessage, this.client); const promises = this.chunkEvents.getEvents().reduce((promises, event: MatrixEvent) => {
const chunkEvents = relations?.getRelations(); if (!this.playbacks.has(event.getId() || "")) {
promises.push(this.enqueueChunk(event));
}
return promises;
}, [] as Promise<void>[]);
if (!chunkEvents) { await Promise.all(promises);
return;
}
this.chunkEvents.addEvents(chunkEvents);
this.setDuration(this.chunkEvents.getLength());
for (const chunkEvent of chunkEvents) {
await this.enqueueChunk(chunkEvent);
}
} }
private async enqueueChunk(chunkEvent: MatrixEvent) { private async enqueueChunk(chunkEvent: MatrixEvent): Promise<void> {
const eventId = chunkEvent.getId(); const eventId = chunkEvent.getId();
if (!eventId) { if (!eventId) {
@ -317,10 +331,7 @@ export class VoiceBroadcastPlayback
} }
public async start(): Promise<void> { public async start(): Promise<void> {
if (this.playbacks.size === 0) { await this.enqueueChunks();
await this.loadChunks();
}
const chunkEvents = this.chunkEvents.getEvents(); const chunkEvents = this.chunkEvents.getEvents();
const toPlay = this.getInfoState() === VoiceBroadcastInfoState.Stopped const toPlay = this.getInfoState() === VoiceBroadcastInfoState.Stopped

19
test/@types/common.ts Normal file
View file

@ -0,0 +1,19 @@
/*
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.
*/
export type PublicInterface<T> = {
[P in keyof T]: T[P];
};

View file

@ -28,13 +28,15 @@ import { Relations } from "matrix-js-sdk/src/models/relations";
import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container"; import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { RelationsHelper, RelationsHelperEvent } from "../../src/events/RelationsHelper"; import { RelationsHelper, RelationsHelperEvent } from "../../src/events/RelationsHelper";
import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; import { mkEvent, mkRelationsContainer, mkStubRoom, stubClient } from "../test-utils";
describe("RelationsHelper", () => { describe("RelationsHelper", () => {
const roomId = "!room:example.com"; const roomId = "!room:example.com";
let userId: string;
let event: MatrixEvent; let event: MatrixEvent;
let relatedEvent1: MatrixEvent; let relatedEvent1: MatrixEvent;
let relatedEvent2: MatrixEvent; let relatedEvent2: MatrixEvent;
let relatedEvent3: MatrixEvent;
let room: Room; let room: Room;
let client: MatrixClient; let client: MatrixClient;
let relationsHelper: RelationsHelper; let relationsHelper: RelationsHelper;
@ -46,47 +48,81 @@ describe("RelationsHelper", () => {
beforeEach(() => { beforeEach(() => {
client = stubClient(); client = stubClient();
userId = client.getUserId() || "";
mocked(client.relations).mockClear();
room = mkStubRoom(roomId, "test room", client); room = mkStubRoom(roomId, "test room", client);
mocked(client.getRoom).mockImplementation((getRoomId: string) => { mocked(client.getRoom).mockImplementation((getRoomId?: string) => {
if (getRoomId === roomId) { if (getRoomId === roomId) {
return room; return room;
} }
return null;
}); });
event = mkEvent({ event = mkEvent({
event: true, event: true,
type: EventType.RoomMessage, type: EventType.RoomMessage,
room: roomId, room: roomId,
user: client.getUserId(), user: userId,
content: {}, content: {},
}); });
relatedEvent1 = mkEvent({ relatedEvent1 = mkEvent({
event: true, event: true,
type: EventType.RoomMessage, type: EventType.RoomMessage,
room: roomId, room: roomId,
user: client.getUserId(), user: userId,
content: {}, content: { relatedEvent: 1 },
}); });
relatedEvent2 = mkEvent({ relatedEvent2 = mkEvent({
event: true, event: true,
type: EventType.RoomMessage, type: EventType.RoomMessage,
room: roomId, room: roomId,
user: client.getUserId(), user: userId,
content: {}, content: { relatedEvent: 2 },
});
relatedEvent3 = mkEvent({
event: true,
type: EventType.RoomMessage,
room: roomId,
user: userId,
content: { relatedEvent: 3 },
}); });
onAdd = jest.fn(); onAdd = jest.fn();
relationsContainer = mkRelationsContainer();
// TODO Michael W: create test utils, remove casts // TODO Michael W: create test utils, remove casts
relationsContainer = {
getChildEventsForEvent: jest.fn(),
} as unknown as RelationsContainer;
relations = { relations = {
getRelations: jest.fn(), getRelations: jest.fn(),
on: jest.fn().mockImplementation((type, l) => relationsOnAdd = l), on: jest.fn().mockImplementation((type, l) => relationsOnAdd = l),
off: jest.fn(),
} as unknown as Relations; } as unknown as Relations;
timelineSet = { timelineSet = {
relations: relationsContainer, relations: relationsContainer,
} as unknown as EventTimelineSet; } as unknown as EventTimelineSet;
}); });
afterEach(() => {
relationsHelper?.destroy();
});
describe("when there is an event without ID", () => {
it("should raise an error", () => {
jest.spyOn(event, "getId").mockReturnValue(undefined);
expect(() => {
new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client);
}).toThrowError("unable to create RelationsHelper: missing event ID");
});
});
describe("when there is an event without room ID", () => {
it("should raise an error", () => {
jest.spyOn(event, "getRoomId").mockReturnValue(undefined);
expect(() => {
new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client);
}).toThrowError("unable to create RelationsHelper: missing room ID");
});
});
describe("when there is an event without relations", () => { describe("when there is an event without relations", () => {
beforeEach(() => { beforeEach(() => {
relationsHelper = new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); relationsHelper = new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client);
@ -118,6 +154,34 @@ describe("RelationsHelper", () => {
}); });
}); });
describe("when there is an event with two pages server side relations", () => {
beforeEach(() => {
mocked(client.relations)
.mockResolvedValueOnce({
events: [relatedEvent1, relatedEvent2],
nextBatch: "next",
})
.mockResolvedValueOnce({
events: [relatedEvent3],
nextBatch: null,
});
relationsHelper = new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client);
relationsHelper.on(RelationsHelperEvent.Add, onAdd);
});
describe("emitFetchCurrent", () => {
beforeEach(async () => {
await relationsHelper.emitFetchCurrent();
});
it("should emit the server side events", () => {
expect(onAdd).toHaveBeenCalledWith(relatedEvent1);
expect(onAdd).toHaveBeenCalledWith(relatedEvent2);
expect(onAdd).toHaveBeenCalledWith(relatedEvent3);
});
});
});
describe("when there is an event with relations", () => { describe("when there is an event with relations", () => {
beforeEach(() => { beforeEach(() => {
mocked(room.getUnfilteredTimelineSet).mockReturnValue(timelineSet); mocked(room.getUnfilteredTimelineSet).mockReturnValue(timelineSet);

View file

@ -20,10 +20,7 @@ import { SimpleObservable } from "matrix-widget-api";
import { Playback, PlaybackState } from "../../src/audio/Playback"; import { Playback, PlaybackState } from "../../src/audio/Playback";
import { PlaybackClock } from "../../src/audio/PlaybackClock"; import { PlaybackClock } from "../../src/audio/PlaybackClock";
import { UPDATE_EVENT } from "../../src/stores/AsyncStore"; import { UPDATE_EVENT } from "../../src/stores/AsyncStore";
import { PublicInterface } from "../@types/common";
type PublicInterface<T> = {
[P in keyof T]: T[P];
};
export const createTestPlayback = (): Playback => { export const createTestPlayback = (): Playback => {
const eventEmitter = new EventEmitter(); const eventEmitter = new EventEmitter();

View file

@ -25,3 +25,4 @@ export * from './call';
export * from './wrappers'; export * from './wrappers';
export * from './utilities'; export * from './utilities';
export * from './date'; export * from './date';
export * from './relations';

View file

@ -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 { Relations } from "matrix-js-sdk/src/models/relations";
import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { PublicInterface } from "../@types/common";
export const mkRelations = (): Relations => {
return {
} as PublicInterface<Relations> as Relations;
};
export const mkRelationsContainer = (): RelationsContainer => {
return {
aggregateChildEvent: jest.fn(),
aggregateParentEvent: jest.fn(),
getAllChildEventsForEvent: jest.fn(),
getChildEventsForEvent: jest.fn(),
} as PublicInterface<RelationsContainer> as RelationsContainer;
};

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { ReactElement } from "react";
import { act, render, screen } from "@testing-library/react"; import { act, render, screen } from "@testing-library/react";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
@ -31,6 +31,8 @@ import {
} from "../../../src/voice-broadcast"; } from "../../../src/voice-broadcast";
import { stubClient } from "../../test-utils"; import { stubClient } from "../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils"; import { mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils";
import { MediaEventHelper } from "../../../src/utils/MediaEventHelper";
import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks";
jest.mock("../../../src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody", () => ({ jest.mock("../../../src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody", () => ({
VoiceBroadcastRecordingBody: jest.fn(), VoiceBroadcastRecordingBody: jest.fn(),
@ -40,8 +42,13 @@ jest.mock("../../../src/voice-broadcast/components/molecules/VoiceBroadcastPlayb
VoiceBroadcastPlaybackBody: jest.fn(), VoiceBroadcastPlaybackBody: jest.fn(),
})); }));
jest.mock("../../../src/utils/permalinks/Permalinks");
jest.mock("../../../src/utils/MediaEventHelper");
describe("VoiceBroadcastBody", () => { describe("VoiceBroadcastBody", () => {
const roomId = "!room:example.com"; const roomId = "!room:example.com";
let userId: string;
let deviceId: string;
let client: MatrixClient; let client: MatrixClient;
let room: Room; let room: Room;
let infoEvent: MatrixEvent; let infoEvent: MatrixEvent;
@ -52,62 +59,75 @@ describe("VoiceBroadcastBody", () => {
const renderVoiceBroadcast = () => { const renderVoiceBroadcast = () => {
render(<VoiceBroadcastBody render(<VoiceBroadcastBody
mxEvent={infoEvent} mxEvent={infoEvent}
mediaEventHelper={null} mediaEventHelper={new MediaEventHelper(infoEvent)}
onHeightChanged={() => {}} onHeightChanged={() => {}}
onMessageAllowed={() => {}} onMessageAllowed={() => {}}
permalinkCreator={null} permalinkCreator={new RoomPermalinkCreator(room)}
/>); />);
testRecording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(infoEvent, client); testRecording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(infoEvent, client);
}; };
beforeEach(() => { beforeEach(() => {
client = stubClient(); client = stubClient();
room = new Room(roomId, client, client.getUserId()); userId = client.getUserId() || "";
mocked(client.getRoom).mockImplementation((getRoomId: string) => { deviceId = client.getDeviceId() || "";
mocked(client.relations).mockClear();
mocked(client.relations).mockResolvedValue({ events: [] });
room = new Room(roomId, client, userId);
mocked(client.getRoom).mockImplementation((getRoomId?: string) => {
if (getRoomId === roomId) return room; if (getRoomId === roomId) return room;
return null;
}); });
infoEvent = mkVoiceBroadcastInfoStateEvent( infoEvent = mkVoiceBroadcastInfoStateEvent(
roomId, roomId,
VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Started,
client.getUserId(), userId,
client.getDeviceId(), deviceId,
); );
stoppedEvent = mkVoiceBroadcastInfoStateEvent( stoppedEvent = mkVoiceBroadcastInfoStateEvent(
roomId, roomId,
VoiceBroadcastInfoState.Stopped, VoiceBroadcastInfoState.Stopped,
client.getUserId(), userId,
client.getDeviceId(), deviceId,
infoEvent, infoEvent,
); );
room.addEventsToTimeline([infoEvent], true, room.getLiveTimeline()); room.addEventsToTimeline([infoEvent], true, room.getLiveTimeline());
testRecording = new VoiceBroadcastRecording(infoEvent, client); testRecording = new VoiceBroadcastRecording(infoEvent, client);
testPlayback = new VoiceBroadcastPlayback(infoEvent, client); testPlayback = new VoiceBroadcastPlayback(infoEvent, client);
mocked(VoiceBroadcastRecordingBody).mockImplementation(({ recording }) => { mocked(VoiceBroadcastRecordingBody).mockImplementation(({ recording }): ReactElement | null => {
if (testRecording === recording) { if (testRecording === recording) {
return <div data-testid="voice-broadcast-recording-body" />; return <div data-testid="voice-broadcast-recording-body" />;
} }
return null;
}); });
mocked(VoiceBroadcastPlaybackBody).mockImplementation(({ playback }) => { mocked(VoiceBroadcastPlaybackBody).mockImplementation(({ playback }): ReactElement | null => {
if (testPlayback === playback) { if (testPlayback === playback) {
return <div data-testid="voice-broadcast-playback-body" />; return <div data-testid="voice-broadcast-playback-body" />;
} }
return null;
}); });
jest.spyOn(VoiceBroadcastRecordingsStore.instance(), "getByInfoEvent").mockImplementation( jest.spyOn(VoiceBroadcastRecordingsStore.instance(), "getByInfoEvent").mockImplementation(
(getEvent: MatrixEvent, getClient: MatrixClient) => { (getEvent: MatrixEvent, getClient: MatrixClient): VoiceBroadcastRecording => {
if (getEvent === infoEvent && getClient === client) { if (getEvent === infoEvent && getClient === client) {
return testRecording; return testRecording;
} }
throw new Error("unexpected event");
}, },
); );
jest.spyOn(VoiceBroadcastPlaybacksStore.instance(), "getByInfoEvent").mockImplementation( jest.spyOn(VoiceBroadcastPlaybacksStore.instance(), "getByInfoEvent").mockImplementation(
(getEvent: MatrixEvent) => { (getEvent: MatrixEvent): VoiceBroadcastPlayback => {
if (getEvent === infoEvent) { if (getEvent === infoEvent) {
return testPlayback; return testPlayback;
} }
throw new Error("unexpected event");
}, },
); );
}); });

View file

@ -48,6 +48,9 @@ describe("VoiceBroadcastPlaybackBody", () => {
beforeAll(() => { beforeAll(() => {
client = stubClient(); client = stubClient();
mocked(client.relations).mockClear();
mocked(client.relations).mockResolvedValue({ events: [] });
infoEvent = mkVoiceBroadcastInfoStateEvent( infoEvent = mkVoiceBroadcastInfoStateEvent(
roomId, roomId,
VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Started,

View file

@ -15,24 +15,21 @@ limitations under the License.
*/ */
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { EventType, MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { Playback, PlaybackState } from "../../../src/audio/Playback"; import { Playback, PlaybackState } from "../../../src/audio/Playback";
import { PlaybackManager } from "../../../src/audio/PlaybackManager"; import { PlaybackManager } from "../../../src/audio/PlaybackManager";
import { getReferenceRelationsForEvent } from "../../../src/events";
import { RelationsHelperEvent } from "../../../src/events/RelationsHelper"; import { RelationsHelperEvent } from "../../../src/events/RelationsHelper";
import { MediaEventHelper } from "../../../src/utils/MediaEventHelper"; import { MediaEventHelper } from "../../../src/utils/MediaEventHelper";
import { import {
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState, VoiceBroadcastInfoState,
VoiceBroadcastPlayback, VoiceBroadcastPlayback,
VoiceBroadcastPlaybackEvent, VoiceBroadcastPlaybackEvent,
VoiceBroadcastPlaybackState, VoiceBroadcastPlaybackState,
} from "../../../src/voice-broadcast"; } from "../../../src/voice-broadcast";
import { mkEvent, stubClient } from "../../test-utils"; import { flushPromises, stubClient } from "../../test-utils";
import { createTestPlayback } from "../../test-utils/audio"; import { createTestPlayback } from "../../test-utils/audio";
import { mkVoiceBroadcastChunkEvent } from "../utils/test-utils"; import { mkVoiceBroadcastChunkEvent, mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils";
jest.mock("../../../src/events/getReferenceRelationsForEvent", () => ({ jest.mock("../../../src/events/getReferenceRelationsForEvent", () => ({
getReferenceRelationsForEvent: jest.fn(), getReferenceRelationsForEvent: jest.fn(),
@ -44,6 +41,7 @@ jest.mock("../../../src/utils/MediaEventHelper", () => ({
describe("VoiceBroadcastPlayback", () => { describe("VoiceBroadcastPlayback", () => {
const userId = "@user:example.com"; const userId = "@user:example.com";
let deviceId: string;
const roomId = "!room:example.com"; const roomId = "!room:example.com";
let client: MatrixClient; let client: MatrixClient;
let infoEvent: MatrixEvent; let infoEvent: MatrixEvent;
@ -98,7 +96,7 @@ describe("VoiceBroadcastPlayback", () => {
const mkChunkHelper = (data: ArrayBuffer): MediaEventHelper => { const mkChunkHelper = (data: ArrayBuffer): MediaEventHelper => {
return { return {
sourceBlob: { sourceBlob: {
cachedValue: null, cachedValue: new Blob(),
done: false, done: false,
value: { value: {
// @ts-ignore // @ts-ignore
@ -109,32 +107,31 @@ describe("VoiceBroadcastPlayback", () => {
}; };
const mkInfoEvent = (state: VoiceBroadcastInfoState) => { const mkInfoEvent = (state: VoiceBroadcastInfoState) => {
return mkEvent({ return mkVoiceBroadcastInfoStateEvent(
event: true, roomId,
type: VoiceBroadcastInfoEventType, state,
user: userId, userId,
room: roomId, deviceId,
content: { );
state,
},
});
}; };
const mkPlayback = () => { const mkPlayback = async () => {
const playback = new VoiceBroadcastPlayback(infoEvent, client); const playback = new VoiceBroadcastPlayback(infoEvent, client);
jest.spyOn(playback, "removeAllListeners"); jest.spyOn(playback, "removeAllListeners");
playback.on(VoiceBroadcastPlaybackEvent.StateChanged, onStateChanged); playback.on(VoiceBroadcastPlaybackEvent.StateChanged, onStateChanged);
await flushPromises();
return playback; return playback;
}; };
const setUpChunkEvents = (chunkEvents: MatrixEvent[]) => { const setUpChunkEvents = (chunkEvents: MatrixEvent[]) => {
const relations = new Relations(RelationType.Reference, EventType.RoomMessage, client); mocked(client.relations).mockResolvedValueOnce({
jest.spyOn(relations, "getRelations").mockReturnValue(chunkEvents); events: chunkEvents,
mocked(getReferenceRelationsForEvent).mockReturnValue(relations); });
}; };
beforeAll(() => { beforeAll(() => {
client = stubClient(); client = stubClient();
deviceId = client.getDeviceId() || "";
chunk1Event = mkVoiceBroadcastChunkEvent(userId, roomId, chunk1Length, 1); chunk1Event = mkVoiceBroadcastChunkEvent(userId, roomId, chunk1Length, 1);
chunk2Event = mkVoiceBroadcastChunkEvent(userId, roomId, chunk2Length, 2); chunk2Event = mkVoiceBroadcastChunkEvent(userId, roomId, chunk2Length, 2);
@ -153,6 +150,8 @@ describe("VoiceBroadcastPlayback", () => {
if (buffer === chunk1Data) return chunk1Playback; if (buffer === chunk1Data) return chunk1Playback;
if (buffer === chunk2Data) return chunk2Playback; if (buffer === chunk2Data) return chunk2Playback;
if (buffer === chunk3Data) return chunk3Playback; if (buffer === chunk3Data) return chunk3Playback;
throw new Error("unexpected buffer");
}, },
); );
@ -168,11 +167,17 @@ describe("VoiceBroadcastPlayback", () => {
onStateChanged = jest.fn(); onStateChanged = jest.fn();
}); });
afterEach(() => {
playback.destroy();
});
describe(`when there is a ${VoiceBroadcastInfoState.Resumed} broadcast without chunks yet`, () => { describe(`when there is a ${VoiceBroadcastInfoState.Resumed} broadcast without chunks yet`, () => {
beforeEach(() => { beforeEach(async () => {
infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed); // info relation
playback = mkPlayback(); mocked(client.relations).mockResolvedValueOnce({ events: [] });
setUpChunkEvents([]); setUpChunkEvents([]);
infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed);
playback = await mkPlayback();
}); });
describe("and calling start", () => { describe("and calling start", () => {
@ -227,10 +232,12 @@ describe("VoiceBroadcastPlayback", () => {
}); });
describe(`when there is a ${VoiceBroadcastInfoState.Resumed} voice broadcast with some chunks`, () => { describe(`when there is a ${VoiceBroadcastInfoState.Resumed} voice broadcast with some chunks`, () => {
beforeEach(() => { beforeEach(async () => {
infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed); // info relation
playback = mkPlayback(); mocked(client.relations).mockResolvedValueOnce({ events: [] });
setUpChunkEvents([chunk2Event, chunk1Event]); setUpChunkEvents([chunk2Event, chunk1Event]);
infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed);
playback = await mkPlayback();
}); });
describe("and calling start", () => { describe("and calling start", () => {
@ -267,158 +274,153 @@ describe("VoiceBroadcastPlayback", () => {
}); });
describe("when there is a stopped voice broadcast", () => { describe("when there is a stopped voice broadcast", () => {
beforeEach(() => { beforeEach(async () => {
setUpChunkEvents([chunk2Event, chunk1Event]);
infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Stopped); infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Stopped);
playback = mkPlayback(); playback = await mkPlayback();
}); });
describe("and there are some chunks", () => { it("should expose the info event", () => {
beforeEach(() => { expect(playback.infoEvent).toBe(infoEvent);
setUpChunkEvents([chunk2Event, chunk1Event]); });
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped);
describe("and calling start", () => {
startPlayback();
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing);
it("should play the chunks beginning with the first one", () => {
// assert that the first chunk is being played
expect(chunk1Playback.play).toHaveBeenCalled();
expect(chunk2Playback.play).not.toHaveBeenCalled();
}); });
it("should expose the info event", () => { describe("and the chunk playback progresses", () => {
expect(playback.infoEvent).toBe(infoEvent); beforeEach(() => {
chunk1Playback.clockInfo.liveData.update([11]);
});
it("should update the time", () => {
expect(playback.timeSeconds).toBe(11);
});
}); });
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); describe("and skipping to the middle of the second chunk", () => {
const middleOfSecondChunk = (chunk1Length + (chunk2Length / 2)) / 1000;
describe("and calling start", () => { beforeEach(async () => {
startPlayback(); await playback.skipTo(middleOfSecondChunk);
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing);
it("should play the chunks beginning with the first one", () => {
// assert that the first chunk is being played
expect(chunk1Playback.play).toHaveBeenCalled();
expect(chunk2Playback.play).not.toHaveBeenCalled();
}); });
describe("and the chunk playback progresses", () => { it("should play the second chunk", () => {
beforeEach(() => { expect(chunk1Playback.stop).toHaveBeenCalled();
chunk1Playback.clockInfo.liveData.update([11]); expect(chunk2Playback.play).toHaveBeenCalled();
});
it("should update the time", () => {
expect(playback.timeSeconds).toBe(11);
});
}); });
describe("and skipping to the middle of the second chunk", () => { it("should update the time", () => {
const middleOfSecondChunk = (chunk1Length + (chunk2Length / 2)) / 1000; expect(playback.timeSeconds).toBe(middleOfSecondChunk);
});
describe("and skipping to the start", () => {
beforeEach(async () => { beforeEach(async () => {
await playback.skipTo(middleOfSecondChunk); await playback.skipTo(0);
}); });
it("should play the second chunk", () => { it("should play the second chunk", () => {
expect(chunk1Playback.stop).toHaveBeenCalled(); expect(chunk1Playback.play).toHaveBeenCalled();
expect(chunk2Playback.play).toHaveBeenCalled(); expect(chunk2Playback.stop).toHaveBeenCalled();
}); });
it("should update the time", () => { it("should update the time", () => {
expect(playback.timeSeconds).toBe(middleOfSecondChunk); expect(playback.timeSeconds).toBe(0);
});
describe("and skipping to the start", () => {
beforeEach(async () => {
await playback.skipTo(0);
});
it("should play the second chunk", () => {
expect(chunk1Playback.play).toHaveBeenCalled();
expect(chunk2Playback.stop).toHaveBeenCalled();
});
it("should update the time", () => {
expect(playback.timeSeconds).toBe(0);
});
});
});
describe("and the first chunk ends", () => {
beforeEach(() => {
chunk1Playback.emit(PlaybackState.Stopped);
});
it("should play until the end", () => {
// assert that the second chunk is being played
expect(chunk2Playback.play).toHaveBeenCalled();
// simulate end of second chunk
chunk2Playback.emit(PlaybackState.Stopped);
// assert that the entire playback is now in stopped state
expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Stopped);
});
});
describe("and calling pause", () => {
pausePlayback();
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused);
itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Paused);
});
describe("and calling stop", () => {
stopPlayback();
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped);
});
describe("and calling destroy", () => {
beforeEach(() => {
playback.destroy();
});
it("should call removeAllListeners", () => {
expect(playback.removeAllListeners).toHaveBeenCalled();
});
it("should call destroy on the playbacks", () => {
expect(chunk1Playback.destroy).toHaveBeenCalled();
expect(chunk2Playback.destroy).toHaveBeenCalled();
}); });
}); });
}); });
describe("and calling toggle for the first time", () => { describe("and the first chunk ends", () => {
beforeEach(async () => { beforeEach(() => {
await playback.toggle(); chunk1Playback.emit(PlaybackState.Stopped);
}); });
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); it("should play until the end", () => {
// assert that the second chunk is being played
expect(chunk2Playback.play).toHaveBeenCalled();
describe("and calling toggle a second time", () => { // simulate end of second chunk
beforeEach(async () => { chunk2Playback.emit(PlaybackState.Stopped);
await playback.toggle();
});
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); // assert that the entire playback is now in stopped state
expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Stopped);
describe("and calling toggle a third time", () => {
beforeEach(async () => {
await playback.toggle();
});
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing);
});
}); });
}); });
describe("and calling pause", () => {
pausePlayback();
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused);
itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Paused);
});
describe("and calling stop", () => { describe("and calling stop", () => {
stopPlayback(); stopPlayback();
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped);
});
describe("and calling toggle", () => { describe("and calling destroy", () => {
beforeEach(() => {
playback.destroy();
});
it("should call removeAllListeners", () => {
expect(playback.removeAllListeners).toHaveBeenCalled();
});
it("should call destroy on the playbacks", () => {
expect(chunk1Playback.destroy).toHaveBeenCalled();
expect(chunk2Playback.destroy).toHaveBeenCalled();
});
});
});
describe("and calling toggle for the first time", () => {
beforeEach(async () => {
await playback.toggle();
});
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing);
describe("and calling toggle a second time", () => {
beforeEach(async () => {
await playback.toggle();
});
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused);
describe("and calling toggle a third time", () => {
beforeEach(async () => { beforeEach(async () => {
mocked(onStateChanged).mockReset();
await playback.toggle(); await playback.toggle();
}); });
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing);
itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Playing);
}); });
}); });
}); });
describe("and calling stop", () => {
stopPlayback();
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped);
describe("and calling toggle", () => {
beforeEach(async () => {
mocked(onStateChanged).mockReset();
await playback.toggle();
});
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing);
itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Playing);
});
});
}); });
}); });

View file

@ -35,6 +35,8 @@ import { mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils";
describe("VoiceBroadcastPlaybacksStore", () => { describe("VoiceBroadcastPlaybacksStore", () => {
const roomId = "!room:example.com"; const roomId = "!room:example.com";
let client: MatrixClient; let client: MatrixClient;
let userId: string;
let deviceId: string;
let room: Room; let room: Room;
let infoEvent1: MatrixEvent; let infoEvent1: MatrixEvent;
let infoEvent2: MatrixEvent; let infoEvent2: MatrixEvent;
@ -45,24 +47,31 @@ describe("VoiceBroadcastPlaybacksStore", () => {
beforeEach(() => { beforeEach(() => {
client = stubClient(); client = stubClient();
userId = client.getUserId() || "";
deviceId = client.getDeviceId() || "";
mocked(client.relations).mockClear();
mocked(client.relations).mockResolvedValue({ events: [] });
room = mkStubRoom(roomId, "test room", client); room = mkStubRoom(roomId, "test room", client);
mocked(client.getRoom).mockImplementation((roomId: string) => { mocked(client.getRoom).mockImplementation((roomId: string): Room | null => {
if (roomId === room.roomId) { if (roomId === room.roomId) {
return room; return room;
} }
return null;
}); });
infoEvent1 = mkVoiceBroadcastInfoStateEvent( infoEvent1 = mkVoiceBroadcastInfoStateEvent(
roomId, roomId,
VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Started,
client.getUserId(), userId,
client.getDeviceId(), deviceId,
); );
infoEvent2 = mkVoiceBroadcastInfoStateEvent( infoEvent2 = mkVoiceBroadcastInfoStateEvent(
roomId, roomId,
VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Started,
client.getUserId(), userId,
client.getDeviceId(), deviceId,
); );
playback1 = new VoiceBroadcastPlayback(infoEvent1, client); playback1 = new VoiceBroadcastPlayback(infoEvent1, client);
jest.spyOn(playback1, "off"); jest.spyOn(playback1, "off");