diff --git a/.changeset/stop-audio-on-message-delete.md b/.changeset/stop-audio-on-message-delete.md new file mode 100644 index 0000000000000..e8705bc454a84 --- /dev/null +++ b/.changeset/stop-audio-on-message-delete.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Stops the shared audio player and hides the Now Playing card when the message that owns the playing audio attachment is deleted diff --git a/.changeset/stop-audio-on-quoted-message-delete.md b/.changeset/stop-audio-on-quoted-message-delete.md new file mode 100644 index 0000000000000..bb758248e81e1 --- /dev/null +++ b/.changeset/stop-audio-on-quoted-message-delete.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/core-typings': patch +'@rocket.chat/meteor': patch +--- + +Stops the shared audio player and hides the Now Playing card when the audio is no longer the listener's to hear: when the original message of a quoted audio attachment is deleted, when the listener leaves or is removed from the room the audio belongs to, and — for quotes created from now on — when the original is deleted in another room it was quoted from. Pinning a message while its audio plays no longer stops playback on a later bulk delete that excludes pinned messages, for as long as that message stays rendered diff --git a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx index b49fc2b000f74..b3596b2014d16 100644 --- a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx @@ -16,6 +16,7 @@ import AttachmentDetails from './structure/AttachmentDetails'; import AttachmentInner from './structure/AttachmentInner'; import AttachmentMessageLink from './structure/AttachmentMessageLink'; import { toPlainTextRoot } from '../../../../lib/toPlainTextRoot'; +import { getMessageIdFromPermalink } from '../../../../lib/utils/getMessageIdFromPermalink'; // TODO: remove this team collaboration const quoteStyles = css` @@ -44,6 +45,7 @@ export type QuoteAttachmentProps = { export const QuoteAttachment = ({ attachment, source, path }: QuoteAttachmentProps) => { const formatTime = useTimeAgo(); const displayAvatarPreference = useUserPreference('displayAvatars'); + const originMid = getMessageIdFromPermalink(attachment.message_link); return ( <> @@ -73,7 +75,7 @@ export const QuoteAttachment = ({ attachment, source, path }: QuoteAttachmentPro diff --git a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx index 16999733b295c..3025a27f4b80e 100644 --- a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx @@ -1,7 +1,7 @@ import type { AudioAttachmentProps } from '@rocket.chat/core-typings'; import { AudioPlayerControls, Box } from '@rocket.chat/fuselage'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useMediaPlayer } from '../../../../../providers/MediaPlayerProvider'; import type { PersistentAudioTrack } from '../../../../../providers/MediaPlayerProvider'; @@ -15,6 +15,17 @@ export type AudioAttachmentSource = { mid?: string; username?: string; name?: string; + ts?: Date; + /** Discussion room id the owning message links to. Immutable once set, so it is safe to snapshot. */ + drid?: string; + /** Whether the owning message is pinned. Mutable, so the player is refreshed while this is rendered. */ + pinned?: boolean; + /** When the audio is rendered inside a quote, the id of the original message that holds the attachment. */ + originMid?: string; + /** Timestamp of the original quoted message. */ + originTs?: Date; + /** Room of the original quoted message, which may differ from the room the quote is rendered in. */ + originRid?: string; }; type AudioAttachmentComponentProps = AudioAttachmentProps & { @@ -36,7 +47,7 @@ const AudioAttachment = ({ const getURL = useMediaUrl(); const src = useMemo(() => getURL(url), [getURL, url]); - const { play, toggle, seek, cyclePlaybackRate, isActive, playing, currentTime, duration, playbackRate } = useMediaPlayer(); + const { play, toggle, seek, cyclePlaybackRate, isActive, updateTrack, playing, currentTime, duration, playbackRate } = useMediaPlayer(); const track = useMemo( () => ({ @@ -49,11 +60,41 @@ const AudioAttachment = ({ mid: source?.mid, username: source?.username, name: source?.name, + ts: source?.ts, + drid: source?.drid, + pinned: source?.pinned, + originMid: source?.originMid, + originTs: source?.originTs, + originRid: source?.originRid, }), - [source?.mid, source?.rid, source?.username, source?.name, url, src, type, title, size], + [ + source?.mid, + source?.rid, + source?.username, + source?.name, + source?.ts, + source?.drid, + source?.pinned, + source?.originMid, + source?.originTs, + source?.originRid, + url, + src, + type, + title, + size, + ], ); const active = isActive(track.id); + + // The shared player keeps the descriptor it was handed, so hand it a fresh one whenever this + // message re-renders with different mutable state while it owns playback. + useEffect(() => { + if (active) { + updateTrack(track); + } + }, [active, track, updateTrack]); const [previewDuration, setPreviewDuration] = useState(0); return ( diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx index c95204ac6ec13..70f88a040484a 100644 --- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx @@ -60,7 +60,15 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM {!!quotes?.length && ( )} @@ -83,7 +91,15 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM )} diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx index 17fa32c8f0eea..52880a7d8ab32 100644 --- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx @@ -54,7 +54,15 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps) => { {!!quotes?.length && ( )} @@ -79,7 +87,15 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps) => { )} diff --git a/apps/meteor/client/lib/utils/getMessageIdFromPermalink.spec.ts b/apps/meteor/client/lib/utils/getMessageIdFromPermalink.spec.ts new file mode 100644 index 0000000000000..6b42cbb1f81e1 --- /dev/null +++ b/apps/meteor/client/lib/utils/getMessageIdFromPermalink.spec.ts @@ -0,0 +1,26 @@ +import { getMessageIdFromPermalink } from './getMessageIdFromPermalink'; + +describe('getMessageIdFromPermalink', () => { + it('returns the msg query parameter from an absolute permalink', () => { + expect(getMessageIdFromPermalink('https://open.rocket.chat/channel/general?msg=abc123')).toBe('abc123'); + }); + + it('returns the msg query parameter when other parameters are present', () => { + expect(getMessageIdFromPermalink('https://open.rocket.chat/group/team?tab=thread&msg=xyz789&foo=bar')).toBe('xyz789'); + }); + + it('returns the msg query parameter from a relative permalink', () => { + expect(getMessageIdFromPermalink('/direct/abc?msg=def456')).toBe('def456'); + }); + + it('returns undefined when there is no msg query parameter', () => { + expect(getMessageIdFromPermalink('https://open.rocket.chat/channel/general')).toBeUndefined(); + expect(getMessageIdFromPermalink('https://open.rocket.chat/channel/general?msg=')).toBeUndefined(); + }); + + it('returns undefined for empty or unparsable input', () => { + expect(getMessageIdFromPermalink(undefined)).toBeUndefined(); + expect(getMessageIdFromPermalink('')).toBeUndefined(); + expect(getMessageIdFromPermalink('http://[invalid')).toBeUndefined(); + }); +}); diff --git a/apps/meteor/client/lib/utils/getMessageIdFromPermalink.ts b/apps/meteor/client/lib/utils/getMessageIdFromPermalink.ts new file mode 100644 index 0000000000000..b790db7d86d2c --- /dev/null +++ b/apps/meteor/client/lib/utils/getMessageIdFromPermalink.ts @@ -0,0 +1,17 @@ +/** + * Extracts the message id from a message permalink such as + * `https://open.rocket.chat/channel/general?msg=abc123`. + * Returns `undefined` when the link has no `msg` query parameter or cannot be parsed. + */ +export const getMessageIdFromPermalink = (permalink: string | undefined): string | undefined => { + if (!permalink) { + return undefined; + } + + try { + const msgId = new URL(permalink, 'http://localhost').searchParams.get('msg'); + return msgId || undefined; + } catch { + return undefined; + } +}; diff --git a/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerContext.ts b/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerContext.ts index 50607808b6177..ce13839ff904e 100644 --- a/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerContext.ts +++ b/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerContext.ts @@ -25,6 +25,34 @@ export type PersistentAudioTrack = { username?: string; /** Display name of the sender. */ name?: string; + /** + * Timestamp of the message the audio belongs to (used to match bulk-delete criteria). + * Only immutable message state is kept here: a track is replaced on the shared element + * only when its id changes, so anything that can change mid-playback would go stale. + */ + ts?: Date; + /** + * Discussion room id the owning message links to (used to match bulk-delete criteria). + * A message is created with its `drid` and never gains or loses one, so this snapshot + * stays accurate for as long as the track lives. + */ + drid?: string; + /** + * Whether the owning message is pinned (used to match bulk-delete criteria). Unlike `drid` + * this can change while the track is active, so the provider refreshes it from the rendered + * message; it can still drift while that message is unmounted. + */ + pinned?: boolean; + /** When played from a quote, the id of the original message that holds the attachment (its deletion also closes the player). */ + originMid?: string; + /** Timestamp of the original quoted message (used to match bulk-delete criteria). */ + originTs?: Date; + /** + * Room of the original quoted message. A quote may point at another room, in which case the + * player also watches that room for deletions. Absent on quotes stored before the origin room + * was persisted, which fall back to assuming the quoting room. + */ + originRid?: string; }; export type MediaPlayerContextValue = { @@ -43,6 +71,12 @@ export type MediaPlayerContextValue = { cyclePlaybackRate: () => void; /** Stops playback and clears the active track. */ close: () => void; + /** + * Refreshes the mutable metadata of the active track when `next` describes it. Ignored when no + * track is active or `next` is a different one, so a re-rendering message can keep the player's + * copy of its own state current without disturbing playback. + */ + updateTrack: (next: PersistentAudioTrack) => void; /** Whether the given track id is the one currently owned by the shared element. */ isActive: (id: string) => boolean; }; @@ -60,6 +94,7 @@ export const MediaPlayerContext = createContext({ seek: noop, cyclePlaybackRate: noop, close: noop, + updateTrack: noop, isActive: () => false, }); diff --git a/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.spec.tsx b/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.spec.tsx new file mode 100644 index 0000000000000..7c83617703eb6 --- /dev/null +++ b/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.spec.tsx @@ -0,0 +1,103 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { act, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import type { PersistentAudioTrack } from './MediaPlayerContext'; +import { useMediaPlayer } from './MediaPlayerContext'; +import MediaPlayerProvider from './MediaPlayerProvider'; + +const buildTrack = (overrides: Partial = {}): PersistentAudioTrack => ({ + id: 'mid1:url', + url: 'https://example.com/audio.mp3', + title: 'audio.mp3', + rid: 'room1', + mid: 'mid1', + username: 'john.doe', + ts: new Date('2024-01-01T00:00:00.000Z'), + pinned: false, + ...overrides, +}); + +const wrapper = ({ children }: { children: ReactNode }) => { + const AppRoot = mockAppRoot().build(); + return ( + + {children} + + ); +}; + +// jsdom does not implement media playback. +beforeAll(() => { + Object.defineProperty(HTMLMediaElement.prototype, 'play', { configurable: true, value: jest.fn().mockResolvedValue(undefined) }); + Object.defineProperty(HTMLMediaElement.prototype, 'load', { configurable: true, value: jest.fn() }); +}); + +describe('MediaPlayerProvider updateTrack', () => { + it('adopts new mutable state for the active track', () => { + const { result } = renderHook(() => useMediaPlayer(), { wrapper }); + + act(() => result.current.play(buildTrack({ pinned: false }))); + + expect(result.current.track?.pinned).toBe(false); + + act(() => result.current.updateTrack(buildTrack({ pinned: true }))); + + expect(result.current.track?.pinned).toBe(true); + }); + + // A message is not expected to gain a discussion id after it exists, so this is defensive: + // should the descriptors ever disagree, the player takes the one the message currently renders + // rather than keeping a value it can no longer justify. + it('refreshes the discussion id when the supplied descriptor differs', () => { + const { result } = renderHook(() => useMediaPlayer(), { wrapper }); + + act(() => result.current.play(buildTrack())); + + expect(result.current.track?.drid).toBeUndefined(); + + act(() => result.current.updateTrack(buildTrack({ drid: 'disc1' }))); + + expect(result.current.track?.drid).toBe('disc1'); + }); + + it('ignores an update describing a different track', () => { + const { result } = renderHook(() => useMediaPlayer(), { wrapper }); + + act(() => result.current.play(buildTrack({ pinned: false }))); + act(() => result.current.updateTrack(buildTrack({ id: 'mid2:url', mid: 'mid2', pinned: true }))); + + expect(result.current.track?.id).toBe('mid1:url'); + expect(result.current.track?.pinned).toBe(false); + }); + + it('ignores an update when no track is active', () => { + const { result } = renderHook(() => useMediaPlayer(), { wrapper }); + + act(() => result.current.updateTrack(buildTrack({ pinned: true }))); + + expect(result.current.track).toBeNull(); + }); + + it('keeps the same track object when nothing mutable changed, so the player does not re-render', () => { + const { result } = renderHook(() => useMediaPlayer(), { wrapper }); + + act(() => result.current.play(buildTrack())); + + const before = result.current.track; + + act(() => result.current.updateTrack(buildTrack())); + + expect(result.current.track).toBe(before); + }); + + it('does not disturb the url or identity of the active track', () => { + const { result } = renderHook(() => useMediaPlayer(), { wrapper }); + + act(() => result.current.play(buildTrack())); + act(() => result.current.updateTrack(buildTrack({ pinned: true }))); + + expect(result.current.track?.url).toBe('https://example.com/audio.mp3'); + expect(result.current.track?.mid).toBe('mid1'); + }); +}); diff --git a/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.tsx b/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.tsx index ac0c843dc94e8..e3b89d4f60da7 100644 --- a/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.tsx +++ b/apps/meteor/client/providers/MediaPlayerProvider/MediaPlayerProvider.tsx @@ -4,6 +4,8 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import type { MediaPlayerContextValue, PersistentAudioTrack } from './MediaPlayerContext'; import { MediaPlayerContext } from './MediaPlayerContext'; +import { useCloseOnTrackMessageDeleted } from './useCloseOnTrackMessageDeleted'; +import { useCloseOnTrackRoomLeft } from './useCloseOnTrackRoomLeft'; import { useReloadOnError } from '../../components/message/content/attachments/file/hooks/useReloadOnError'; const PLAYBACK_RATES = [1, 1.5, 2] as const; @@ -56,6 +58,29 @@ const MediaPlayerProvider = ({ children }: MediaPlayerProviderProps) => { audio.play().catch((err) => console.warn('Failed to start audio playback:', err)); }); + // `play` only swaps the track when its id changes, so a message that is re-rendered with new + // mutable state (pinning, for instance) would otherwise leave the player matching delete + // criteria against the values captured when playback started. + const updateTrack = useStableCallback((next: PersistentAudioTrack) => { + setTrack((current) => { + if (!current || current.id !== next.id) { + return current; + } + + // A descriptor that omits a field says nothing about it, so keep what was last known + // rather than letting a partial snapshot clear state a fuller one had refreshed. + // Unpinning sends `false`, not `undefined`, so it still comes through. + const pinned = next.pinned ?? current.pinned; + const drid = next.drid ?? current.drid; + + if (current.pinned === pinned && current.drid === drid) { + return current; + } + + return { ...current, pinned, drid }; + }); + }); + const toggle = useStableCallback(() => { const audio = audioRef.current; if (!audio || !trackRef.current) { @@ -102,9 +127,12 @@ const MediaPlayerProvider = ({ children }: MediaPlayerProviderProps) => { const isActive = useCallback((id: string) => trackRef.current?.id === id, []); + useCloseOnTrackMessageDeleted(track, close); + useCloseOnTrackRoomLeft(track, close); + const value = useMemo( - () => ({ track, playing, currentTime, duration, playbackRate, play, toggle, seek, cyclePlaybackRate, close, isActive }), - [track, playing, currentTime, duration, playbackRate, play, toggle, seek, cyclePlaybackRate, close, isActive], + () => ({ track, playing, currentTime, duration, playbackRate, play, toggle, seek, cyclePlaybackRate, close, updateTrack, isActive }), + [track, playing, currentTime, duration, playbackRate, play, toggle, seek, cyclePlaybackRate, close, updateTrack, isActive], ); return ( diff --git a/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackMessageDeleted.spec.ts b/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackMessageDeleted.spec.ts new file mode 100644 index 0000000000000..a910247bafd5d --- /dev/null +++ b/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackMessageDeleted.spec.ts @@ -0,0 +1,683 @@ +import { mockAppRoot, type StreamControllerRef } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react'; + +import type { PersistentAudioTrack } from './MediaPlayerContext'; +import { useCloseOnTrackMessageDeleted } from './useCloseOnTrackMessageDeleted'; + +const buildTrack = (overrides: Partial = {}): PersistentAudioTrack => ({ + id: 'mid1:url', + url: 'https://example.com/audio.mp3', + title: 'audio.mp3', + rid: 'room1', + mid: 'mid1', + username: 'john.doe', + ts: new Date('2024-01-01T00:00:00.000Z'), + ...overrides, +}); + +describe('useCloseOnTrackMessageDeleted', () => { + it('closes the player when deleteMessage matches the track message id', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessage`, [{ _id: track.mid! }]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('does not close the player when deleteMessage targets another message id', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessage`, [{ _id: 'other-mid' }]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('closes the player when room-messages delivers a soft-deleted (t: rm) update for the track message', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + roomMessagesRef.controller?.emit(track.rid!, [{ _id: track.mid!, t: 'rm' } as any]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('does not close the player when room-messages delivers an update for the track message without t: rm', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + roomMessagesRef.controller?.emit(track.rid!, [{ _id: track.mid! } as any]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('closes the player when deleteMessageBulk targets the track message id', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessageBulk`, [ + { + rid: track.rid!, + excludePinned: false, + ignoreDiscussion: false, + ts: { $gt: new Date(0) }, + users: [], + ids: [track.mid!], + }, + ]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('closes when deleteMessageBulk lists the track id even though excludePinned/ignoreDiscussion would exclude it', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + // Pinned and in a discussion, so the criteria genuinely exclude it: only the explicit id can close. + const track = buildTrack({ pinned: true, drid: 'disc1' }); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessageBulk`, [ + { + rid: track.rid!, + excludePinned: true, + ignoreDiscussion: true, + ts: { $gt: new Date() }, + users: [], + ids: [track.mid!], + }, + ]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('does not close the player when deleteMessageBulk ids do not include the track message', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessageBulk`, [ + { + rid: track.rid!, + excludePinned: false, + ignoreDiscussion: false, + ts: { $gt: new Date(0) }, + users: [], + ids: ['other-mid'], + }, + ]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('closes the player when deleteMessageBulk matches by ts range and user, without ids', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessageBulk`, [ + { + rid: track.rid!, + excludePinned: false, + ignoreDiscussion: false, + ts: { $gt: new Date(0) }, + users: [track.username!], + }, + ]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('does not close the player when deleteMessageBulk targets a different user, without ids', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessageBulk`, [ + { + rid: track.rid!, + excludePinned: false, + ignoreDiscussion: false, + ts: { $gt: new Date(0) }, + users: ['someone-else'], + }, + ]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('does not close the player when deleteMessageBulk excludes pinned messages and the track is pinned, but closes when it is not', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const closePinned = jest.fn(); + const closeUnpinned = jest.fn(); + + const { rerender } = renderHook(({ track, close }) => useCloseOnTrackMessageDeleted(track, close), { + initialProps: { track: buildTrack({ pinned: true }) as PersistentAudioTrack | null, close: closePinned }, + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + const bulkParams = { rid: 'room1', excludePinned: true, ignoreDiscussion: false, ts: { $gt: new Date(0) }, users: [] }; + + notifyRef.controller?.emit('room1/deleteMessageBulk', [bulkParams]); + + expect(closePinned).not.toHaveBeenCalled(); + + rerender({ track: buildTrack({ pinned: false }), close: closeUnpinned }); + + notifyRef.controller?.emit('room1/deleteMessageBulk', [bulkParams]); + + expect(closeUnpinned).toHaveBeenCalledTimes(1); + }); + + it('does not close the player when deleteMessageBulk ignores discussions and the track belongs to one, but closes when it does not', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const closeDiscussion = jest.fn(); + const closeNonDiscussion = jest.fn(); + const discussionTrack = buildTrack({ drid: 'disc1' }); + const nonDiscussionTrack = buildTrack({ drid: undefined }); + + const { rerender } = renderHook(({ track, close }) => useCloseOnTrackMessageDeleted(track, close), { + initialProps: { track: discussionTrack as PersistentAudioTrack | null, close: closeDiscussion }, + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + const bulkParams = { + rid: discussionTrack.rid!, + excludePinned: false, + ignoreDiscussion: true, + ts: { $gt: new Date(0) }, + users: [], + }; + + notifyRef.controller?.emit(`${discussionTrack.rid}/deleteMessageBulk`, [bulkParams]); + + expect(closeDiscussion).not.toHaveBeenCalled(); + + rerender({ track: nonDiscussionTrack, close: closeNonDiscussion }); + + notifyRef.controller?.emit(`${nonDiscussionTrack.rid}/deleteMessageBulk`, [bulkParams]); + + expect(closeNonDiscussion).toHaveBeenCalledTimes(1); + }); + + describe('when the audio was played from a quote', () => { + const originTs = new Date('2023-12-31T00:00:00.000Z'); + const buildQuotedTrack = (overrides: Partial = {}) => + buildTrack({ id: 'mid2:url', mid: 'mid2', originMid: 'mid1', originTs, ...overrides }); + + it('closes the player when deleteMessage targets the original quoted message', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildQuotedTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessage`, [{ _id: 'mid1' }]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('still closes the player when deleteMessage targets the quoting message', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildQuotedTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessage`, [{ _id: 'mid2' }]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('does not close the player when deleteMessage targets an unrelated message', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildQuotedTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessage`, [{ _id: 'other-mid' }]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('closes the player when the original quoted message is soft-deleted (t: rm)', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildQuotedTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + roomMessagesRef.controller?.emit(track.rid!, [{ _id: 'mid1', t: 'rm' } as any]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('closes the player when deleteMessageBulk ids include the original quoted message', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildQuotedTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessageBulk`, [ + { + rid: track.rid!, + excludePinned: false, + ignoreDiscussion: false, + ts: { $gt: new Date(0) }, + users: [], + ids: ['mid1'], + }, + ]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('closes the player when a prune by ts range covers the original but not the quoting message', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildQuotedTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessageBulk`, [ + { + rid: track.rid!, + excludePinned: false, + ignoreDiscussion: false, + ts: { $gt: new Date('2023-12-30T00:00:00.000Z'), $lt: new Date('2023-12-31T12:00:00.000Z') }, + users: [], + }, + ]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('does not evaluate the original against a prune filtered by users, since its author is unknown', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildQuotedTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessageBulk`, [ + { + rid: track.rid!, + excludePinned: false, + ignoreDiscussion: false, + ts: { $gt: new Date('2023-12-30T00:00:00.000Z'), $lt: new Date('2023-12-31T12:00:00.000Z') }, + users: ['someone-else'], + }, + ]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('does not evaluate the original against a prune that excludes pinned messages, since its pinned state is unknown', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildQuotedTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessageBulk`, [ + { + rid: track.rid!, + excludePinned: true, + ignoreDiscussion: false, + ts: { $gt: new Date('2023-12-30T00:00:00.000Z'), $lt: new Date('2023-12-31T12:00:00.000Z') }, + users: [], + }, + ]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('does not evaluate the original against a prune that ignores discussions, since its discussion is unknown', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildQuotedTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessageBulk`, [ + { + rid: track.rid!, + excludePinned: false, + ignoreDiscussion: true, + ts: { $gt: new Date('2023-12-30T00:00:00.000Z'), $lt: new Date('2023-12-31T12:00:00.000Z') }, + users: [], + }, + ]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('still closes for an explicit id even when the prune excludes pinned and ignores discussions', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildQuotedTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessageBulk`, [ + { + rid: track.rid!, + excludePinned: true, + ignoreDiscussion: true, + ts: { $gt: new Date(0) }, + users: [], + ids: ['mid1'], + }, + ]); + + expect(close).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the quoted original lives in another room', () => { + const originTs = new Date('2023-12-31T00:00:00.000Z'); + const buildCrossRoomTrack = (overrides: Partial = {}) => + buildTrack({ id: 'mid2:url', mid: 'mid2', originMid: 'mid1', originTs, originRid: 'room2', ...overrides }); + + it('closes the player when the original is deleted in its own room', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildCrossRoomTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit('room2/deleteMessage', [{ _id: 'mid1' }]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('closes the player when the original is soft-deleted (t: rm) in its own room', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildCrossRoomTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + roomMessagesRef.controller?.emit('room2', [{ _id: 'mid1', t: 'rm' } as any]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('closes the player when a bulk delete in the origin room lists the original', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildCrossRoomTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit('room2/deleteMessageBulk', [ + { rid: 'room2', excludePinned: false, ignoreDiscussion: false, ts: { $gt: new Date(0) }, users: [], ids: ['mid1'] }, + ]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('still closes the player when the quoting message itself is deleted in its own room', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildCrossRoomTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessage`, [{ _id: 'mid2' }]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('does not close the player when the origin id is announced in the quoting room instead', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildCrossRoomTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit(`${track.rid}/deleteMessage`, [{ _id: 'mid1' }]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('closes the player when an unfiltered ts prune in the origin room covers the original', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildCrossRoomTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit('room2/deleteMessageBulk', [ + { + rid: 'room2', + excludePinned: false, + ignoreDiscussion: false, + ts: { $gt: new Date('2023-12-30T00:00:00.000Z'), $lt: new Date('2023-12-31T12:00:00.000Z') }, + users: [], + }, + ]); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('does not evaluate the original against a prune that excludes pinned messages, since a stored quote never learns it was pinned', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildCrossRoomTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit('room2/deleteMessageBulk', [ + { + rid: 'room2', + excludePinned: true, + ignoreDiscussion: false, + ts: { $gt: new Date('2023-12-30T00:00:00.000Z'), $lt: new Date('2023-12-31T12:00:00.000Z') }, + users: [], + }, + ]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('does not evaluate the original against a prune that ignores discussions, since a stored quote never learns it became one', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildCrossRoomTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit('room2/deleteMessageBulk', [ + { + rid: 'room2', + excludePinned: false, + ignoreDiscussion: true, + ts: { $gt: new Date('2023-12-30T00:00:00.000Z'), $lt: new Date('2023-12-31T12:00:00.000Z') }, + users: [], + }, + ]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('does not evaluate the original against a prune filtered by users, since its author is still unknown', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const track = buildCrossRoomTrack(); + + renderHook(() => useCloseOnTrackMessageDeleted(track, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + notifyRef.controller?.emit('room2/deleteMessageBulk', [ + { + rid: 'room2', + excludePinned: false, + ignoreDiscussion: false, + ts: { $gt: new Date('2023-12-30T00:00:00.000Z'), $lt: new Date('2023-12-31T12:00:00.000Z') }, + users: ['someone-else'], + }, + ]); + + expect(close).not.toHaveBeenCalled(); + }); + + it('unsubscribes from the origin room when the track changes', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + + const { rerender } = renderHook(({ track }) => useCloseOnTrackMessageDeleted(track, close), { + initialProps: { track: buildCrossRoomTrack() as PersistentAudioTrack | null }, + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + rerender({ track: buildTrack() }); + + notifyRef.controller?.emit('room2/deleteMessage', [{ _id: 'mid1' }]); + + expect(close).not.toHaveBeenCalled(); + }); + }); + + it('does not subscribe to streams when track is null', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + + renderHook(() => useCloseOnTrackMessageDeleted(null, close), { + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + expect(notifyRef.controller?.has(`room1/deleteMessage`)).toBe(false); + expect(close).not.toHaveBeenCalled(); + }); + + it('unsubscribes from the previous track when the track changes, so old events no longer trigger close', () => { + const notifyRef: StreamControllerRef<'notify-room'> = {}; + const roomMessagesRef: StreamControllerRef<'room-messages'> = {}; + const close = jest.fn(); + const firstTrack = buildTrack(); + const secondTrack = buildTrack({ id: 'mid2:url', mid: 'mid2' }); + + const { rerender } = renderHook(({ track }) => useCloseOnTrackMessageDeleted(track, close), { + initialProps: { track: firstTrack as PersistentAudioTrack | null }, + wrapper: mockAppRoot().withStream('notify-room', notifyRef).withStream('room-messages', roomMessagesRef).build(), + }); + + rerender({ track: secondTrack }); + + notifyRef.controller?.emit(`${firstTrack.rid}/deleteMessage`, [{ _id: firstTrack.mid! }]); + + expect(close).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackMessageDeleted.ts b/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackMessageDeleted.ts new file mode 100644 index 0000000000000..bd32778dcebbc --- /dev/null +++ b/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackMessageDeleted.ts @@ -0,0 +1,105 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { useStream } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import type { PersistentAudioTrack } from './MediaPlayerContext'; +import { createDeleteCriteria } from '../../lib/utils/threadMessageUtils'; + +/** A message the player watches, and the room whose deletion events can remove it. */ +type RoomWatch = { + ids: string[]; + criteria: { message: IMessage; isOrigin: boolean }[]; +}; + +export const useCloseOnTrackMessageDeleted = (track: PersistentAudioTrack | null, close: () => void): void => { + const subscribeToNotifyRoom = useStream('notify-room'); + const subscribeToRoomMessages = useStream('room-messages'); + + const rid = track?.rid; + const mid = track?.mid; + const ts = track?.ts; + const username = track?.username; + const drid = track?.drid; + const pinned = track?.pinned; + const originMid = track?.originMid; + const originTs = track?.originTs; + const originRid = track?.originRid; + + useEffect(() => { + if (!rid || !mid) { + return; + } + + const hasOrigin = Boolean(originMid && originMid !== mid); + // Quotes stored before the origin room existed carry only an id and a timestamp. Their + // original is assumed to live in the quoting room, which is what the player watched before. + const originRoom = hasOrigin ? (originRid ?? rid) : undefined; + + const watches = new Map(); + const watchRoom = (roomId: string): RoomWatch => { + const existing = watches.get(roomId); + if (existing) { + return existing; + } + + const created: RoomWatch = { ids: [], criteria: [] }; + watches.set(roomId, created); + return created; + }; + + const trackWatch = watchRoom(rid); + trackWatch.ids.push(mid); + trackWatch.criteria.push({ message: { _id: mid, rid, ts, drid, pinned, u: { username } } as IMessage, isOrigin: false }); + + if (hasOrigin && originMid && originRoom) { + const originWatch = watchRoom(originRoom); + originWatch.ids.push(originMid); + + if (originTs) { + originWatch.criteria.push({ + message: { _id: originMid, rid: originRoom, ts: originTs } as IMessage, + isOrigin: true, + }); + } + } + + const unsubscribers = [...watches].flatMap(([roomId, { ids, criteria }]) => [ + subscribeToNotifyRoom(`${roomId}/deleteMessage`, ({ _id }) => { + if (ids.includes(_id)) { + close(); + } + }), + + subscribeToNotifyRoom(`${roomId}/deleteMessageBulk`, (params) => { + if (params.ids?.some((id) => ids.includes(id))) { + close(); + return; + } + + const matchesCriteria = createDeleteCriteria(params); + + // The playing message is matched on its full state: `drid` never changes once set, and + // `pinned` is refreshed by the provider while that message is rendered. It can still drift + // if the message is unmounted, which is accepted — leaving the player running on audio the + // server deleted, and whose file is gone, is the worse outcome. + // + // A quoted original has no such refresh: the attachment stores its room but not its author + // or pinned state, so prunes filtering on those cannot be evaluated for it. + const canEvaluate = ({ isOrigin }: { isOrigin: boolean }): boolean => + !isOrigin || (!params.users?.length && !params.excludePinned && !params.ignoreDiscussion); + + if (criteria.some((entry) => canEvaluate(entry) && matchesCriteria(entry.message))) { + close(); + } + }), + + subscribeToRoomMessages(roomId, (message) => { + if (message.t === 'rm' && ids.includes(message._id)) { + close(); + } + }), + ]); + + return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); + }, [rid, mid, ts, username, drid, pinned, originMid, originTs, originRid, subscribeToNotifyRoom, subscribeToRoomMessages, close]); +}; diff --git a/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackRoomLeft.spec.ts b/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackRoomLeft.spec.ts new file mode 100644 index 0000000000000..4cd46815e13bb --- /dev/null +++ b/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackRoomLeft.spec.ts @@ -0,0 +1,121 @@ +import { renderHook } from '@testing-library/react'; + +import type { PersistentAudioTrack } from './MediaPlayerContext'; +import { useCloseOnTrackRoomLeft } from './useCloseOnTrackRoomLeft'; + +// `notify-user`'s `subscriptions-changed` delivers two arguments, and the shared stream mock in +// `@rocket.chat/mock-providers` forwards only the first, so the stream is stubbed directly here. +type SubscriptionsChangedCallback = (event: string, subscription: { rid?: string }) => void; + +const mockUnsubscribe = jest.fn(); +const mockSubscribe = jest.fn((_eventName: string, _callback: SubscriptionsChangedCallback) => mockUnsubscribe); +const mockUserId = { current: 'john.doe' as string | null }; + +jest.mock('@rocket.chat/ui-contexts', () => ({ + useStream: () => mockSubscribe, + useUserId: () => mockUserId.current, +})); + +const lastCall = () => { + const call = mockSubscribe.mock.calls.at(-1); + if (!call) { + throw new Error('the hook did not subscribe'); + } + + return call; +}; + +const lastCallback = (): SubscriptionsChangedCallback => lastCall()[1]; +const lastEventName = (): string => lastCall()[0]; + +const buildTrack = (overrides: Partial = {}): PersistentAudioTrack => ({ + id: 'mid1:url', + url: 'https://example.com/audio.mp3', + title: 'audio.mp3', + rid: 'room1', + mid: 'mid1', + username: 'john.doe', + ts: new Date('2024-01-01T00:00:00.000Z'), + ...overrides, +}); + +beforeEach(() => { + mockUserId.current = 'john.doe'; + mockSubscribe.mockClear(); + mockUnsubscribe.mockClear(); +}); + +describe('useCloseOnTrackRoomLeft', () => { + it("subscribes to the listener's own subscriptions-changed events", () => { + renderHook(() => useCloseOnTrackRoomLeft(buildTrack(), jest.fn())); + + expect(lastEventName()).toBe('john.doe/subscriptions-changed'); + }); + + it('closes the player when the listener loses the subscription to the track room', () => { + const close = jest.fn(); + + renderHook(() => useCloseOnTrackRoomLeft(buildTrack(), close)); + lastCallback()('removed', { rid: 'room1' }); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('does not close the player when another room subscription is removed', () => { + const close = jest.fn(); + + renderHook(() => useCloseOnTrackRoomLeft(buildTrack(), close)); + lastCallback()('removed', { rid: 'other-room' }); + + expect(close).not.toHaveBeenCalled(); + }); + + it('does not close the player for subscription changes other than removal', () => { + const close = jest.fn(); + + renderHook(() => useCloseOnTrackRoomLeft(buildTrack(), close)); + lastCallback()('updated', { rid: 'room1' }); + lastCallback()('inserted', { rid: 'room1' }); + + expect(close).not.toHaveBeenCalled(); + }); + + it('leaves playback alone when the origin room of a quote is left, since the quote is still visible', () => { + const close = jest.fn(); + + renderHook(() => useCloseOnTrackRoomLeft(buildTrack({ originMid: 'mid0', originRid: 'room2' }), close)); + lastCallback()('removed', { rid: 'room2' }); + + expect(close).not.toHaveBeenCalled(); + }); + + it('does not subscribe when no track is active', () => { + renderHook(() => useCloseOnTrackRoomLeft(null, jest.fn())); + + expect(mockSubscribe).not.toHaveBeenCalled(); + }); + + it('does not subscribe when there is no logged in user', () => { + mockUserId.current = null; + + renderHook(() => useCloseOnTrackRoomLeft(buildTrack(), jest.fn())); + + expect(mockSubscribe).not.toHaveBeenCalled(); + }); + + it('unsubscribes from the previous room once the track changes', () => { + const close = jest.fn(); + + const { rerender } = renderHook(({ track }) => useCloseOnTrackRoomLeft(track, close), { + initialProps: { track: buildTrack() as PersistentAudioTrack | null }, + }); + + rerender({ track: buildTrack({ id: 'mid2:url', mid: 'mid2', rid: 'room2' }) }); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + + lastCallback()('removed', { rid: 'room1' }); + + expect(close).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackRoomLeft.ts b/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackRoomLeft.ts new file mode 100644 index 0000000000000..0b0c507610bae --- /dev/null +++ b/apps/meteor/client/providers/MediaPlayerProvider/useCloseOnTrackRoomLeft.ts @@ -0,0 +1,31 @@ +import { useStream, useUserId } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import type { PersistentAudioTrack } from './MediaPlayerContext'; + +/** + * Closes the shared player when the listener loses the room the track belongs to, by leaving it or + * being removed from it. The message is not deleted in that case, so no deletion stream reports it, + * but the audio is no longer theirs to hear. + * + * Audio played from a quote is left alone when the *origin* room goes away: the attachment is + * embedded in the quoting message, which the listener can still see. + */ +export const useCloseOnTrackRoomLeft = (track: PersistentAudioTrack | null, close: () => void): void => { + const subscribeToNotifyUser = useStream('notify-user'); + const userId = useUserId(); + + const rid = track?.rid; + + useEffect(() => { + if (!userId || !rid) { + return; + } + + return subscribeToNotifyUser(`${userId}/subscriptions-changed`, (event, subscription) => { + if (event === 'removed' && subscription.rid === rid) { + close(); + } + }); + }, [userId, rid, subscribeToNotifyUser, close]); +}; diff --git a/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx b/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx index aaf93c26ee9d0..3b988ebe040c7 100644 --- a/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx +++ b/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx @@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'; import ReportReasonCollapsible from './ReportReasonCollapsible'; import MessageContentBody from '../../../../components/message/MessageContentBody'; import Attachments from '../../../../components/message/content/Attachments'; +import type { AudioAttachmentSource } from '../../../../components/message/content/attachments/file/AudioAttachment'; import UiKitMessageBlock from '../../../../components/message/uikit/UiKitMessageBlock'; import { useFormatDate } from '../../../../hooks/useFormatDate'; import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime'; @@ -66,6 +67,16 @@ const ContextMessage = ({ const attachments = message?.attachments?.filter((attachment: MessageAttachment) => !isQuoteAttachment(attachment)) || []; + const source: AudioAttachmentSource = { + rid: message.rid, + mid: message._id, + username: message.u.username, + name: message.u.name, + ts: new Date(message.ts), + pinned: message.pinned, + drid: message.drid, + }; + return ( <> {formatDate(message._updatedAt)} @@ -84,7 +95,7 @@ const ContextMessage = ({ {room.name || room.fname || 'DM'} - {!!quotes?.length && } + {!!quotes?.length && } {!message.blocks?.length && !!message.md?.length ? ( <> {(!isEncryptedMessage || message.e2e === 'done') && ( @@ -98,7 +109,7 @@ const ContextMessage = ({ ) )} - {!!attachments && } + {!!attachments && } {message.blocks && } diff --git a/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessage.tsx b/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessage.tsx index 9816d1d00ad33..f34a1cb510f39 100644 --- a/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessage.tsx +++ b/apps/meteor/client/views/admin/moderation/hooks/useDeleteMessage.tsx @@ -3,6 +3,8 @@ import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { useMediaPlayer } from '../../../../providers/MediaPlayerProvider/MediaPlayerContext'; + const useDeleteMessage = (mid: string, rid: string, onChange: () => void) => { const { t } = useTranslation(); const deleteMessage = useEndpoint('POST', '/v1/chat.delete'); @@ -10,6 +12,7 @@ const useDeleteMessage = (mid: string, rid: string, onChange: () => void) => { const dispatchToastMessage = useToastMessageDispatch(); const setModal = useSetModal(); const queryClient = useQueryClient(); + const { track, close: closeMediaPlayer } = useMediaPlayer(); const handleDeleteMessages = useMutation({ mutationFn: deleteMessage, @@ -18,6 +21,13 @@ const useDeleteMessage = (mid: string, rid: string, onChange: () => void) => { setModal(); }, onSuccess: async () => { + // Moderation only requires `view-moderation-console`, but the `notify-room` and + // `room-messages` deletion streams are authorized against room access, so a moderator + // acting on a room they have not joined never receives the event that closes the track. + if (track?.mid === mid) { + closeMediaPlayer(); + } + await handleDismissMessage.mutateAsync({ msgId: mid }); }, }); diff --git a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx index 075da3d89214b..49c8d72b48368 100644 --- a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx +++ b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx @@ -26,6 +26,7 @@ import { useTranslation } from 'react-i18next'; import MessageContentBody from '../../../../components/message/MessageContentBody'; import StatusIndicators from '../../../../components/message/StatusIndicators'; import Attachments from '../../../../components/message/content/Attachments'; +import type { AudioAttachmentSource } from '../../../../components/message/content/attachments/file/AudioAttachment'; import UiKitMessageBlock from '../../../../components/message/uikit/UiKitMessageBlock'; import { useFormatDate } from '../../../../hooks/useFormatDate'; import { useFormatTime } from '../../../../hooks/useFormatTime'; @@ -50,6 +51,16 @@ const ContactHistoryMessage = ({ message, sequential, isNewDay, showUserAvatar } const attachments = message?.attachments?.filter((attachment: MessageAttachment) => !isQuoteAttachment(attachment)) || []; + const source: AudioAttachmentSource = { + rid: message.rid, + mid: message._id, + username: message.u.username, + name: message.u.name, + ts: message.ts, + pinned: message.pinned, + drid: message.drid, + }; + if (message.t === 'livechat-close') { return ( @@ -116,7 +127,7 @@ const ContactHistoryMessage = ({ message, sequential, isNewDay, showUserAvatar } )} - {!!quotes?.length && } + {!!quotes?.length && } {!message.blocks && ( )} {message.blocks && } - {!!attachments && } + {!!attachments && } diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxReply.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxReply.tsx index d36d4d34fa3a2..486c184fdedca 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxReply.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxReply.tsx @@ -39,6 +39,17 @@ const MessageBoxReply = ({ reply }: MessageBoxReplyProps) => { collapsed: true, } as MessageQuoteAttachment } + // Audio previewed here plays through the shared player, so it needs the quoted + // message's identity for the player to notice that message going away. + source={{ + rid: reply.rid, + mid: reply._id, + username: reply.u.username, + name: reply.u.name, + ts: reply.ts, + drid: reply.drid, + pinned: reply.pinned, + }} /> - + diff --git a/apps/meteor/client/views/room/modals/PinMessageModal/PinMessageModal.tsx b/apps/meteor/client/views/room/modals/PinMessageModal/PinMessageModal.tsx index 4fe09b2c0554b..9452b2565753b 100644 --- a/apps/meteor/client/views/room/modals/PinMessageModal/PinMessageModal.tsx +++ b/apps/meteor/client/views/room/modals/PinMessageModal/PinMessageModal.tsx @@ -31,7 +31,20 @@ const PinMessageModal = ({ message, ...props }: PinMessageModalProps) => { {t('Are_you_sure_you_want_to_pin_this_message')} - + {t('Pinned_messages_are_visible_to_everyone')} diff --git a/apps/meteor/lib/createQuoteAttachment.ts b/apps/meteor/lib/createQuoteAttachment.ts index 7f2ca4e62202a..7ad114b7d420c 100644 --- a/apps/meteor/lib/createQuoteAttachment.ts +++ b/apps/meteor/lib/createQuoteAttachment.ts @@ -16,5 +16,10 @@ export function createQuoteAttachment( author_icon: userAvatarUrl, attachments: message.attachments || [], ts: message.ts, + // Room of the quoted message, so a client can watch it for deletions even when the quote is + // rendered elsewhere. Only immutable identity is stored: `pinned` and `drid` can change after + // the quote is saved and nothing refreshes the stored attachment, so a snapshot of them here + // would go stale silently. + rid: message.rid, }; } diff --git a/apps/meteor/tests/e2e/audio-player-stop.spec.ts b/apps/meteor/tests/e2e/audio-player-stop.spec.ts new file mode 100644 index 0000000000000..0de526c920d42 --- /dev/null +++ b/apps/meteor/tests/e2e/audio-player-stop.spec.ts @@ -0,0 +1,161 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects/home-channel'; +import { createTargetChannelAndReturnFullRoom } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +// The suite is normally run against a production build; a local dev server serves an unminified +// bundle and needs noticeably longer to hydrate a room. +test.describe.configure({ timeout: 180 * 1000 }); + +const AUDIO_FILE = 'sample-audio.mp3'; + +/** + * The shared player is only meant to keep running while the audio is still the listener's to + * hear. These cover the three ways that can stop being true without the playing message itself + * being deleted in the room being viewed. + */ +test.describe('audio player stops when the audio is no longer available', () => { + let poHomeChannel: HomeChannel; + let createdRoomIds: string[] = []; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + createdRoomIds = []; + }); + + // In afterEach rather than at the end of each test: a failing test never reaches its own + // cleanup, and rooms left behind accumulate in the admin's sidebar, slowing every later run + // until the page stops hydrating within the timeout. + test.afterEach(async ({ api }) => { + await Promise.all(createdRoomIds.map((roomId) => api.post('/channels.delete', { roomId }))); + }); + + const createRoom = async (api: Parameters[0], members?: string[]) => { + const { channel } = await createTargetChannelAndReturnFullRoom(api, members ? { members } : undefined); + createdRoomIds.push(channel._id); + return channel; + }; + + /** + * The Now Playing card. Identified by the player's own slider rather than its play/pause + * button, whose accessible name flips with playback state, and scoped to the sidebar so it is + * not confused with the player rendered inside the message itself. + */ + const nowPlayingCard = (page: HomeChannel['page']) => + page.getByRole('navigation', { name: 'Sidebar' }).getByRole('slider', { name: 'Audio Playback Range' }); + + const sendAudio = async (channel: string) => { + await poHomeChannel.gotoChannel(channel); + await poHomeChannel.content.sendFileMessage(AUDIO_FILE); + // The upload lands in the composer first; sending before it is attached posts an empty message. + await expect(poHomeChannel.content.composer.getFileByName(AUDIO_FILE)).toBeVisible(); + await poHomeChannel.composer.btnSend.click(); + // An audio attachment renders its filename as plain text, not a link, so assert on the + // player itself — which is what the rest of the test needs anyway. + await expect(poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Play', exact: true })).toBeVisible(); + }; + + const lastMessageIdOf = async (api: Parameters[0], roomId: string) => { + const history = await (await api.get(`/channels.history?roomId=${roomId}&count=1`)).json(); + return history.messages[0]._id as string; + }; + + test('closes when the quoted original is deleted in another room', async ({ page, api }) => { + const originRoom = await createRoom(api); + const quotingRoom = await createRoom(api); + + await test.step('send the audio in the origin room', async () => { + await sendAudio(originRoom.name!); + }); + + await test.step('quote it from another room by pasting its permalink', async () => { + const originMessageId = await lastMessageIdOf(api, originRoom._id); + const permalink = `${new URL(page.url()).origin}/channel/${originRoom.name}?msg=${originMessageId}`; + + // Posted over REST and opened from the sidebar rather than a second `gotoChannel`: that + // helper does a full `page.goto`, and a second reload of the dev bundle is what made this + // test hang intermittently. The quote itself is built server-side either way. + expect((await api.post('/chat.postMessage', { roomId: quotingRoom._id, text: permalink })).status()).toBe(200); + await poHomeChannel.navbar.openChat(quotingRoom.name!); + + // The quote is built server-side, so this also proves the permalink resolved. + await expect(poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Play', exact: true })).toBeVisible(); + }); + + await test.step('play the audio from the quote', async () => { + await poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Play', exact: true }).click(); + await expect(nowPlayingCard(page)).toBeVisible(); + }); + + await test.step('deleting the original in its own room closes the player', async () => { + const originMessageId = await lastMessageIdOf(api, originRoom._id); + + expect((await api.post('/chat.delete', { roomId: originRoom._id, msgId: originMessageId, asUser: true })).status()).toBe(200); + + await expect(nowPlayingCard(page)).not.toBeVisible(); + }); + }); + + test('keeps playing when a prune that excludes pinned messages spares the pinned message', async ({ page, api }) => { + const targetChannel = await createRoom(api); + + await test.step('play the audio', async () => { + await sendAudio(targetChannel.name!); + await poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Play', exact: true }).click(); + await expect(nowPlayingCard(page)).toBeVisible(); + }); + + await test.step('pin the message while it is playing', async () => { + // Pinning appends a `message_pinned` system message, so the audio is no longer the last + // one afterwards — take its id first. + const audioMessageId = await lastMessageIdOf(api, targetChannel._id); + + // Pinned through the UI on purpose: the client must have processed the pin before the + // prune arrives, and an API call gives no signal for when that has happened. + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionPinMessage.click(); + await page.getByRole('button', { name: 'Yes, pin message' }).click(); + + // Separates a server-side pin failure from a player bug if this ever regresses. + await expect.poll(async () => (await (await api.get(`/chat.getMessage?msgId=${audioMessageId}`)).json()).message?.pinned).toBe(true); + }); + + await test.step('a prune excluding pinned messages leaves playback alone', async () => { + expect( + ( + await api.post('/rooms.cleanHistory', { + roomId: targetChannel._id, + excludePinned: true, + latest: new Date(Date.now() + 30 * 24 * 3600 * 1000), + oldest: new Date(Date.now() - 30 * 24 * 3600 * 1000), + }) + ).status(), + ).toBe(200); + + // The server kept the message, so the player must keep it too. Give the deletion + // streams a chance to arrive before trusting that nothing closed it. + await page.waitForTimeout(2000); + await expect(nowPlayingCard(page)).toBeVisible(); + }); + }); + + test('closes when the listener leaves the room the audio belongs to', async ({ page, api }) => { + const targetChannel = await createRoom(api, ['user1']); + + await test.step('play the audio', async () => { + await sendAudio(targetChannel.name!); + await poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Play', exact: true }).click(); + await expect(nowPlayingCard(page)).toBeVisible(); + }); + + await test.step('leave the room', async () => { + // A channel's last owner cannot leave it, so hand ownership over first. + expect((await api.post('/channels.addOwner', { roomId: targetChannel._id, userId: Users.user1.data._id })).status()).toBe(200); + expect((await api.post('/channels.leave', { roomId: targetChannel._id })).status()).toBe(200); + + await expect(nowPlayingCard(page)).not.toBeVisible(); + }); + }); +}); diff --git a/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts b/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts index 03c1fcb9cb5e5..861e6043e7f5b 100644 --- a/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts +++ b/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts @@ -11,6 +11,16 @@ export type MessageQuoteAttachment = { text: string; md?: Root; attachments?: Array; // TODO this is causing issues to define a model, see @ts-expect-error at apps/meteor/app/api/server/v1/channels.ts:274 + /** + * Room the quoted message lives in. A quote may point at a different room than the one it is + * rendered in, so this is not necessarily the room of the message carrying the attachment. + * Absent on quotes stored before this field was introduced. + * + * Only immutable identity is stored here. Mutable state such as `pinned` or `drid` would be a + * snapshot from the moment the quote was created — nothing refreshes a stored quote attachment + * when the original is later pinned or moved into a discussion — so consumers must not infer it. + */ + rid?: string; } & MessageAttachmentBase; export const isQuoteAttachment = (attachment: MessageAttachment): attachment is MessageQuoteAttachment =>