diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index 55ae3b16dc..c3104ad076 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -961,7 +961,7 @@ test.describe("Timeline", () => { const reply = "Reply"; const viewRoomSendMessageAndSetupReply = async (page: Page, app: ElementAppPage, roomId: string) => { // View room - await page.goto(`/#/room/${roomId}`); + await app.viewRoomById(roomId); // Send a message const composer = app.getComposerField(); @@ -993,6 +993,24 @@ test.describe("Timeline", () => { await expect(eventTileLine.getByText(reply)).toHaveCount(1); }); + test("can send a voice message", { tag: "@screenshot" }, async ({ page, app, room, context }) => { + await app.viewRoomById(room.roomId); + + const composerOptions = await app.openMessageComposerOptions(); + await composerOptions.getByRole("menuitem", { name: "Voice Message" }).click(); + + // Record an empty message + await page.waitForTimeout(3000); + + const roomViewBody = page.locator(".mx_RoomView_body"); + await roomViewBody + .locator(".mx_MessageComposer") + .getByRole("button", { name: "Send voice message" }) + .click(); + + await expect(roomViewBody.locator(".mx_MVoiceMessageBody")).toMatchScreenshot("voice-message.png"); + }); + test("can reply with a voice message", async ({ page, app, room, context }) => { await viewRoomSendMessageAndSetupReply(page, app, room.roomId); diff --git a/playwright/snapshots/timeline/timeline.spec.ts/voice-message-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/voice-message-linux.png new file mode 100644 index 0000000000..ba1cb6e3f6 Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/voice-message-linux.png differ diff --git a/src/audio/PlaybackQueue.ts b/src/audio/PlaybackQueue.ts index fbca9d7872..d22cb1e5bb 100644 --- a/src/audio/PlaybackQueue.ts +++ b/src/audio/PlaybackQueue.ts @@ -89,9 +89,15 @@ export class PlaybackQueue { private onPlaybackStateChange(playback: Playback, mxEvent: MatrixEvent, newState: PlaybackState): void { // Remember where the user got to in playback const wasLastPlaying = this.currentPlaybackId === mxEvent.getId(); - if (newState === PlaybackState.Stopped && this.clockStates.has(mxEvent.getId()!) && !wasLastPlaying) { - // noinspection JSIgnoredPromiseFromCall - playback.skipTo(this.clockStates.get(mxEvent.getId()!)!); + const currentClockState = this.clockStates.get(mxEvent.getId()!); + if (newState === PlaybackState.Stopped && currentClockState !== undefined && !wasLastPlaying) { + if (currentClockState > 0) { + // skipTo will pause playback, which causes the clock to render the current + // playback seconds. If the clock state is 0, then we can just ignore + // skipping entirely. + // noinspection JSIgnoredPromiseFromCall + playback.skipTo(currentClockState); + } } else if (newState === PlaybackState.Stopped) { // Remove the now-useless clock for some space savings this.clockStates.delete(mxEvent.getId()!); diff --git a/test/unit-tests/audio/PlaybackQueue-test.ts b/test/unit-tests/audio/PlaybackQueue-test.ts new file mode 100644 index 0000000000..e43b975e2c --- /dev/null +++ b/test/unit-tests/audio/PlaybackQueue-test.ts @@ -0,0 +1,74 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type Mocked } from "jest-mock"; +import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix"; +import { SimpleObservable } from "matrix-widget-api"; + +import { PlaybackQueue } from "../../../src/audio/PlaybackQueue"; +import { PlaybackState, type Playback } from "../../../src/audio/Playback"; +import { MockEventEmitter } from "../../test-utils"; +import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; + +describe("PlaybackQueue", () => { + let playbackQueue: PlaybackQueue; + + beforeEach(() => { + const mockRoom = { + getMember: jest.fn(), + } as unknown as Mocked; + playbackQueue = new PlaybackQueue(mockRoom); + }); + + it("does not call skipTo on playback if clock advances to 0s", () => { + const mockEvent = { + getId: jest.fn().mockReturnValue("$foo:bar"), + } as unknown as Mocked; + const mockPlayback = new MockEventEmitter({ + clockInfo: { + liveData: new SimpleObservable(), + }, + skipTo: jest.fn(), + }) as unknown as Mocked; + + // Enqueue + playbackQueue.unsortedEnqueue(mockEvent, mockPlayback); + + // Emit our clockInfo of 0, which will playbackQueue to save the state. + mockPlayback.clockInfo.liveData.update([0]); + + // Fire an update event to say that we have stopped. + // Note that Playback really emits an UPDATE_EVENT whenever state changes, the types are lies. + mockPlayback.emit(UPDATE_EVENT as any, PlaybackState.Stopped); + + expect(mockPlayback.skipTo).not.toHaveBeenCalled(); + }); + + it("does call skipTo on playback if clock advances to 0s", () => { + const mockEvent = { + getId: jest.fn().mockReturnValue("$foo:bar"), + } as unknown as Mocked; + const mockPlayback = new MockEventEmitter({ + clockInfo: { + liveData: new SimpleObservable(), + }, + skipTo: jest.fn(), + }) as unknown as Mocked; + + // Enqueue + playbackQueue.unsortedEnqueue(mockEvent, mockPlayback); + + // Emit our clockInfo of 0, which will playbackQueue to save the state. + mockPlayback.clockInfo.liveData.update([1]); + + // Fire an update event to say that we have stopped. + // Note that Playback really emits an UPDATE_EVENT whenever state changes, the types are lies. + mockPlayback.emit(UPDATE_EVENT as any, PlaybackState.Stopped); + + expect(mockPlayback.skipTo).toHaveBeenCalledWith(1); + }); +});