diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx
index 2e802939..61b0dfb8 100644
--- a/packages/web/src/components/chat/Message.tsx
+++ b/packages/web/src/components/chat/Message.tsx
@@ -10,7 +10,7 @@ import { useAuthStore } from '../../stores/authStore';
import { useChatStore } from '../../stores/chatStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
-import { AttachmentRenderer } from './AttachmentRenderer';
+import { AttachmentRenderer, attUrlOf } from './AttachmentRenderer';
import { AttachmentProgress } from './AttachmentProgress';
import { EmbedRenderer } from './EmbedRenderer';
import { Username } from '../ui/Username';
@@ -248,12 +248,40 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
const isContentImage = imgEl && !imgEl.closest('[data-avatar]') && !imgEl.closest('[data-embed-thumbnail]');
const imageUrl = isContentImage ? imgEl.src : null;
+ // Detect right-click on a video/audio element and resolve its underlying attachment.
+ // `message` is the persisted form here — pending messages return early above.
+ const persistedAttachments = (message as MessageWithUser).attachments ?? [];
+ const videoEl = (e.target as HTMLElement).closest('video') as HTMLVideoElement | null;
+ const audioEl = (e.target as HTMLElement).closest('audio') as HTMLAudioElement | null;
+ const matchAttByUrl = (mediaSrc: string, kind: 'video' | 'audio') => {
+ // currentSrc is absolute; attUrlOf may be relative — compare via URL parse.
+ let mediaPath: string;
+ try {
+ mediaPath = new URL(mediaSrc, window.location.origin).pathname;
+ } catch { mediaPath = mediaSrc; }
+ return persistedAttachments.find((att) => {
+ if (!att.mimetype.startsWith(`${kind}/`)) return false;
+ const attRaw = attUrlOf(att.filename);
+ let attPath: string;
+ try { attPath = new URL(attRaw, window.location.origin).pathname; } catch { attPath = attRaw; }
+ return attPath === mediaPath;
+ }) ?? null;
+ };
+ const videoAtt = videoEl?.currentSrc ? matchAttByUrl(videoEl.currentSrc, 'video') : null;
+ const audioAtt = audioEl?.currentSrc ? matchAttByUrl(audioEl.currentSrc, 'audio') : null;
+
const items = buildMessageMenuItems({
message,
selectedText,
previousMessageId,
imageUrl,
sourceUrl,
+ videoUrl: videoAtt ? attUrlOf(videoAtt.filename) : null,
+ videoFilename: videoAtt ? videoAtt.originalName : null,
+ videoSize: videoAtt ? videoAtt.size : null,
+ audioUrl: audioAtt ? attUrlOf(audioAtt.filename) : null,
+ audioFilename: audioAtt ? audioAtt.originalName : null,
+ audioSize: audioAtt ? audioAtt.size : null,
isAuthor,
isDm: isDmMessage,
canAddReactions,
diff --git a/packages/web/src/components/chat/messageMenuItems.tsx b/packages/web/src/components/chat/messageMenuItems.tsx
index 1e5e233e..64f30f33 100644
--- a/packages/web/src/components/chat/messageMenuItems.tsx
+++ b/packages/web/src/components/chat/messageMenuItems.tsx
@@ -3,6 +3,7 @@ import type { ContextMenuItem } from '../../stores/contextMenuStore';
import type { MessageWithUser } from '@backspace/shared';
import { saveImage, copyImageToClipboard } from '../../utils/imageActions';
import { useUIStore } from '../../stores/uiStore';
+import { useTransferStore } from '../../stores/transferStore';
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮'];
@@ -23,6 +24,12 @@ interface MessageMenuParams {
onMarkUnread: (messageId: string) => void;
imageUrl?: string | null;
sourceUrl?: string | null;
+ videoUrl?: string | null;
+ videoFilename?: string | null;
+ videoSize?: number | null;
+ audioUrl?: string | null;
+ audioFilename?: string | null;
+ audioSize?: number | null;
}
export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuItem[] {
@@ -43,6 +50,12 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
onMarkUnread,
imageUrl,
sourceUrl,
+ videoUrl,
+ videoFilename,
+ videoSize,
+ audioUrl,
+ audioFilename,
+ audioSize,
} = params;
const items: ContextMenuItem[] = [];
@@ -91,6 +104,52 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
items.push({ key: 'image-sep', type: 'separator' });
}
+ // ── Video Actions (when right-clicking a video attachment) ─────────────
+ if (videoUrl && videoFilename) {
+ items.push({
+ key: 'save-video',
+ type: 'action',
+ label: 'Save Video',
+ icon: (
+
+ ),
+ onClick: () => {
+ void useTransferStore.getState().startDownload(videoUrl, {
+ filename: videoFilename,
+ size: videoSize ?? undefined,
+ mimetype: 'video/*',
+ tray: true,
+ });
+ },
+ });
+ items.push({ key: 'video-sep', type: 'separator' });
+ }
+
+ // ── Audio Actions (when right-clicking an audio attachment) ────────────
+ if (audioUrl && audioFilename) {
+ items.push({
+ key: 'save-audio',
+ type: 'action',
+ label: 'Save Audio',
+ icon: (
+
+ ),
+ onClick: () => {
+ void useTransferStore.getState().startDownload(audioUrl, {
+ filename: audioFilename,
+ size: audioSize ?? undefined,
+ mimetype: 'audio/*',
+ tray: true,
+ });
+ },
+ });
+ items.push({ key: 'audio-sep', type: 'separator' });
+ }
+
// ── Link Actions (when URL text is suppressed) ──────────────────────
if (sourceUrl) {
items.push({