de431386ea
Messages: - Reply-to: hover action, composer chip with cancel, quote bubble inside the replying message with tap-to-jump + amber highlight ring - Search: header search button toggles in-conversation search bar with prev/next + match counter, auto-jump to active match - Forward: multi-select conversation picker. Attachments are now carried over: download + decrypt source, re-encrypt under each target conv-key, re-upload with fresh per-attachment keys, insert new attachment rows Conversations: - Archive + mute per member. New migration 20260420000001 adds `archived` + `muted_until` on conversation_members. Shared helpers: setConversationArchived / setConversationMutedUntil / isConversationMuted - ChatsPage: archive toggle in header with unread badge for archived bucket, split active/archived lists, muted indicator (BellOff icon, dimmed unread badge) - ConversationRowMenu via createPortal (escapes sidebar overflow clip), forwardRef-based MenuItem so submenu positioning refs survive React 18 - ConversationsContext: suppresses notification sound + OS notif when target conversation is muted - Refresh on `profiles UPDATE` realtime so peer avatar / displayName changes flow to conversation.members without manual refresh Resilience: - ErrorBoundary (Discord-style): centred spinner + escalating copy, no manual reload button. Backoff retry schedule [2s, 4s, 8s, 15s, 30s]. Uses a keyed Fragment (not a div wrapper) so flex h-full chains survive - App wrapped root + per-route RouteBoundary, conversation-level boundary - AuthContext: flip `ready` immediately on cached session read; validate getUser in background so a stalled/offline Supabase doesn't freeze the app on the loading spinner Crypto: - Swap libsodium-wrappers -> libsodium-wrappers-sumo (compact build was missing crypto_pwhash so Argon2id vault KDF threw, falling back to plaintext localStorage on every launch) - Shim d.ts for sumo types (sumo is API superset, no official types ship) - vite optimizeDeps includes sumo with the "require" condition - secureFileStore: exists(dir) check before mkdir; surface genuine permission errors instead of silent catch Tauri: - fs scope adds `$APPLOCALDATA` direct entry (not just /**) so the app data directory itself can be mkdir'd on first launch Chat layout: - Skip call_event messages when computing avatar run boundaries so a regular bubble followed by a call event from the same sender still shows its avatar
316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
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<Set<string>>(new Set());
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(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<AttachmentHandle[]>(() => {
|
|
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<string, string>();
|
|
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 (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
onClick={(e) => 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"
|
|
>
|
|
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<ForwardIcon className="h-4 w-4 text-accent" />
|
|
<h3 className="font-display text-sm font-semibold text-fg">
|
|
{t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
|
>
|
|
<XIcon className="h-4 w-4" />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="border-b border-line bg-surface-2 px-5 py-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
|
{t('app:chats.forward_preview', { defaultValue: 'Vorschau' })}
|
|
</p>
|
|
<p className="mt-1 line-clamp-3 break-words text-sm text-fg">
|
|
{preview || (sourceAttachments.length > 0 ? '📎' : '…')}
|
|
</p>
|
|
{sourceAttachments.length > 0 && (
|
|
<p className="mt-1 text-[11px] text-fg-muted">
|
|
📎{' '}
|
|
{t('app:chats.forward_attachments_count', {
|
|
count: sourceAttachments.length,
|
|
defaultValue: '{{count}} Anhang wird mit weitergeleitet',
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-2">
|
|
{targets.length === 0 ? (
|
|
<p className="px-4 py-6 text-center text-sm text-fg-muted">
|
|
{t('app:chats.forward_no_targets', {
|
|
defaultValue: 'Keine anderen Unterhaltungen verfügbar.',
|
|
})}
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-0.5">
|
|
{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 (
|
|
<li key={c.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggle(c.id)}
|
|
className={
|
|
'flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
|
(checked ? 'bg-accent/15' : 'hover:bg-surface-2')
|
|
}
|
|
>
|
|
{avatarUrl ? (
|
|
<Avatar
|
|
url={avatarUrl}
|
|
displayName={title}
|
|
className="h-9 w-9 text-sm"
|
|
/>
|
|
) : isGroup ? (
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
|
|
<UsersIcon className="h-4 w-4" />
|
|
</div>
|
|
) : (
|
|
<Avatar
|
|
url={null}
|
|
displayName={title}
|
|
className="h-9 w-9 text-sm"
|
|
/>
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
|
|
{title}
|
|
</span>
|
|
<span
|
|
aria-hidden="true"
|
|
className={
|
|
'flex h-5 w-5 shrink-0 items-center justify-center rounded border ' +
|
|
(checked ? 'border-accent bg-accent text-accent-fg' : 'border-line bg-surface-2')
|
|
}
|
|
>
|
|
{checked && (
|
|
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="20 6 9 17 4 12" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<p
|
|
role="alert"
|
|
className="border-t border-rose-500/30 bg-rose-500/10 px-5 py-2 text-xs text-rose-700 dark:text-rose-200"
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
|
>
|
|
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busy || selected.size === 0 || done}
|
|
onClick={() => void handleSend()}
|
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
|
<span>
|
|
{done
|
|
? t('app:chats.forward_done', { defaultValue: 'Gesendet' })
|
|
: t('app:chats.forward_send', {
|
|
count: selected.size,
|
|
defaultValue: 'An {{count}} senden',
|
|
})}
|
|
</span>
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|