Merge matrix-react-sdk into element-web

Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-10-15 14:57:26 +01:00
commit f0ee7f7905
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
3265 changed files with 484599 additions and 699 deletions

View file

@ -0,0 +1,142 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { VoiceBroadcastChunkEvents } from "../../../../src/voice-broadcast/utils/VoiceBroadcastChunkEvents";
import { mkVoiceBroadcastChunkEvent } from "./test-utils";
describe("VoiceBroadcastChunkEvents", () => {
const userId = "@user:example.com";
const roomId = "!room:example.com";
const txnId = "txn-id";
let eventSeq1Time1: MatrixEvent;
let eventSeq2Time4: MatrixEvent;
let eventSeq3Time2: MatrixEvent;
let eventSeq3Time2T: MatrixEvent;
let eventSeq4Time1: MatrixEvent;
let eventSeqUTime3: MatrixEvent;
let eventSeq2Time4Dup: MatrixEvent;
let chunkEvents: VoiceBroadcastChunkEvents;
beforeEach(() => {
eventSeq1Time1 = mkVoiceBroadcastChunkEvent("info1", userId, roomId, 7, 1, 1);
eventSeq2Time4 = mkVoiceBroadcastChunkEvent("info1", userId, roomId, 23, 2, 4);
eventSeq2Time4Dup = mkVoiceBroadcastChunkEvent("info1", userId, roomId, 3141, 2, 4);
jest.spyOn(eventSeq2Time4Dup, "getId").mockReturnValue(eventSeq2Time4.getId());
eventSeq3Time2 = mkVoiceBroadcastChunkEvent("info1", userId, roomId, 42, 3, 2);
eventSeq3Time2.setTxnId(txnId);
eventSeq3Time2T = mkVoiceBroadcastChunkEvent("info1", userId, roomId, 42, 3, 2);
eventSeq3Time2T.setTxnId(txnId);
eventSeq4Time1 = mkVoiceBroadcastChunkEvent("info1", userId, roomId, 69, 4, 1);
eventSeqUTime3 = mkVoiceBroadcastChunkEvent("info1", userId, roomId, 314, undefined, 3);
chunkEvents = new VoiceBroadcastChunkEvents();
});
describe("when adding events that all have a sequence", () => {
beforeEach(() => {
chunkEvents.addEvent(eventSeq2Time4);
chunkEvents.addEvent(eventSeq1Time1);
chunkEvents.addEvents([eventSeq4Time1, eventSeq2Time4Dup, eventSeq3Time2]);
});
it("should provide the events sort by sequence", () => {
expect(chunkEvents.getEvents()).toEqual([
eventSeq1Time1,
eventSeq2Time4Dup,
eventSeq3Time2,
eventSeq4Time1,
]);
});
it("getNumberOfEvents should return 4", () => {
expect(chunkEvents.getNumberOfEvents()).toBe(4);
});
it("getLength should return the total length of all chunks", () => {
expect(chunkEvents.getLength()).toBe(3259);
});
it("getLengthTo(first event) should return 0", () => {
expect(chunkEvents.getLengthTo(eventSeq1Time1)).toBe(0);
});
it("getLengthTo(some event) should return the time excl. that event", () => {
expect(chunkEvents.getLengthTo(eventSeq3Time2)).toBe(7 + 3141);
});
it("getLengthTo(last event) should return the time excl. that event", () => {
expect(chunkEvents.getLengthTo(eventSeq4Time1)).toBe(7 + 3141 + 42);
});
it("should return the expected next chunk", () => {
expect(chunkEvents.getNext(eventSeq2Time4Dup)).toBe(eventSeq3Time2);
});
it("should return undefined for next last chunk", () => {
expect(chunkEvents.getNext(eventSeq4Time1)).toBeUndefined();
});
it("findByTime(0) should return the first chunk", () => {
expect(chunkEvents.findByTime(0)).toBe(eventSeq1Time1);
});
it("findByTime(some time) should return the chunk with this time", () => {
expect(chunkEvents.findByTime(7 + 3141 + 21)).toBe(eventSeq3Time2);
});
it("findByTime(entire duration) should return the last chunk", () => {
expect(chunkEvents.findByTime(7 + 3141 + 42 + 69)).toBe(eventSeq4Time1);
});
describe("and adding an event with a known transaction Id", () => {
beforeEach(() => {
chunkEvents.addEvent(eventSeq3Time2T);
});
it("should replace the previous event", () => {
expect(chunkEvents.getEvents()).toEqual([
eventSeq1Time1,
eventSeq2Time4Dup,
eventSeq3Time2T,
eventSeq4Time1,
]);
expect(chunkEvents.getNumberOfEvents()).toBe(4);
});
});
});
describe("when adding events where at least one does not have a sequence", () => {
beforeEach(() => {
chunkEvents.addEvent(eventSeq2Time4);
chunkEvents.addEvent(eventSeq1Time1);
chunkEvents.addEvents([eventSeq4Time1, eventSeqUTime3, eventSeq2Time4Dup, eventSeq3Time2]);
});
it("should provide the events sort by timestamp without duplicates", () => {
expect(chunkEvents.getEvents()).toEqual([
eventSeq1Time1,
eventSeq4Time1,
eventSeq3Time2,
eventSeqUTime3,
eventSeq2Time4Dup,
]);
expect(chunkEvents.getNumberOfEvents()).toBe(5);
});
describe("getSequenceForEvent", () => {
it("should return the sequence if provided by the event", () => {
expect(chunkEvents.getSequenceForEvent(eventSeq3Time2)).toBe(3);
});
it("should return the index if no sequence provided by event", () => {
expect(chunkEvents.getSequenceForEvent(eventSeqUTime3)).toBe(4);
});
});
});
});

View file

@ -0,0 +1,179 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { ClientEvent, MatrixClient, MatrixEvent, RelationType, Room, SyncState } from "matrix-js-sdk/src/matrix";
import {
VoiceBroadcastInfoEventContent,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
VoiceBroadcastResumer,
} from "../../../../src/voice-broadcast";
import { stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
describe("VoiceBroadcastResumer", () => {
const roomId = "!room:example.com";
let client: MatrixClient;
let room: Room;
let resumer: VoiceBroadcastResumer;
let startedInfoEvent: MatrixEvent;
let pausedInfoEvent: MatrixEvent;
const itShouldNotSendAStateEvent = (): void => {
it("should not send a state event", () => {
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
};
const itShouldSendAStoppedStateEvent = (): void => {
it("should send a stopped state event", () => {
expect(client.sendStateEvent).toHaveBeenCalledWith(
startedInfoEvent.getRoomId(),
VoiceBroadcastInfoEventType,
{
"device_id": client.getDeviceId(),
"state": VoiceBroadcastInfoState.Stopped,
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: startedInfoEvent.getId(),
},
} as VoiceBroadcastInfoEventContent,
client.getUserId()!,
);
});
};
const itShouldDeregisterFromTheClient = () => {
it("should deregister from the client", () => {
expect(client.off).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function));
});
};
beforeEach(() => {
client = stubClient();
jest.spyOn(client, "off");
room = new Room(roomId, client, client.getUserId()!);
mocked(client.getRoom).mockImplementation((getRoomId: string | undefined) => {
if (getRoomId === roomId) return room;
return null;
});
mocked(client.getRooms).mockReturnValue([room]);
startedInfoEvent = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Started,
client.getUserId()!,
client.getDeviceId()!,
);
pausedInfoEvent = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Paused,
client.getUserId()!,
client.getDeviceId()!,
startedInfoEvent,
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("when the initial sync is completed", () => {
beforeEach(() => {
mocked(client.isInitialSyncComplete).mockReturnValue(true);
});
describe("and there is no info event", () => {
beforeEach(() => {
resumer = new VoiceBroadcastResumer(client);
});
itShouldNotSendAStateEvent();
describe("and calling destroy", () => {
beforeEach(() => {
resumer.destroy();
});
itShouldDeregisterFromTheClient();
});
});
describe("and there is a started info event", () => {
beforeEach(() => {
room.currentState.setStateEvents([startedInfoEvent]);
});
describe("and the client knows about the user and device", () => {
beforeEach(() => {
resumer = new VoiceBroadcastResumer(client);
});
itShouldSendAStoppedStateEvent();
});
describe("and the client doesn't know about the user", () => {
beforeEach(() => {
mocked(client.getUserId).mockReturnValue(null);
resumer = new VoiceBroadcastResumer(client);
});
itShouldNotSendAStateEvent();
});
describe("and the client doesn't know about the device", () => {
beforeEach(() => {
mocked(client.getDeviceId).mockReturnValue(null);
resumer = new VoiceBroadcastResumer(client);
});
itShouldNotSendAStateEvent();
});
});
describe("and there is a paused info event", () => {
beforeEach(() => {
room.currentState.setStateEvents([pausedInfoEvent]);
resumer = new VoiceBroadcastResumer(client);
});
itShouldSendAStoppedStateEvent();
});
});
describe("when the initial sync is not completed", () => {
beforeEach(() => {
room.currentState.setStateEvents([pausedInfoEvent]);
mocked(client.isInitialSyncComplete).mockReturnValue(false);
mocked(client.getSyncState).mockReturnValue(SyncState.Prepared);
resumer = new VoiceBroadcastResumer(client);
});
itShouldNotSendAStateEvent();
describe("and a sync event appears", () => {
beforeEach(() => {
client.emit(ClientEvent.Sync, SyncState.Prepared, SyncState.Stopped);
});
itShouldNotSendAStateEvent();
describe("and the initial sync completed and a sync event appears", () => {
beforeEach(() => {
mocked(client.getSyncState).mockReturnValue(SyncState.Syncing);
client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Prepared);
});
itShouldSendAStoppedStateEvent();
itShouldDeregisterFromTheClient();
});
});
});
});

View file

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`setUpVoiceBroadcastPreRecording when trying to start a broadcast if there is no connection should show an info dialog and not set up a pre-recording 1`] = `
[MockFunction] {
"calls": [
[
[Function],
{
"description": <p>
Unfortunately we're unable to start a recording right now. Please try again later.
</p>,
"hasCloseButton": true,
"title": "Connection error",
},
],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
}
`;

View file

@ -0,0 +1,116 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`startNewVoiceBroadcastRecording when the current user is allowed to send voice broadcast info state events when there already is a live broadcast of another user should show an info dialog 1`] = `
[MockFunction] {
"calls": [
[
[Function],
{
"description": <p>
Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.
</p>,
"hasCloseButton": true,
"title": "Can't start a new voice broadcast",
},
],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
}
`;
exports[`startNewVoiceBroadcastRecording when the current user is allowed to send voice broadcast info state events when there already is a live broadcast of the current user in the room should show an info dialog 1`] = `
[MockFunction] {
"calls": [
[
[Function],
{
"description": <p>
You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.
</p>,
"hasCloseButton": true,
"title": "Can't start a new voice broadcast",
},
],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
}
`;
exports[`startNewVoiceBroadcastRecording when the current user is allowed to send voice broadcast info state events when there is already a current voice broadcast should show an info dialog 1`] = `
[MockFunction] {
"calls": [
[
[Function],
{
"description": <p>
You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.
</p>,
"hasCloseButton": true,
"title": "Can't start a new voice broadcast",
},
],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
}
`;
exports[`startNewVoiceBroadcastRecording when the current user is not allowed to send voice broadcast info state events should show an info dialog 1`] = `
[MockFunction] {
"calls": [
[
[Function],
{
"description": <p>
You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.
</p>,
"hasCloseButton": true,
"title": "Can't start a new voice broadcast",
},
],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
}
`;
exports[`startNewVoiceBroadcastRecording when trying to start a broadcast if there is no connection should show an info dialog and not start a recording 1`] = `
[MockFunction] {
"calls": [
[
[Function],
{
"description": <p>
Unfortunately we're unable to start a recording right now. Please try again later.
</p>,
"hasCloseButton": true,
"title": "Connection error",
},
],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
}
`;

View file

@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`textForVoiceBroadcastStoppedEvent should render other users broadcast as expected 1`] = `
<div>
<div>
@other:example.com ended a voice broadcast
</div>
</div>
`;
exports[`textForVoiceBroadcastStoppedEvent should render own broadcast as expected 1`] = `
<div>
<div>
You ended a voice broadcast
</div>
</div>
`;
exports[`textForVoiceBroadcastStoppedEvent should render without login as expected 1`] = `
<div>
<div>
@other:example.com ended a voice broadcast
</div>
</div>
`;
exports[`textForVoiceBroadcastStoppedEvent when rendering an event with relation to the start event should render events with relation to the start event 1`] = `
<div>
<div>
<span>
You ended a
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
voice broadcast
</div>
</span>
</div>
</div>
`;

View file

@ -0,0 +1,51 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
cleanUpBroadcasts,
VoiceBroadcastPlayback,
VoiceBroadcastPreRecording,
VoiceBroadcastRecording,
} from "../../../../src/voice-broadcast";
import { stubClient } from "../../../test-utils";
import { TestSdkContext } from "../../TestSdkContext";
import { mkVoiceBroadcastPlayback, mkVoiceBroadcastPreRecording, mkVoiceBroadcastRecording } from "./test-utils";
describe("cleanUpBroadcasts", () => {
let playback: VoiceBroadcastPlayback;
let recording: VoiceBroadcastRecording;
let preRecording: VoiceBroadcastPreRecording;
let stores: TestSdkContext;
beforeEach(() => {
stores = new TestSdkContext();
stores.client = stubClient();
playback = mkVoiceBroadcastPlayback(stores);
jest.spyOn(playback, "stop").mockReturnValue();
stores.voiceBroadcastPlaybacksStore.setCurrent(playback);
recording = mkVoiceBroadcastRecording(stores);
jest.spyOn(recording, "stop").mockResolvedValue();
stores.voiceBroadcastRecordingsStore.setCurrent(recording);
preRecording = mkVoiceBroadcastPreRecording(stores);
jest.spyOn(preRecording, "cancel").mockReturnValue();
stores.voiceBroadcastPreRecordingStore.setCurrent(preRecording);
});
it("should stop and clear all broadcast related stuff", async () => {
await cleanUpBroadcasts(stores);
expect(playback.stop).toHaveBeenCalled();
expect(stores.voiceBroadcastPlaybacksStore.getCurrent()).toBeNull();
expect(recording.stop).toHaveBeenCalled();
expect(stores.voiceBroadcastRecordingsStore.getCurrent()).toBeNull();
expect(preRecording.cancel).toHaveBeenCalled();
expect(stores.voiceBroadcastPreRecordingStore.getCurrent()).toBeNull();
});
});

View file

@ -0,0 +1,33 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { VoiceBroadcastInfoState, VoiceBroadcastLiveness } from "../../../../src/voice-broadcast";
import { determineVoiceBroadcastLiveness } from "../../../../src/voice-broadcast/utils/determineVoiceBroadcastLiveness";
const testData: Array<{ state: VoiceBroadcastInfoState; expected: VoiceBroadcastLiveness }> = [
{ state: VoiceBroadcastInfoState.Started, expected: "live" },
{ state: VoiceBroadcastInfoState.Resumed, expected: "live" },
{ state: VoiceBroadcastInfoState.Paused, expected: "grey" },
{ state: VoiceBroadcastInfoState.Stopped, expected: "not-live" },
];
describe("determineVoiceBroadcastLiveness", () => {
it.each(testData)("should return the expected value for a %s broadcast", ({ state, expected }) => {
expect(determineVoiceBroadcastLiveness(state)).toBe(expected);
});
it("should return »non-live« for an undefined state", () => {
// @ts-ignore
expect(determineVoiceBroadcastLiveness(undefined)).toBe("not-live");
});
it("should return »non-live« for an unknown state", () => {
// @ts-ignore
expect(determineVoiceBroadcastLiveness("unknown test state")).toBe("not-live");
});
});

View file

@ -0,0 +1,116 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import {
findRoomLiveVoiceBroadcastFromUserAndDevice,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
} from "../../../../src/voice-broadcast";
import { mkEvent, stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
describe("findRoomLiveVoiceBroadcastFromUserAndDevice", () => {
const roomId = "!room:example.com";
let client: MatrixClient;
let room: Room;
const itShouldReturnNull = () => {
it("should return null", () => {
expect(
findRoomLiveVoiceBroadcastFromUserAndDevice(room, client.getUserId()!, client.getDeviceId()!),
).toBeNull();
});
};
beforeAll(() => {
client = stubClient();
room = new Room(roomId, client, client.getUserId()!);
jest.spyOn(room.currentState, "getStateEvents");
mocked(client.getRoom).mockImplementation((getRoomId: string) => {
if (getRoomId === roomId) return room;
return null;
});
});
describe("when there is no info event", () => {
itShouldReturnNull();
});
describe("when there is an info event without content", () => {
beforeEach(() => {
room.currentState.setStateEvents([
mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
room: roomId,
user: client.getUserId()!,
content: {},
}),
]);
});
itShouldReturnNull();
});
describe("when there is a stopped info event", () => {
beforeEach(() => {
room.currentState.setStateEvents([
mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Stopped,
client.getUserId()!,
client.getDeviceId(),
),
]);
});
itShouldReturnNull();
});
describe("when there is a started info event from another device", () => {
beforeEach(() => {
const event = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Stopped,
client.getUserId()!,
"JKL123",
);
room.currentState.setStateEvents([event]);
});
itShouldReturnNull();
});
describe("when there is a started info event", () => {
let event: MatrixEvent;
beforeEach(() => {
event = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Started,
client.getUserId()!,
client.getDeviceId(),
);
room.currentState.setStateEvents([event]);
});
it("should return this event", () => {
expect(room.currentState.getStateEvents).toHaveBeenCalledWith(
VoiceBroadcastInfoEventType,
client.getUserId()!,
);
expect(findRoomLiveVoiceBroadcastFromUserAndDevice(room, client.getUserId()!, client.getDeviceId()!)).toBe(
event,
);
});
});
});

View file

@ -0,0 +1,63 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import SdkConfig from "../../../../src/SdkConfig";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { Features } from "../../../../src/settings/Settings";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { getChunkLength } from "../../../../src/voice-broadcast/utils/getChunkLength";
describe("getChunkLength", () => {
afterEach(() => {
SdkConfig.reset();
});
describe("when there is a value provided by Sdk config", () => {
beforeEach(() => {
SdkConfig.add({
voice_broadcast: {
chunk_length: 42,
},
});
});
it("should return this value", () => {
expect(getChunkLength()).toBe(42);
});
});
describe("when Sdk config does not provide a value", () => {
beforeEach(() => {
SdkConfig.add({
voice_broadcast: {
chunk_length: 23,
},
});
});
it("should return this value", () => {
expect(getChunkLength()).toBe(23);
});
});
describe("when there are no defaults", () => {
it("should return the fallback value", () => {
expect(getChunkLength()).toBe(120);
});
});
describe("when the Features.VoiceBroadcastForceSmallChunks is enabled", () => {
beforeEach(async () => {
await SettingsStore.setValue(Features.VoiceBroadcastForceSmallChunks, null, SettingLevel.DEVICE, true);
});
it("should return a chunk length of 15 seconds", () => {
expect(getChunkLength()).toBe(15);
});
});
});

View file

@ -0,0 +1,43 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import SdkConfig, { DEFAULTS } from "../../../../src/SdkConfig";
import { getMaxBroadcastLength } from "../../../../src/voice-broadcast";
describe("getMaxBroadcastLength", () => {
afterEach(() => {
SdkConfig.reset();
});
describe("when there is a value provided by Sdk config", () => {
beforeEach(() => {
SdkConfig.put({
voice_broadcast: {
max_length: 42,
},
});
});
it("should return this value", () => {
expect(getMaxBroadcastLength()).toBe(42);
});
});
describe("when Sdk config does not provide a value", () => {
it("should return this value", () => {
expect(getMaxBroadcastLength()).toBe(DEFAULTS.voice_broadcast!.max_length);
});
});
describe("if there are no defaults", () => {
it("should return the fallback value", () => {
expect(DEFAULTS.voice_broadcast!.max_length).toBe(4 * 60 * 60);
expect(getMaxBroadcastLength()).toBe(4 * 60 * 60);
});
});
});

View file

@ -0,0 +1,195 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import {
hasRoomLiveVoiceBroadcast,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
} from "../../../../src/voice-broadcast";
import { mkEvent, stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
describe("hasRoomLiveVoiceBroadcast", () => {
const otherUserId = "@other:example.com";
const otherDeviceId = "ASD123";
const roomId = "!room:example.com";
let client: MatrixClient;
let room: Room;
let expectedEvent: MatrixEvent | null = null;
const addVoiceBroadcastInfoEvent = (
state: VoiceBroadcastInfoState,
userId: string,
deviceId: string,
startedEvent?: MatrixEvent,
): MatrixEvent => {
const infoEvent = mkVoiceBroadcastInfoStateEvent(room.roomId, state, userId, deviceId, startedEvent);
room.addLiveEvents([infoEvent]);
room.currentState.setStateEvents([infoEvent]);
room.relations.aggregateChildEvent(infoEvent);
return infoEvent;
};
const itShouldReturnTrueTrue = () => {
it("should return true/true", async () => {
expect(await hasRoomLiveVoiceBroadcast(client, room, client.getSafeUserId())).toEqual({
hasBroadcast: true,
infoEvent: expectedEvent,
startedByUser: true,
});
});
};
const itShouldReturnTrueFalse = () => {
it("should return true/false", async () => {
expect(await hasRoomLiveVoiceBroadcast(client, room, client.getSafeUserId())).toEqual({
hasBroadcast: true,
infoEvent: expectedEvent,
startedByUser: false,
});
});
};
const itShouldReturnFalseFalse = () => {
it("should return false/false", async () => {
expect(await hasRoomLiveVoiceBroadcast(client, room, client.getSafeUserId())).toEqual({
hasBroadcast: false,
infoEvent: null,
startedByUser: false,
});
});
};
beforeEach(() => {
client = stubClient();
room = new Room(roomId, client, client.getSafeUserId());
mocked(client.getRoom).mockImplementation((roomId: string): Room | null => {
return roomId === room.roomId ? room : null;
});
expectedEvent = null;
});
describe("when there is no voice broadcast info at all", () => {
itShouldReturnFalseFalse();
});
describe("when the »state« prop is missing", () => {
beforeEach(() => {
room.currentState.setStateEvents([
mkEvent({
event: true,
room: room.roomId,
user: client.getSafeUserId(),
type: VoiceBroadcastInfoEventType,
skey: client.getSafeUserId(),
content: {},
}),
]);
});
itShouldReturnFalseFalse();
});
describe("when there is a live broadcast from the current and another user", () => {
beforeEach(() => {
expectedEvent = addVoiceBroadcastInfoEvent(
VoiceBroadcastInfoState.Started,
client.getSafeUserId(),
client.getDeviceId()!,
);
addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Started, otherUserId, otherDeviceId);
});
itShouldReturnTrueTrue();
});
describe("when there are only stopped info events", () => {
beforeEach(() => {
addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped, client.getSafeUserId(), client.getDeviceId()!);
addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped, otherUserId, otherDeviceId);
});
itShouldReturnFalseFalse();
});
describe("when there is a live, started broadcast from the current user", () => {
beforeEach(() => {
expectedEvent = addVoiceBroadcastInfoEvent(
VoiceBroadcastInfoState.Started,
client.getSafeUserId(),
client.getDeviceId()!,
);
});
itShouldReturnTrueTrue();
});
describe("when there is a live, paused broadcast from the current user", () => {
beforeEach(() => {
expectedEvent = addVoiceBroadcastInfoEvent(
VoiceBroadcastInfoState.Started,
client.getSafeUserId(),
client.getDeviceId()!,
);
addVoiceBroadcastInfoEvent(
VoiceBroadcastInfoState.Paused,
client.getSafeUserId(),
client.getDeviceId()!,
expectedEvent,
);
});
itShouldReturnTrueTrue();
});
describe("when there is a live, resumed broadcast from the current user", () => {
beforeEach(() => {
expectedEvent = addVoiceBroadcastInfoEvent(
VoiceBroadcastInfoState.Started,
client.getSafeUserId(),
client.getDeviceId()!,
);
addVoiceBroadcastInfoEvent(
VoiceBroadcastInfoState.Resumed,
client.getSafeUserId(),
client.getDeviceId()!,
expectedEvent,
);
});
itShouldReturnTrueTrue();
});
describe("when there was a live broadcast, that has been stopped", () => {
beforeEach(() => {
const startedEvent = addVoiceBroadcastInfoEvent(
VoiceBroadcastInfoState.Started,
client.getSafeUserId(),
client.getDeviceId()!,
);
addVoiceBroadcastInfoEvent(
VoiceBroadcastInfoState.Stopped,
client.getSafeUserId(),
client.getDeviceId()!,
startedEvent,
);
});
itShouldReturnFalseFalse();
});
describe("when there is a live broadcast from another user", () => {
beforeEach(() => {
expectedEvent = addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Started, otherUserId, otherDeviceId);
});
itShouldReturnTrueFalse();
});
});

View file

@ -0,0 +1,109 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EventType, MatrixEvent, RelationType, Room, MatrixClient } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { isRelatedToVoiceBroadcast, VoiceBroadcastInfoState } from "../../../../src/voice-broadcast";
import { mkEvent, stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
const mkRelatedEvent = (
room: Room,
relationType: RelationType,
relatesTo: MatrixEvent | undefined,
client: MatrixClient,
): MatrixEvent => {
const event = mkEvent({
event: true,
type: EventType.RoomMessage,
room: room.roomId,
content: {
"m.relates_to": {
rel_type: relationType,
event_id: relatesTo?.getId(),
},
},
user: client.getSafeUserId(),
});
room.addLiveEvents([event]);
return event;
};
describe("isRelatedToVoiceBroadcast", () => {
const roomId = "!room:example.com";
let client: MatrixClient;
let room: Room;
let broadcastEvent: MatrixEvent;
let nonBroadcastEvent: MatrixEvent;
beforeAll(() => {
client = stubClient();
room = new Room(roomId, client, client.getSafeUserId());
mocked(client.getRoom).mockImplementation((getRoomId: string): Room | null => {
if (getRoomId === roomId) return room;
return null;
});
broadcastEvent = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Started,
client.getSafeUserId(),
"ABC123",
);
nonBroadcastEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
room: roomId,
content: {},
user: client.getSafeUserId(),
});
room.addLiveEvents([broadcastEvent, nonBroadcastEvent]);
});
it("should return true if related (reference) to a broadcast event", () => {
expect(
isRelatedToVoiceBroadcast(mkRelatedEvent(room, RelationType.Reference, broadcastEvent, client), client),
).toBe(true);
});
it("should return false if related (reference) is undefeind", () => {
expect(isRelatedToVoiceBroadcast(mkRelatedEvent(room, RelationType.Reference, undefined, client), client)).toBe(
false,
);
});
it("should return false if related (referenireplace) to a broadcast event", () => {
expect(
isRelatedToVoiceBroadcast(mkRelatedEvent(room, RelationType.Replace, broadcastEvent, client), client),
).toBe(false);
});
it("should return false if the event has no relation", () => {
const noRelationEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
room: room.roomId,
content: {},
user: client.getSafeUserId(),
});
expect(isRelatedToVoiceBroadcast(noRelationEvent, client)).toBe(false);
});
it("should return false for an unknown room", () => {
const otherRoom = new Room("!other:example.com", client, client.getSafeUserId());
expect(
isRelatedToVoiceBroadcast(
mkRelatedEvent(otherRoom, RelationType.Reference, broadcastEvent, client),
client,
),
).toBe(false);
});
});

View file

@ -0,0 +1,98 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import {
VoiceBroadcastInfoState,
VoiceBroadcastPlayback,
VoiceBroadcastPlaybacksStore,
VoiceBroadcastRecordingsStore,
} from "../../../../src/voice-broadcast";
import { pauseNonLiveBroadcastFromOtherRoom } from "../../../../src/voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom";
import { stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
describe("pauseNonLiveBroadcastFromOtherRoom", () => {
const roomId = "!room:example.com";
const roomId2 = "!room2@example.com";
let room: Room;
let client: MatrixClient;
let playback: VoiceBroadcastPlayback;
let playbacks: VoiceBroadcastPlaybacksStore;
let recordings: VoiceBroadcastRecordingsStore;
const mkPlayback = (infoState: VoiceBroadcastInfoState, roomId: string): VoiceBroadcastPlayback => {
const infoEvent = mkVoiceBroadcastInfoStateEvent(
roomId,
infoState,
client.getSafeUserId(),
client.getDeviceId()!,
);
const playback = new VoiceBroadcastPlayback(infoEvent, client, recordings);
jest.spyOn(playback, "pause");
playbacks.setCurrent(playback);
return playback;
};
beforeEach(() => {
client = stubClient();
room = new Room(roomId, client, client.getSafeUserId());
recordings = new VoiceBroadcastRecordingsStore();
playbacks = new VoiceBroadcastPlaybacksStore(recordings);
jest.spyOn(playbacks, "clearCurrent");
});
afterEach(() => {
playback?.destroy();
playbacks.destroy();
});
describe("when there is no current playback", () => {
it("should not clear the current playback", () => {
pauseNonLiveBroadcastFromOtherRoom(room, playbacks);
expect(playbacks.clearCurrent).not.toHaveBeenCalled();
});
});
describe("when listening to a live broadcast in another room", () => {
beforeEach(() => {
playback = mkPlayback(VoiceBroadcastInfoState.Started, roomId2);
});
it("should not clear current / pause the playback", () => {
pauseNonLiveBroadcastFromOtherRoom(room, playbacks);
expect(playbacks.clearCurrent).not.toHaveBeenCalled();
expect(playback.pause).not.toHaveBeenCalled();
});
});
describe("when listening to a non-live broadcast in the same room", () => {
beforeEach(() => {
playback = mkPlayback(VoiceBroadcastInfoState.Stopped, roomId);
});
it("should not clear current / pause the playback", () => {
pauseNonLiveBroadcastFromOtherRoom(room, playbacks);
expect(playbacks.clearCurrent).not.toHaveBeenCalled();
expect(playback.pause).not.toHaveBeenCalled();
});
});
describe("when listening to a non-live broadcast in another room", () => {
beforeEach(() => {
playback = mkPlayback(VoiceBroadcastInfoState.Stopped, roomId2);
});
it("should clear current and pause the playback", () => {
pauseNonLiveBroadcastFromOtherRoom(room, playbacks);
expect(playbacks.getCurrent()).toBeNull();
expect(playback.pause).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,81 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import {
retrieveStartedInfoEvent,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
} from "../../../../src/voice-broadcast";
import { mkEvent, stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
describe("retrieveStartedInfoEvent", () => {
let client: MatrixClient;
let room: Room;
const mkStartEvent = () => {
return mkVoiceBroadcastInfoStateEvent(
room.roomId,
VoiceBroadcastInfoState.Started,
client.getUserId()!,
client.deviceId!,
);
};
const mkStopEvent = (startEvent: MatrixEvent) => {
return mkVoiceBroadcastInfoStateEvent(
room.roomId,
VoiceBroadcastInfoState.Stopped,
client.getUserId()!,
client.deviceId!,
startEvent,
);
};
beforeEach(() => {
client = stubClient();
room = new Room("!room:example.com", client, client.getUserId()!);
mocked(client.getRoom).mockImplementation((roomId: string): Room | null => {
if (roomId === room.roomId) return room;
return null;
});
});
it("when passing a started event, it should return the event", async () => {
const event = mkStartEvent();
expect(await retrieveStartedInfoEvent(event, client)).toBe(event);
});
it("when passing an event without relation, it should return null", async () => {
const event = mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
user: client.getUserId()!,
content: {},
});
expect(await retrieveStartedInfoEvent(event, client)).toBeNull();
});
it("when the room contains the event, it should return it", async () => {
const startEvent = mkStartEvent();
const stopEvent = mkStopEvent(startEvent);
room.addLiveEvents([startEvent]);
expect(await retrieveStartedInfoEvent(stopEvent, client)).toBe(startEvent);
});
it("when the room not contains the event, it should fetch it", async () => {
const startEvent = mkStartEvent();
const stopEvent = mkStopEvent(startEvent);
mocked(client.fetchRoomEvent).mockResolvedValue(startEvent.event);
expect((await retrieveStartedInfoEvent(stopEvent, client))?.getId()).toBe(startEvent.getId());
expect(client.fetchRoomEvent).toHaveBeenCalledWith(room.roomId, startEvent.getId());
});
});

View file

@ -0,0 +1,123 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
import Modal from "../../../../src/Modal";
import {
VoiceBroadcastInfoState,
VoiceBroadcastPlayback,
VoiceBroadcastPlaybacksStore,
VoiceBroadcastPreRecording,
VoiceBroadcastPreRecordingStore,
VoiceBroadcastRecordingsStore,
} from "../../../../src/voice-broadcast";
import { setUpVoiceBroadcastPreRecording } from "../../../../src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording";
import { mkRoomMemberJoinEvent, stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
jest.mock("../../../../src/Modal");
describe("setUpVoiceBroadcastPreRecording", () => {
const roomId = "!room:example.com";
let client: MatrixClient;
let userId: string;
let room: Room;
let preRecordingStore: VoiceBroadcastPreRecordingStore;
let infoEvent: MatrixEvent;
let playback: VoiceBroadcastPlayback;
let playbacksStore: VoiceBroadcastPlaybacksStore;
let recordingsStore: VoiceBroadcastRecordingsStore;
let preRecording: VoiceBroadcastPreRecording | null;
const itShouldNotCreateAPreRecording = () => {
it("should return null", () => {
expect(preRecording).toBeNull();
});
it("should not create a broadcast pre recording", () => {
expect(preRecordingStore.getCurrent()).toBeNull();
});
};
const setUpPreRecording = async () => {
preRecording = await setUpVoiceBroadcastPreRecording(
room,
client,
playbacksStore,
recordingsStore,
preRecordingStore,
);
};
beforeEach(() => {
client = stubClient();
userId = client.getSafeUserId();
room = new Room(roomId, client, userId);
infoEvent = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Started,
client.getUserId()!,
client.getDeviceId()!,
);
preRecording = null;
preRecordingStore = new VoiceBroadcastPreRecordingStore();
recordingsStore = new VoiceBroadcastRecordingsStore();
playback = new VoiceBroadcastPlayback(infoEvent, client, recordingsStore);
jest.spyOn(playback, "pause");
playbacksStore = new VoiceBroadcastPlaybacksStore(recordingsStore);
});
describe("when trying to start a broadcast if there is no connection", () => {
beforeEach(async () => {
mocked(client.getSyncState).mockReturnValue(SyncState.Error);
await setUpPreRecording();
});
it("should show an info dialog and not set up a pre-recording", () => {
expect(preRecordingStore.getCurrent()).toBeNull();
expect(Modal.createDialog).toMatchSnapshot();
});
});
describe("when setting up a pre-recording", () => {
describe("and there is no user id", () => {
beforeEach(async () => {
mocked(client.getUserId).mockReturnValue(null);
await setUpPreRecording();
});
itShouldNotCreateAPreRecording();
});
describe("and there is no room member", () => {
beforeEach(async () => {
// check test precondition
expect(room.getMember(userId)).toBeNull();
await setUpPreRecording();
});
itShouldNotCreateAPreRecording();
});
describe("and there is a room member and listening to another broadcast", () => {
beforeEach(async () => {
playbacksStore.setCurrent(playback);
room.currentState.setStateEvents([mkRoomMemberJoinEvent(userId, roomId)]);
await setUpPreRecording();
});
it("should pause the current playback and create a voice broadcast pre-recording", () => {
expect(playback.pause).toHaveBeenCalled();
expect(playbacksStore.getCurrent()).toBeNull();
expect(preRecording).toBeInstanceOf(VoiceBroadcastPreRecording);
});
});
});
});

View file

@ -0,0 +1,72 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { shouldDisplayAsVoiceBroadcastRecordingTile, VoiceBroadcastInfoState } from "../../../../src/voice-broadcast";
import { createTestClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
type TestTuple = [string | null, string, string, string, VoiceBroadcastInfoState, boolean];
const testCases: TestTuple[] = [
[
"@user1:example.com", // own MXID
"@user1:example.com", // sender MXID
"ABC123", // own device ID
"ABC123", // sender device ID
VoiceBroadcastInfoState.Started,
true, // expected return value
],
["@user1:example.com", "@user1:example.com", "ABC123", "ABC123", VoiceBroadcastInfoState.Paused, true],
["@user1:example.com", "@user1:example.com", "ABC123", "ABC123", VoiceBroadcastInfoState.Resumed, true],
["@user1:example.com", "@user1:example.com", "ABC123", "ABC123", VoiceBroadcastInfoState.Stopped, false],
["@user2:example.com", "@user1:example.com", "ABC123", "ABC123", VoiceBroadcastInfoState.Started, false],
[null, "@user1:example.com", "ABC123", "ABC123", VoiceBroadcastInfoState.Started, false],
// other device
["@user1:example.com", "@user1:example.com", "ABC123", "JKL123", VoiceBroadcastInfoState.Started, false],
["@user1:example.com", "@user1:example.com", "ABC123", "JKL123", VoiceBroadcastInfoState.Paused, false],
["@user1:example.com", "@user1:example.com", "ABC123", "JKL123", VoiceBroadcastInfoState.Resumed, false],
];
describe("shouldDisplayAsVoiceBroadcastRecordingTile", () => {
let event: MatrixEvent;
let client: MatrixClient;
beforeAll(() => {
client = createTestClient();
});
describe.each<TestTuple>(testCases)(
"when called with user »%s«, sender »%s«, device »%s«, sender device »%s« state »%s«",
(userId, senderId, deviceId, senderDeviceId, state, expected) => {
beforeEach(() => {
event = mkVoiceBroadcastInfoStateEvent("!room:example.com", state, senderId, senderDeviceId);
mocked(client.getUserId).mockReturnValue(userId);
mocked(client.getDeviceId).mockReturnValue(deviceId);
});
it(`should return ${expected}`, () => {
expect(shouldDisplayAsVoiceBroadcastRecordingTile(state, client, event)).toBe(expected);
});
},
);
it("should return false, when all params are null", () => {
event = mkVoiceBroadcastInfoStateEvent("!room:example.com", null, null, null);
// @ts-ignore Simulate null state received for any reason.
expect(shouldDisplayAsVoiceBroadcastRecordingTile(null, client, event)).toBe(false);
});
it("should return false, when all params are undefined", () => {
event = mkVoiceBroadcastInfoStateEvent("!room:example.com", undefined, undefined, undefined);
// @ts-ignore Simulate undefined state received for any reason.
expect(shouldDisplayAsVoiceBroadcastRecordingTile(undefined, client, event)).toBe(false);
});
});

View file

@ -0,0 +1,138 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EventType, IEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import {
shouldDisplayAsVoiceBroadcastTile,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
} from "../../../../src/voice-broadcast";
import { mkEvent } from "../../../test-utils";
describe("shouldDisplayAsVoiceBroadcastTile", () => {
let event: MatrixEvent;
const roomId = "!room:example.com";
const senderId = "@user:example.com";
const itShouldReturnFalse = () => {
it("should return false", () => {
expect(shouldDisplayAsVoiceBroadcastTile(event)).toBe(false);
});
};
const itShouldReturnTrue = () => {
it("should return true", () => {
expect(shouldDisplayAsVoiceBroadcastTile(event)).toBe(true);
});
};
describe("when a broken event occurs", () => {
beforeEach(() => {
event = 23 as unknown as MatrixEvent;
});
itShouldReturnFalse();
});
describe("when a non-voice broadcast info event occurs", () => {
beforeEach(() => {
event = mkEvent({
event: true,
type: EventType.RoomMessage,
room: roomId,
user: senderId,
content: {},
});
});
itShouldReturnFalse();
});
describe("when a voice broadcast info event with empty content occurs", () => {
beforeEach(() => {
event = mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
room: roomId,
user: senderId,
content: {},
});
});
itShouldReturnFalse();
});
describe("when a voice broadcast info event with undefined content occurs", () => {
beforeEach(() => {
event = mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
room: roomId,
user: senderId,
content: {},
});
event.getContent = () => ({}) as any;
});
itShouldReturnFalse();
});
describe("when a voice broadcast info event in state started occurs", () => {
beforeEach(() => {
event = mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
room: roomId,
user: senderId,
content: {
state: VoiceBroadcastInfoState.Started,
},
});
});
itShouldReturnTrue();
});
describe("when a redacted event occurs", () => {
beforeEach(() => {
event = mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
room: roomId,
user: senderId,
content: {},
unsigned: {
redacted_because: {} as unknown as IEvent,
},
});
event.getContent = () => ({}) as any;
});
itShouldReturnTrue();
});
describe.each([VoiceBroadcastInfoState.Paused, VoiceBroadcastInfoState.Resumed, VoiceBroadcastInfoState.Stopped])(
"when a voice broadcast info event in state %s occurs",
(state: VoiceBroadcastInfoState) => {
beforeEach(() => {
event = mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
room: roomId,
user: senderId,
content: {
state,
},
});
});
itShouldReturnFalse();
},
);
});

View file

@ -0,0 +1,234 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { EventType, ISendEventResponse, MatrixClient, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
import Modal from "../../../../src/Modal";
import {
startNewVoiceBroadcastRecording,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
VoiceBroadcastRecordingsStore,
VoiceBroadcastRecording,
VoiceBroadcastPlaybacksStore,
VoiceBroadcastPlayback,
} from "../../../../src/voice-broadcast";
import { mkEvent, stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
jest.mock("../../../../src/voice-broadcast/models/VoiceBroadcastRecording", () => ({
VoiceBroadcastRecording: jest.fn(),
}));
jest.mock("../../../../src/Modal");
describe("startNewVoiceBroadcastRecording", () => {
const roomId = "!room:example.com";
const otherUserId = "@other:example.com";
let client: MatrixClient;
let playbacksStore: VoiceBroadcastPlaybacksStore;
let recordingsStore: VoiceBroadcastRecordingsStore;
let room: Room;
let infoEvent: MatrixEvent;
let otherEvent: MatrixEvent;
let result: VoiceBroadcastRecording | null;
beforeEach(() => {
client = stubClient();
room = new Room(roomId, client, client.getUserId()!);
jest.spyOn(room.currentState, "maySendStateEvent");
mocked(client.getRoom).mockImplementation((getRoomId: string) => {
if (getRoomId === roomId) {
return room;
}
return null;
});
mocked(client.sendStateEvent).mockImplementation(
(sendRoomId: string, eventType: string, content: any, stateKey: string): Promise<ISendEventResponse> => {
if (sendRoomId === roomId && eventType === VoiceBroadcastInfoEventType) {
return Promise.resolve({ event_id: infoEvent.getId()! });
}
throw new Error("Unexpected sendStateEvent call");
},
);
infoEvent = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Started,
client.getUserId()!,
client.getDeviceId()!,
);
otherEvent = mkEvent({
event: true,
type: EventType.RoomMember,
content: {},
user: client.getUserId()!,
room: roomId,
skey: "",
});
playbacksStore = new VoiceBroadcastPlaybacksStore(recordingsStore);
recordingsStore = {
setCurrent: jest.fn(),
getCurrent: jest.fn(),
} as unknown as VoiceBroadcastRecordingsStore;
mocked(VoiceBroadcastRecording).mockImplementation((infoEvent: MatrixEvent, client: MatrixClient): any => {
return {
infoEvent,
client,
start: jest.fn(),
} as unknown as VoiceBroadcastRecording;
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe("when trying to start a broadcast if there is no connection", () => {
beforeEach(async () => {
mocked(client.getSyncState).mockReturnValue(SyncState.Error);
result = await startNewVoiceBroadcastRecording(room, client, playbacksStore, recordingsStore);
});
it("should show an info dialog and not start a recording", () => {
expect(result).toBeNull();
expect(Modal.createDialog).toMatchSnapshot();
});
});
describe("when the current user is allowed to send voice broadcast info state events", () => {
beforeEach(() => {
mocked(room.currentState.maySendStateEvent).mockReturnValue(true);
});
describe("when currently listening to a broadcast and there is no recording", () => {
let playback: VoiceBroadcastPlayback;
beforeEach(() => {
playback = new VoiceBroadcastPlayback(infoEvent, client, recordingsStore);
jest.spyOn(playback, "pause");
playbacksStore.setCurrent(playback);
});
it("should stop listen to the current broadcast and create a new recording", async () => {
mocked(client.sendStateEvent).mockImplementation(
async (
_roomId: string,
_eventType: string,
_content: any,
_stateKey = "",
): Promise<ISendEventResponse> => {
window.setTimeout(() => {
// emit state events after resolving the promise
room.currentState.setStateEvents([otherEvent]);
room.currentState.setStateEvents([infoEvent]);
}, 0);
return { event_id: infoEvent.getId()! };
},
);
const recording = await startNewVoiceBroadcastRecording(room, client, playbacksStore, recordingsStore);
expect(recording).not.toBeNull();
// expect to stop and clear the current playback
expect(playback.pause).toHaveBeenCalled();
expect(playbacksStore.getCurrent()).toBeNull();
expect(client.sendStateEvent).toHaveBeenCalledWith(
roomId,
VoiceBroadcastInfoEventType,
{
chunk_length: 120,
device_id: client.getDeviceId(),
state: VoiceBroadcastInfoState.Started,
},
client.getUserId()!,
);
expect(recording!.infoEvent).toBe(infoEvent);
expect(recording!.start).toHaveBeenCalled();
});
});
describe("when there is already a current voice broadcast", () => {
beforeEach(async () => {
mocked(recordingsStore.getCurrent).mockReturnValue(new VoiceBroadcastRecording(infoEvent, client));
result = await startNewVoiceBroadcastRecording(room, client, playbacksStore, recordingsStore);
});
it("should not start a voice broadcast", () => {
expect(result).toBeNull();
});
it("should show an info dialog", () => {
expect(Modal.createDialog).toMatchSnapshot();
});
});
describe("when there already is a live broadcast of the current user in the room", () => {
beforeEach(async () => {
room.currentState.setStateEvents([
mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Resumed,
client.getUserId()!,
client.getDeviceId()!,
),
]);
result = await startNewVoiceBroadcastRecording(room, client, playbacksStore, recordingsStore);
});
it("should not start a voice broadcast", () => {
expect(result).toBeNull();
});
it("should show an info dialog", () => {
expect(Modal.createDialog).toMatchSnapshot();
});
});
describe("when there already is a live broadcast of another user", () => {
beforeEach(async () => {
room.currentState.setStateEvents([
mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Resumed, otherUserId, "ASD123"),
]);
result = await startNewVoiceBroadcastRecording(room, client, playbacksStore, recordingsStore);
});
it("should not start a voice broadcast", () => {
expect(result).toBeNull();
});
it("should show an info dialog", () => {
expect(Modal.createDialog).toMatchSnapshot();
});
});
});
describe("when the current user is not allowed to send voice broadcast info state events", () => {
beforeEach(async () => {
mocked(room.currentState.maySendStateEvent).mockReturnValue(false);
result = await startNewVoiceBroadcastRecording(room, client, playbacksStore, recordingsStore);
});
it("should not start a voice broadcast", () => {
expect(result).toBeNull();
});
it("should show an info dialog", () => {
expect(Modal.createDialog).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,131 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Optional } from "matrix-events-sdk";
import { EventType, IContent, MatrixEvent, MsgType, RelationType, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
import {
VoiceBroadcastPlayback,
VoiceBroadcastPreRecording,
VoiceBroadcastRecording,
} from "../../../../src/voice-broadcast";
import {
VoiceBroadcastChunkEventType,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
} from "../../../../src/voice-broadcast/types";
import { mkEvent } from "../../../test-utils";
// timestamp incremented on each call to prevent duplicate timestamp
let timestamp = new Date().getTime();
export const mkVoiceBroadcastInfoStateEvent = (
roomId: Optional<string>,
state: Optional<VoiceBroadcastInfoState>,
senderId: Optional<string>,
senderDeviceId: Optional<string>,
startedInfoEvent?: MatrixEvent,
lastChunkSequence?: number,
): MatrixEvent => {
const relationContent: IContent = {};
if (startedInfoEvent) {
relationContent["m.relates_to"] = {
event_id: startedInfoEvent.getId(),
rel_type: "m.reference",
};
}
const lastChunkSequenceContent = lastChunkSequence ? { last_chunk_sequence: lastChunkSequence } : {};
return mkEvent({
event: true,
// @ts-ignore allow everything here for edge test cases
room: roomId,
// @ts-ignore allow everything here for edge test cases
user: senderId,
type: VoiceBroadcastInfoEventType,
// @ts-ignore allow everything here for edge test cases
skey: senderId,
content: {
state,
device_id: senderDeviceId,
...relationContent,
...lastChunkSequenceContent,
},
ts: timestamp++,
});
};
export const mkVoiceBroadcastChunkEvent = (
infoEventId: string,
userId: string,
roomId: string,
duration: number,
sequence?: number,
timestamp?: number,
): MatrixEvent => {
return mkEvent({
event: true,
user: userId,
room: roomId,
type: EventType.RoomMessage,
content: {
msgtype: MsgType.Audio,
["org.matrix.msc1767.audio"]: {
duration,
},
info: {
duration,
},
[VoiceBroadcastChunkEventType]: {
...(sequence ? { sequence } : {}),
},
["m.relates_to"]: {
rel_type: RelationType.Reference,
event_id: infoEventId,
},
},
ts: timestamp,
});
};
export const mkVoiceBroadcastPlayback = (stores: SdkContextClass): VoiceBroadcastPlayback => {
const infoEvent = mkVoiceBroadcastInfoStateEvent(
"!room:example.com",
VoiceBroadcastInfoState.Started,
"@user:example.com",
"ASD123",
);
return new VoiceBroadcastPlayback(infoEvent, stores.client!, stores.voiceBroadcastRecordingsStore);
};
export const mkVoiceBroadcastRecording = (stores: SdkContextClass): VoiceBroadcastRecording => {
const infoEvent = mkVoiceBroadcastInfoStateEvent(
"!room:example.com",
VoiceBroadcastInfoState.Started,
"@user:example.com",
"ASD123",
);
return new VoiceBroadcastRecording(infoEvent, stores.client!);
};
export const mkVoiceBroadcastPreRecording = (stores: SdkContextClass): VoiceBroadcastPreRecording => {
const roomId = "!room:example.com";
const userId = "@user:example.com";
const room = new Room(roomId, stores.client!, userId);
const roomMember = new RoomMember(roomId, userId);
return new VoiceBroadcastPreRecording(
room,
roomMember,
stores.client!,
stores.voiceBroadcastPlaybacksStore,
stores.voiceBroadcastRecordingsStore,
);
};

View file

@ -0,0 +1,90 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, RenderResult, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { MatrixClient, RelationType } from "matrix-js-sdk/src/matrix";
import { textForVoiceBroadcastStoppedEvent, VoiceBroadcastInfoState } from "../../../../src/voice-broadcast";
import { stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
import dis from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
jest.mock("../../../../src/dispatcher/dispatcher");
describe("textForVoiceBroadcastStoppedEvent", () => {
const otherUserId = "@other:example.com";
const roomId = "!room:example.com";
let client: MatrixClient;
const renderText = (senderId: string, startEventId?: string) => {
const event = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Stopped,
senderId,
client.deviceId!,
);
if (startEventId) {
event.getContent()["m.relates_to"] = {
rel_type: RelationType.Reference,
event_id: startEventId,
};
}
return render(<div>{textForVoiceBroadcastStoppedEvent(event, client)()}</div>);
};
beforeEach(() => {
client = stubClient();
});
it("should render own broadcast as expected", () => {
expect(renderText(client.getUserId()!).container).toMatchSnapshot();
});
it("should render other users broadcast as expected", () => {
expect(renderText(otherUserId).container).toMatchSnapshot();
});
it("should render without login as expected", () => {
mocked(client.getUserId).mockReturnValue(null);
expect(renderText(otherUserId).container).toMatchSnapshot();
});
describe("when rendering an event with relation to the start event", () => {
let result: RenderResult;
beforeEach(() => {
result = renderText(client.getUserId()!, "$start-id");
});
it("should render events with relation to the start event", () => {
expect(result.container).toMatchSnapshot();
});
describe("and clicking the link", () => {
beforeEach(async () => {
await userEvent.click(screen.getByRole("button"));
});
it("should dispatch an action to highlight the event", () => {
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
event_id: "$start-id",
highlighted: true,
room_id: roomId,
metricsTrigger: undefined, // room doesn't change
});
});
});
});
});

View file

@ -0,0 +1,47 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { textForVoiceBroadcastStoppedEventWithoutLink, VoiceBroadcastInfoState } from "../../../../src/voice-broadcast";
import { stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
describe("textForVoiceBroadcastStoppedEventWithoutLink", () => {
const otherUserId = "@other:example.com";
const roomId = "!room:example.com";
let client: MatrixClient;
beforeAll(() => {
client = stubClient();
});
const getText = (senderId: string, startEventId?: string) => {
const event = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Stopped,
senderId,
client.deviceId!,
);
return textForVoiceBroadcastStoppedEventWithoutLink(event);
};
it("when called for an own broadcast it should return the expected text", () => {
expect(getText(client.getUserId()!)).toBe("You ended a voice broadcast");
});
it("when called for other ones broadcast it should return the expected text", () => {
expect(getText(otherUserId)).toBe(`${otherUserId} ended a voice broadcast`);
});
it("when not logged in it should return the exptected text", () => {
mocked(client.getUserId).mockReturnValue(null);
expect(getText(otherUserId)).toBe(`${otherUserId} ended a voice broadcast`);
});
});