feat(web): right-click Save Video / Save Audio via transferStore
This commit is contained in:
@@ -10,7 +10,7 @@ import { useAuthStore } from '../../stores/authStore';
|
|||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { AttachmentRenderer } from './AttachmentRenderer';
|
import { AttachmentRenderer, attUrlOf } from './AttachmentRenderer';
|
||||||
import { AttachmentProgress } from './AttachmentProgress';
|
import { AttachmentProgress } from './AttachmentProgress';
|
||||||
import { EmbedRenderer } from './EmbedRenderer';
|
import { EmbedRenderer } from './EmbedRenderer';
|
||||||
import { Username } from '../ui/Username';
|
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 isContentImage = imgEl && !imgEl.closest('[data-avatar]') && !imgEl.closest('[data-embed-thumbnail]');
|
||||||
const imageUrl = isContentImage ? imgEl.src : null;
|
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({
|
const items = buildMessageMenuItems({
|
||||||
message,
|
message,
|
||||||
selectedText,
|
selectedText,
|
||||||
previousMessageId,
|
previousMessageId,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
sourceUrl,
|
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,
|
isAuthor,
|
||||||
isDm: isDmMessage,
|
isDm: isDmMessage,
|
||||||
canAddReactions,
|
canAddReactions,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ContextMenuItem } from '../../stores/contextMenuStore';
|
|||||||
import type { MessageWithUser } from '@backspace/shared';
|
import type { MessageWithUser } from '@backspace/shared';
|
||||||
import { saveImage, copyImageToClipboard } from '../../utils/imageActions';
|
import { saveImage, copyImageToClipboard } from '../../utils/imageActions';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { useTransferStore } from '../../stores/transferStore';
|
||||||
|
|
||||||
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮'];
|
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮'];
|
||||||
|
|
||||||
@@ -23,6 +24,12 @@ interface MessageMenuParams {
|
|||||||
onMarkUnread: (messageId: string) => void;
|
onMarkUnread: (messageId: string) => void;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
sourceUrl?: 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[] {
|
export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuItem[] {
|
||||||
@@ -43,6 +50,12 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
|
|||||||
onMarkUnread,
|
onMarkUnread,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
|
videoUrl,
|
||||||
|
videoFilename,
|
||||||
|
videoSize,
|
||||||
|
audioUrl,
|
||||||
|
audioFilename,
|
||||||
|
audioSize,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const items: ContextMenuItem[] = [];
|
const items: ContextMenuItem[] = [];
|
||||||
@@ -91,6 +104,52 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
|
|||||||
items.push({ key: 'image-sep', type: 'separator' });
|
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: (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
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: (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
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) ──────────────────────
|
// ── Link Actions (when URL text is suppressed) ──────────────────────
|
||||||
if (sourceUrl) {
|
if (sourceUrl) {
|
||||||
items.push({
|
items.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user