From 0be69808e4fec5d5ffc8775a9ea4b39a1790051e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 2 May 2026 17:07:39 +0200 Subject: [PATCH] feat(web): right-click Save Video / Save Audio via transferStore --- packages/web/src/components/chat/Message.tsx | 30 +++++++++- .../src/components/chat/messageMenuItems.tsx | 59 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) 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({