diff --git a/.changeset/chatty-feet-ring.md b/.changeset/chatty-feet-ring.md
new file mode 100644
index 0000000000000..b955a3d298a69
--- /dev/null
+++ b/.changeset/chatty-feet-ring.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Fixes an issue where audio and video messages would stop playing if left idle past their link expiration. Now the player automatically refreshes expired links so users can continue listening or watching without reloading the chat.
diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts
index 226cbeb64a144..73f67fb473744 100644
--- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts
+++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts
@@ -589,6 +589,27 @@ export const FileUpload = {
res.end();
},
+ respondWithRedirectUrlInfo(
+ redirectUrl: string | false,
+ file: IUpload,
+ _req: http.IncomingMessage,
+ res: http.ServerResponse,
+ expiresInSeconds?: number | null,
+ ) {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
+ res.writeHead(200);
+ res.end(
+ JSON.stringify({
+ redirectUrl,
+ name: file.name,
+ type: file.type,
+ size: file.size,
+ ...(expiresInSeconds && { expires: new Date(Date.now() + expiresInSeconds * 1000).toISOString() }),
+ }),
+ );
+ },
+
proxyFile(
fileName: string,
fileUrl: string,
diff --git a/apps/meteor/app/file-upload/server/lib/requests.ts b/apps/meteor/app/file-upload/server/lib/requests.ts
index ad780116de0f8..f88b2477777c8 100644
--- a/apps/meteor/app/file-upload/server/lib/requests.ts
+++ b/apps/meteor/app/file-upload/server/lib/requests.ts
@@ -1,7 +1,23 @@
+import type { IncomingMessage } from 'http';
+
import { Uploads } from '@rocket.chat/models';
import { WebApp } from 'meteor/webapp';
import { FileUpload } from './FileUpload';
+import { SystemLogger } from '../../../../server/lib/logger/system';
+
+const hasReplyWithRedirectUrlParam = (req: IncomingMessage) => {
+ if (!req.url) {
+ return false;
+ }
+ const [, params] = req.url.split('?');
+ if (!params) {
+ return false;
+ }
+ const searchParams = new URLSearchParams(params);
+ const replyWithRedirectUrl = searchParams.get('replyWithRedirectUrl');
+ return replyWithRedirectUrl === 'true' || replyWithRedirectUrl === '1';
+};
WebApp.connectHandlers.use(FileUpload.getPath(), async (req, res, next) => {
const match = /^\/([^\/]+)\/(.*)/.exec(req.url || '');
@@ -16,6 +32,24 @@ WebApp.connectHandlers.use(FileUpload.getPath(), async (req, res, next) => {
return;
}
+ if (hasReplyWithRedirectUrlParam(req)) {
+ if (!file.store) {
+ res.writeHead(404);
+ res.end();
+ return;
+ }
+ const store = FileUpload.getStoreByName(file.store);
+ let url: string | false = false;
+ let expiryTimespan: number | null = null;
+ try {
+ url = await store.getStore().getRedirectURL(file, false);
+ expiryTimespan = await store.getStore().getUrlExpiryTimeSpan();
+ } catch (e) {
+ SystemLogger.debug(e);
+ }
+ return FileUpload.respondWithRedirectUrlInfo(url, file, req, res, expiryTimespan);
+ }
+
res.setHeader('Content-Security-Policy', "default-src 'none'");
res.setHeader('Cache-Control', 'max-age=31536000');
await FileUpload.get(file, req, res, next);
diff --git a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts
index d6b69faf75fa2..c1fec9a2a2075 100644
--- a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts
+++ b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts
@@ -190,6 +190,10 @@ class AmazonS3Store extends UploadFS.Store {
return writeStream;
};
+
+ this.getUrlExpiryTimeSpan = async () => {
+ return options.URLExpiryTimeSpan || null;
+ };
}
}
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 d24ec6ba2ff9e..227874cfb8eaf 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,9 @@
import type { AudioAttachmentProps } from '@rocket.chat/core-typings';
import { AudioPlayer } from '@rocket.chat/fuselage';
import { useMediaUrl } from '@rocket.chat/ui-contexts';
+import { useMemo } from 'react';
+import { useReloadOnError } from './hooks/useReloadOnError';
import MarkdownText from '../../../../MarkdownText';
import MessageCollapsible from '../../../MessageCollapsible';
import MessageContentBody from '../../../MessageContentBody';
@@ -18,11 +20,14 @@ const AudioAttachment = ({
collapsed,
}: AudioAttachmentProps) => {
const getURL = useMediaUrl();
+ const src = useMemo(() => getURL(url), [getURL, url]);
+ const { mediaRef } = useReloadOnError(src, 'audio');
+
return (
<>
{descriptionMd ? : }
-
+
>
);
diff --git a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx
index fedfa382de829..4768e01d41cda 100644
--- a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx
+++ b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx
@@ -1,7 +1,9 @@
import type { VideoAttachmentProps } from '@rocket.chat/core-typings';
import { Box, MessageGenericPreview } from '@rocket.chat/fuselage';
import { useMediaUrl } from '@rocket.chat/ui-contexts';
+import { useMemo } from 'react';
+import { useReloadOnError } from './hooks/useReloadOnError';
import { userAgentMIMETypeFallback } from '../../../../../lib/utils/userAgentMIMETypeFallback';
import MarkdownText from '../../../../MarkdownText';
import MessageCollapsible from '../../../MessageCollapsible';
@@ -19,13 +21,15 @@ const VideoAttachment = ({
collapsed,
}: VideoAttachmentProps) => {
const getURL = useMediaUrl();
+ const src = useMemo(() => getURL(url), [getURL, url]);
+ const { mediaRef } = useReloadOnError(src, 'video');
return (
<>
{descriptionMd ? : }
-
+
diff --git a/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.spec.tsx b/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.spec.tsx
new file mode 100644
index 0000000000000..2b1f7415ab452
--- /dev/null
+++ b/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.spec.tsx
@@ -0,0 +1,235 @@
+import { renderHook, act } from '@testing-library/react';
+
+import { useReloadOnError } from './useReloadOnError';
+import { FakeResponse } from '../../../../../../../tests/mocks/utils/FakeResponse';
+
+interface ITestMediaElement extends HTMLAudioElement {
+ _emit: (type: string) => void;
+}
+
+function makeMediaEl(): ITestMediaElement {
+ const el = document.createElement('audio') as ITestMediaElement;
+ (el as any).play = jest.fn().mockResolvedValue(undefined);
+ Object.defineProperty(el, 'paused', { value: false, configurable: true });
+ el._emit = (type: string) => el.dispatchEvent(new Event(type));
+ return el;
+}
+
+describe('useReloadOnError', () => {
+ const OLD_FETCH = global.fetch;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.spyOn(console, 'debug').mockImplementation(() => null);
+ jest.spyOn(console, 'warn').mockImplementation(() => null);
+ jest.spyOn(console, 'error').mockImplementation(() => null);
+
+ // default mock: fresh redirect URL + ISO expiry 60s ahead
+ global.fetch = jest.fn().mockResolvedValue(
+ new FakeResponse(
+ JSON.stringify({
+ redirectUrl: '/sampleurl?token=xyz',
+ expires: new Date(Date.now() + 60_000).toISOString(),
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ ) as any;
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ global.fetch = OLD_FETCH as any;
+ jest.restoreAllMocks();
+ jest.resetAllMocks();
+ });
+
+ it('refreshes media src on error and preserves playback position', async () => {
+ const original = '/sampleurl?token=abc';
+ const { result } = renderHook(() => useReloadOnError(original, 'audio'));
+
+ const media = makeMediaEl();
+ media.currentTime = 12;
+
+ act(() => {
+ result.current.mediaRef(media);
+ });
+
+ const loadSpy = jest.spyOn(media, 'load');
+
+ await act(async () => {
+ media._emit('error');
+ });
+
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ expect(media.src).toContain('/sampleurl?token=xyz');
+
+ await act(async () => {
+ media._emit('loadedmetadata');
+ media._emit('canplay');
+ });
+
+ expect(loadSpy).toHaveBeenCalled();
+ expect(media.currentTime).toBe(12);
+ expect((media as any).play).toHaveBeenCalled();
+ });
+
+ it('refreshes media src on stalled and preserves playback position', async () => {
+ const original = '/sampleurl?token=abc';
+ const { result } = renderHook(() => useReloadOnError(original, 'audio'));
+
+ const media = makeMediaEl();
+ media.currentTime = 12;
+
+ act(() => {
+ result.current.mediaRef(media);
+ });
+
+ const loadSpy = jest.spyOn(media, 'load');
+
+ await act(async () => {
+ media._emit('stalled');
+ });
+
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ expect(media.src).toContain('/sampleurl?token=xyz');
+
+ await act(async () => {
+ media._emit('loadedmetadata');
+ media._emit('canplay');
+ });
+
+ expect(loadSpy).toHaveBeenCalled();
+ expect(media.currentTime).toBe(12);
+ expect((media as any).play).toHaveBeenCalled();
+ });
+
+ it('does nothing when URL is not expired (second event before expiry)', async () => {
+ // Pin system time so Date.now() is deterministic under fake timers
+ const fixed = new Date('2030-01-01T00:00:00.000Z');
+ jest.setSystemTime(fixed);
+
+ // Backend replies with expiry 60s in the future (relative to pinned time)
+ global.fetch = jest.fn().mockResolvedValue(
+ new FakeResponse(
+ JSON.stringify({
+ redirectUrl: '/new?x=1',
+ expires: new Date(fixed.getTime() + 60_000).toISOString(),
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ ) as any;
+
+ const { result } = renderHook(() => useReloadOnError('/sampleurl?token=abc', 'audio'));
+ const media = makeMediaEl();
+
+ act(() => {
+ result.current.mediaRef(media);
+ });
+
+ // First event → fetch + set expires
+ await act(async () => {
+ media._emit('stalled');
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ // Second event before expiry → early return, no new fetch
+ await act(async () => {
+ media._emit('stalled');
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('recovers on stalled after expiry and restores seek position', async () => {
+ // Pin time
+ const fixed = new Date('2030-01-01T00:00:00.000Z');
+ jest.setSystemTime(fixed);
+
+ // 1st fetch (first recovery) -> expires in 5s
+ const firstReply = new FakeResponse(
+ JSON.stringify({
+ redirectUrl: '/fresh?token=first',
+ expires: new Date(fixed.getTime() + 5_000).toISOString(),
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+
+ // 2nd fetch (after expiry) -> new url, further expiry
+ const secondReply = new FakeResponse(
+ JSON.stringify({
+ redirectUrl: '/fresh?token=second',
+ expires: new Date(fixed.getTime() + 65_000).toISOString(),
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+
+ // Mock fetch to return first, then second
+ (global.fetch as jest.Mock) = jest.fn().mockResolvedValueOnce(firstReply).mockResolvedValueOnce(secondReply);
+
+ const { result } = renderHook(() => useReloadOnError('/sampleurl?token=old', 'audio'));
+ const media = makeMediaEl();
+ act(() => {
+ result.current.mediaRef(media);
+ });
+
+ // Initial recovery to set expiresAt (simulate an error)
+ await act(async () => {
+ media._emit('error');
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ expect(media.src).toContain('/fresh?token=first');
+
+ // Complete the ready cycle
+ await act(async () => {
+ media._emit('loadedmetadata');
+ media._emit('canplay');
+ });
+
+ // Fast-forward time beyond expiry
+ jest.setSystemTime(new Date(fixed.getTime() + 6_000));
+
+ // User scrubs to a new position just before stall is detected
+ media.currentTime = 42;
+
+ const loadSpy = jest.spyOn(media, 'load');
+
+ // Now we stall after expiry -> should trigger a new fetch
+ await act(async () => {
+ media._emit('stalled');
+ });
+
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(media.src).toContain('/fresh?token=second');
+
+ // Complete the ready cycle
+ await act(async () => {
+ media._emit('loadedmetadata');
+ media._emit('canplay');
+ });
+
+ // Ensure we reloaded and restored the seek position + playback
+ expect(loadSpy).toHaveBeenCalled();
+ expect(media.currentTime).toBe(42);
+ expect((media as any).play).toHaveBeenCalled();
+ });
+
+ it('ignores initial play when expiry is unknown', async () => {
+ // no fetch expected on first play because expiresAt is not known yet
+ global.fetch = jest.fn();
+
+ const { result } = renderHook(() => useReloadOnError('/foo', 'audio'));
+ const media = makeMediaEl();
+
+ act(() => {
+ result.current.mediaRef(media);
+ });
+
+ await act(async () => {
+ media._emit('play');
+ });
+
+ expect(global.fetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.tsx b/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.tsx
new file mode 100644
index 0000000000000..b5b73c8e2ec13
--- /dev/null
+++ b/apps/meteor/client/components/message/content/attachments/file/hooks/useReloadOnError.tsx
@@ -0,0 +1,157 @@
+import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
+import { useSafeRefCallback } from '@rocket.chat/ui-client';
+import { useCallback, useRef, useState } from 'react';
+
+const events = ['error', 'stalled', 'play'];
+
+function toURL(urlString: string): URL {
+ try {
+ return new URL(urlString);
+ } catch {
+ return new URL(urlString, window.location.href);
+ }
+}
+
+const getRedirectURLInfo = async (url: string): Promise<{ redirectUrl: string | false; expires: number | null }> => {
+ const _url = toURL(url);
+ _url.searchParams.set('replyWithRedirectUrl', 'true');
+ const response = await fetch(_url, {
+ credentials: 'same-origin',
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch URL info: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ return {
+ redirectUrl: data.redirectUrl,
+ expires: data.expires ? new Date(data.expires).getTime() : null,
+ };
+};
+
+const renderBufferingUIFallback = (vidEl: HTMLVideoElement) => {
+ const computed = getComputedStyle(vidEl);
+
+ const videoTempStyles = {
+ width: vidEl.style.width,
+ height: vidEl.style.height,
+ };
+ Object.assign(vidEl.style, {
+ width: computed.width,
+ height: computed.height,
+ });
+
+ return () => {
+ Object.assign(vidEl.style, videoTempStyles);
+ };
+};
+
+export const useReloadOnError = (url: string, type: 'video' | 'audio') => {
+ const [expiresAt, setExpiresAt] = useState(null);
+ const isRecovering = useRef(false);
+ const firstRecoveryAttempted = useRef(false);
+
+ const handleMediaURLRecovery = useEffectEvent(async (event: Event) => {
+ if (isRecovering.current) {
+ console.debug(`Media URL recovery already in progress, skipping ${event.type} event`);
+ return;
+ }
+ isRecovering.current = true;
+
+ const node = event.target as HTMLMediaElement | null;
+ if (!node) {
+ isRecovering.current = false;
+ return;
+ }
+
+ if (firstRecoveryAttempted.current) {
+ if (!expiresAt) {
+ console.debug('No expiration time set, skipping recovery');
+ isRecovering.current = false;
+ return;
+ }
+ } else if (event.type === 'play') {
+ // The user has initiated a playback for the first time, probably we should wait for the stalled or error event
+ // the url may still be valid since we dont know the expiration time yet
+ isRecovering.current = false;
+ return;
+ }
+
+ firstRecoveryAttempted.current = true;
+
+ if (expiresAt && Date.now() < expiresAt) {
+ console.debug('Media URL is still valid, skipping recovery');
+ isRecovering.current = false;
+ return;
+ }
+
+ console.debug('Handling media URL recovery for event:', event.type);
+
+ let cleanup: (() => void) | undefined;
+ if (type === 'video') {
+ cleanup = renderBufferingUIFallback(node as HTMLVideoElement);
+ }
+
+ const wasPlaying = !node.paused;
+ const { currentTime } = node;
+
+ try {
+ const { redirectUrl: newUrl, expires: newExpiresAt } = await getRedirectURLInfo(url);
+ setExpiresAt(newExpiresAt);
+ node.src = newUrl || url;
+
+ const onCanPlay = async () => {
+ node.removeEventListener('canplay', onCanPlay);
+
+ node.currentTime = currentTime;
+ if (wasPlaying) {
+ try {
+ await node.play();
+ } catch (playError) {
+ console.warn('Failed to resume playback after URL recovery:', playError);
+ } finally {
+ isRecovering.current = false;
+ }
+ }
+ };
+
+ const onMetaDataLoaded = () => {
+ node.removeEventListener('loadedmetadata', onMetaDataLoaded);
+ isRecovering.current = false;
+ cleanup?.();
+ };
+
+ node.addEventListener('canplay', onCanPlay, { once: true });
+ node.addEventListener('loadedmetadata', onMetaDataLoaded, { once: true });
+ node.load();
+ } catch (err) {
+ console.error('Error during URL recovery:', err);
+ isRecovering.current = false;
+ cleanup?.();
+ }
+ });
+
+ const mediaRefCallback = useSafeRefCallback(
+ useCallback(
+ (node: HTMLAudioElement | null) => {
+ if (!node) {
+ return;
+ }
+
+ events.forEach((event) => {
+ node.addEventListener(event, handleMediaURLRecovery);
+ });
+ return () => {
+ events.forEach((event) => {
+ node.removeEventListener(event, handleMediaURLRecovery);
+ });
+ };
+ },
+ [handleMediaURLRecovery],
+ ),
+ );
+
+ return { mediaRef: mediaRefCallback };
+};
diff --git a/apps/meteor/server/ufs/ufs-store.ts b/apps/meteor/server/ufs/ufs-store.ts
index 00979b8d934cf..5eb44fdd2a137 100644
--- a/apps/meteor/server/ufs/ufs-store.ts
+++ b/apps/meteor/server/ufs/ufs-store.ts
@@ -368,4 +368,8 @@ export class Store {
await this.onValidate(file, options);
}
}
+
+ async getUrlExpiryTimeSpan(): Promise {
+ throw new Error('getUrlExpiryTimeSpan is not implemented');
+ }
}
diff --git a/apps/meteor/tests/mocks/utils/FakeResponse.ts b/apps/meteor/tests/mocks/utils/FakeResponse.ts
new file mode 100644
index 0000000000000..8ce0fe5b59b82
--- /dev/null
+++ b/apps/meteor/tests/mocks/utils/FakeResponse.ts
@@ -0,0 +1,25 @@
+export class FakeResponse {
+ private _body: string;
+
+ status: number;
+
+ headers: Record;
+
+ constructor(body: string, init: { status?: number; headers?: Record } = {}) {
+ this._body = body;
+ this.status = init.status ?? 200;
+ this.headers = init.headers ?? {};
+ }
+
+ get ok() {
+ return this.status >= 200 && this.status < 300;
+ }
+
+ async json() {
+ return JSON.parse(this._body);
+ }
+
+ async text() {
+ return this._body;
+ }
+}