import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { type AttachmentHandle, type DecryptedMessage, downloadAndDecryptAttachment, encryptAndUploadAttachment, insertAttachmentRow, parseMessagePayload, sendEncryptedMessage, } from '@chat-app/shared/chat'; import { extractErrorCode } from '@chat-app/shared/i18n'; import { bytesToPgHex } from '@chat-app/shared/supabase'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; import { useConversationsContext } from '../context/ConversationsContext'; import { devLocalSecretStore } from '../lib/secretStore'; import { supabase } from '../lib/supabase'; import { Avatar } from './Avatar'; import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons'; interface Props { open: boolean; message: DecryptedMessage | null; currentConversationId: string | null; onClose: () => void; } // Forwards a message's plaintext to one or more conversations. Attachments are // NOT carried over yet (would require re-uploading + re-encrypting under the // new conversation key); only the text payload is forwarded for now and the // preview hints at the dropped attachment. export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) { const { t } = useTranslation(['app', 'errors']); const { session, device } = useAuth(); const { conversations } = useConversationsContext(); const [selected, setSelected] = useState>(new Set()); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [done, setDone] = useState(false); useEffect(() => { if (!open) return; setSelected(new Set()); setError(null); setDone(false); }, [open, message?.id]); const targets = useMemo(() => { return conversations .filter((c) => c.id !== currentConversationId && c.acceptedByMe) .sort((a, b) => { const ta = a.lastMessageAt ?? a.createdAt; const tb = b.lastMessageAt ?? b.createdAt; return tb.localeCompare(ta); }); }, [conversations, currentConversationId]); const preview = useMemo(() => { if (!message?.plaintext) return ''; const p = parseMessagePayload(message.plaintext); if (p.kind !== 'text') return ''; return p.text.length > 140 ? p.text.slice(0, 140) + '…' : p.text; }, [message]); const sourceAttachments = useMemo(() => { if (!message?.plaintext) return []; const p = parseMessagePayload(message.plaintext); return p.kind === 'text' ? p.attachments : []; }, [message]); if (!open || !message) return null; function toggle(id: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } async function handleSend() { if (!session?.user.id || !device?.id || !message) return; if (selected.size === 0) return; setBusy(true); setError(null); try { const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id); if (!priv) throw new Error('private key not loaded'); const hasAttachments = sourceAttachments.length > 0; const text = preview || (hasAttachments ? '' : ''); if (!text && !hasAttachments) throw new Error('nothing to forward'); // Download+decrypt source attachments ONCE (same plaintext goes to every // target). For each target conv we re-encrypt under fresh per-attachment // keys and re-upload under the target conv's storage folder — source and // target conv-keys differ, so the bytes must actually move. const decryptedBlobs: { mime: string; size: number; width?: number; height?: number; blob: Blob }[] = []; for (const h of sourceAttachments) { const blob = await downloadAndDecryptAttachment({ client: supabase, handle: h }); const entry: { mime: string; size: number; width?: number; height?: number; blob: Blob; } = { mime: h.mimeType, size: h.sizeBytes, blob }; if (h.width !== undefined) entry.width = h.width; if (h.height !== undefined) entry.height = h.height; decryptedBlobs.push(entry); } for (const convId of selected) { const newHandles: AttachmentHandle[] = []; const blobNonceHex = new Map(); for (const src of decryptedBlobs) { const res = await encryptAndUploadAttachment({ client: supabase, conversationId: convId, file: src.blob, mimeType: src.mime, sizeBytes: src.size, ...(src.width !== undefined ? { width: src.width } : {}), ...(src.height !== undefined ? { height: src.height } : {}), }); newHandles.push(res.handle); blobNonceHex.set(res.handle.id, bytesToPgHex(res.nonce)); } const msg = await sendEncryptedMessage({ client: supabase, conversationId: convId, plaintext: text, senderUserId: session.user.id, senderDeviceId: device.id, senderPrivateKey: priv, ...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}), }); for (const h of newHandles) { const bn = blobNonceHex.get(h.id) ?? '\\x'; await insertAttachmentRow(supabase, msg.id, h, bn); } } setDone(true); window.setTimeout(onClose, 700); } catch (err: unknown) { const code = extractErrorCode(err); setError( code ? t('errors:' + code, { defaultValue: t('errors:generic') }) : err instanceof Error ? err.message : t('errors:generic'), ); } finally { setBusy(false); } } return (
e.stopPropagation()} className="flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl" >

{t('app:chats.forward', { defaultValue: 'Weiterleiten' })}

{t('app:chats.forward_preview', { defaultValue: 'Vorschau' })}

{preview || (sourceAttachments.length > 0 ? '📎' : '…')}

{sourceAttachments.length > 0 && (

📎{' '} {t('app:chats.forward_attachments_count', { count: sourceAttachments.length, defaultValue: '{{count}} Anhang wird mit weitergeleitet', })}

)}
{targets.length === 0 ? (

{t('app:chats.forward_no_targets', { defaultValue: 'Keine anderen Unterhaltungen verfügbar.', })}

) : (
    {targets.map((c) => { const isGroup = c.type === 'group'; const title = isGroup ? c.name ?? '?' : c.peer?.displayName ?? '?'; const avatarUrl = isGroup ? c.avatarUrl ?? null : c.peer?.avatarUrl ?? null; const checked = selected.has(c.id); return (
  • ); })}
)}
{error && (

{error}

)}
); }