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>
implements IDestroyable {
private relations?: Relations;
private eventId: string;
private roomId: string;
public constructor(
private event: MatrixEvent,
@ -46,6 +48,21 @@ export class RelationsHelper
private client: MatrixClient,
) {
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();
}
@ -73,7 +90,7 @@ export class RelationsHelper
private setRelations(): void {
const room = this.client.getRoom(this.event.getRoomId());
this.relations = room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent(
this.event.getId(),
this.eventId,
this.relationType,
this.relationEventType,
);
@ -87,6 +104,32 @@ export class RelationsHelper
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 {
this.removeAllListeners();
this.event.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated);

View File

@ -32,7 +32,6 @@ import { MediaEventHelper } from "../../utils/MediaEventHelper";
import { IDestroyable } from "../../utils/IDestroyable";
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "..";
import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper";
import { getReferenceRelationsForEvent } from "../../events";
import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents";
export enum VoiceBroadcastPlaybackState {
@ -89,15 +88,27 @@ export class VoiceBroadcastPlayback
this.setUpRelationsHelper();
}
private setUpRelationsHelper(): void {
private async setUpRelationsHelper(): Promise<void> {
this.infoRelationHelper = new RelationsHelper(
this.infoEvent,
RelationType.Reference,
VoiceBroadcastInfoEventType,
this.client,
);
this.infoRelationHelper.getCurrent().forEach(this.addInfoEvent);
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.infoEvent,
@ -106,8 +117,16 @@ export class VoiceBroadcastPlayback
this.client,
);
this.chunkRelationHelper.on(RelationsHelperEvent.Add, this.addChunkEvent);
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> => {
const eventId = event.getId();
@ -150,23 +169,18 @@ export class VoiceBroadcastPlayback
this.setInfoState(state);
};
private async loadChunks(): Promise<void> {
const relations = getReferenceRelationsForEvent(this.infoEvent, EventType.RoomMessage, this.client);
const chunkEvents = relations?.getRelations();
private async enqueueChunks(): Promise<void> {
const promises = this.chunkEvents.getEvents().reduce((promises, event: MatrixEvent) => {
if (!this.playbacks.has(event.getId() || "")) {
promises.push(this.enqueueChunk(event));
}
return promises;
}, [] as Promise<void>[]);
if (!chunkEvents) {
return;
await Promise.all(promises);
}
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();
if (!eventId) {
@ -317,10 +331,7 @@ export class VoiceBroadcastPlayback
}
public async start(): Promise<void> {
if (this.playbacks.size === 0) {
await this.loadChunks();
}
await this.enqueueChunks();
const chunkEvents = this.chunkEvents.getEvents();
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 { RelationsHelper, RelationsHelperEvent } from "../../src/events/RelationsHelper";
import { mkEvent, mkStubRoom, stubClient } from "../test-utils";
import { mkEvent, mkRelationsContainer, mkStubRoom, stubClient } from "../test-utils";
describe("RelationsHelper", () => {
const roomId = "!room:example.com";
let userId: string;
let event: MatrixEvent;
let relatedEvent1: MatrixEvent;
let relatedEvent2: MatrixEvent;
let relatedEvent3: MatrixEvent;
let room: Room;
let client: MatrixClient;
let relationsHelper: RelationsHelper;
@ -46,47 +48,81 @@ describe("RelationsHelper", () => {
beforeEach(() => {
client = stubClient();
userId = client.getUserId() || "";
mocked(client.relations).mockClear();
room = mkStubRoom(roomId, "test room", client);
mocked(client.getRoom).mockImplementation((getRoomId: string) => {
mocked(client.getRoom).mockImplementation((getRoomId?: string) => {
if (getRoomId === roomId) {
return room;
}
return null;
});
event = mkEvent({
event: true,
type: EventType.RoomMessage,
room: roomId,
user: client.getUserId(),
user: userId,
content: {},
});
relatedEvent1 = mkEvent({
event: true,
type: EventType.RoomMessage,
room: roomId,
user: client.getUserId(),
content: {},
user: userId,
content: { relatedEvent: 1 },
});
relatedEvent2 = mkEvent({
event: true,
type: EventType.RoomMessage,
room: roomId,
user: client.getUserId(),
content: {},
user: userId,
content: { relatedEvent: 2 },
});
relatedEvent3 = mkEvent({
event: true,
type: EventType.RoomMessage,
room: roomId,
user: userId,
content: { relatedEvent: 3 },
});
onAdd = jest.fn();
relationsContainer = mkRelationsContainer();
// 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),
off: jest.fn(),
} as unknown as Relations;
timelineSet = {
relations: relationsContainer,
} 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", () => {
beforeEach(() => {
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", () => {
beforeEach(() => {
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 { PlaybackClock } from "../../src/audio/PlaybackClock";
import { UPDATE_EVENT } from "../../src/stores/AsyncStore";
type PublicInterface<T> = {
[P in keyof T]: T[P];
};
import { PublicInterface } from "../@types/common";
export const createTestPlayback = (): Playback => {
const eventEmitter = new EventEmitter();

View File

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

View File

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

View File

@ -15,24 +15,21 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { EventType, MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { Playback, PlaybackState } from "../../../src/audio/Playback";
import { PlaybackManager } from "../../../src/audio/PlaybackManager";
import { getReferenceRelationsForEvent } from "../../../src/events";
import { RelationsHelperEvent } from "../../../src/events/RelationsHelper";
import { MediaEventHelper } from "../../../src/utils/MediaEventHelper";
import {
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
VoiceBroadcastPlayback,
VoiceBroadcastPlaybackEvent,
VoiceBroadcastPlaybackState,
} from "../../../src/voice-broadcast";
import { mkEvent, stubClient } from "../../test-utils";
import { flushPromises, stubClient } from "../../test-utils";
import { createTestPlayback } from "../../test-utils/audio";
import { mkVoiceBroadcastChunkEvent } from "../utils/test-utils";
import { mkVoiceBroadcastChunkEvent, mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils";
jest.mock("../../../src/events/getReferenceRelationsForEvent", () => ({
getReferenceRelationsForEvent: jest.fn(),
@ -44,6 +41,7 @@ jest.mock("../../../src/utils/MediaEventHelper", () => ({
describe("VoiceBroadcastPlayback", () => {
const userId = "@user:example.com";
let deviceId: string;
const roomId = "!room:example.com";
let client: MatrixClient;
let infoEvent: MatrixEvent;
@ -98,7 +96,7 @@ describe("VoiceBroadcastPlayback", () => {
const mkChunkHelper = (data: ArrayBuffer): MediaEventHelper => {
return {
sourceBlob: {
cachedValue: null,
cachedValue: new Blob(),
done: false,
value: {
// @ts-ignore
@ -109,32 +107,31 @@ describe("VoiceBroadcastPlayback", () => {
};
const mkInfoEvent = (state: VoiceBroadcastInfoState) => {
return mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
user: userId,
room: roomId,
content: {
return mkVoiceBroadcastInfoStateEvent(
roomId,
state,
},
});
userId,
deviceId,
);
};
const mkPlayback = () => {
const mkPlayback = async () => {
const playback = new VoiceBroadcastPlayback(infoEvent, client);
jest.spyOn(playback, "removeAllListeners");
playback.on(VoiceBroadcastPlaybackEvent.StateChanged, onStateChanged);
await flushPromises();
return playback;
};
const setUpChunkEvents = (chunkEvents: MatrixEvent[]) => {
const relations = new Relations(RelationType.Reference, EventType.RoomMessage, client);
jest.spyOn(relations, "getRelations").mockReturnValue(chunkEvents);
mocked(getReferenceRelationsForEvent).mockReturnValue(relations);
mocked(client.relations).mockResolvedValueOnce({
events: chunkEvents,
});
};
beforeAll(() => {
client = stubClient();
deviceId = client.getDeviceId() || "";
chunk1Event = mkVoiceBroadcastChunkEvent(userId, roomId, chunk1Length, 1);
chunk2Event = mkVoiceBroadcastChunkEvent(userId, roomId, chunk2Length, 2);
@ -153,6 +150,8 @@ describe("VoiceBroadcastPlayback", () => {
if (buffer === chunk1Data) return chunk1Playback;
if (buffer === chunk2Data) return chunk2Playback;
if (buffer === chunk3Data) return chunk3Playback;
throw new Error("unexpected buffer");
},
);
@ -168,11 +167,17 @@ describe("VoiceBroadcastPlayback", () => {
onStateChanged = jest.fn();
});
afterEach(() => {
playback.destroy();
});
describe(`when there is a ${VoiceBroadcastInfoState.Resumed} broadcast without chunks yet`, () => {
beforeEach(() => {
infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed);
playback = mkPlayback();
beforeEach(async () => {
// info relation
mocked(client.relations).mockResolvedValueOnce({ events: [] });
setUpChunkEvents([]);
infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed);
playback = await mkPlayback();
});
describe("and calling start", () => {
@ -227,10 +232,12 @@ describe("VoiceBroadcastPlayback", () => {
});
describe(`when there is a ${VoiceBroadcastInfoState.Resumed} voice broadcast with some chunks`, () => {
beforeEach(() => {
infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed);
playback = mkPlayback();
beforeEach(async () => {
// info relation
mocked(client.relations).mockResolvedValueOnce({ events: [] });
setUpChunkEvents([chunk2Event, chunk1Event]);
infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed);
playback = await mkPlayback();
});
describe("and calling start", () => {
@ -267,14 +274,10 @@ describe("VoiceBroadcastPlayback", () => {
});
describe("when there is a stopped voice broadcast", () => {
beforeEach(() => {
infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Stopped);
playback = mkPlayback();
});
describe("and there are some chunks", () => {
beforeEach(() => {
beforeEach(async () => {
setUpChunkEvents([chunk2Event, chunk1Event]);
infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Stopped);
playback = await mkPlayback();
});
it("should expose the info event", () => {
@ -420,5 +423,4 @@ describe("VoiceBroadcastPlayback", () => {
});
});
});
});
});

View File

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