From de431386ea2ab1a5cea40dc0ce03b4822cdfa26b Mon Sep 17 00:00:00 2001 From: Dennis Landmann Date: Mon, 20 Apr 2026 15:42:49 +0200 Subject: [PATCH] feat: reply + search + forward + archive/mute + error boundary 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 --- apps/desktop/package.json | 3 +- .../src-tauri/capabilities/default.json | 1 + apps/desktop/src/App.tsx | 106 ++++-- .../src/components/ConversationHeader.tsx | 13 +- .../src/components/ConversationRowMenu.tsx | 264 +++++++++++++++ apps/desktop/src/components/ErrorBoundary.tsx | 133 ++++++++ apps/desktop/src/components/ForwardDialog.tsx | 315 ++++++++++++++++++ apps/desktop/src/components/MessageBubble.tsx | 74 +++- apps/desktop/src/components/icons.tsx | 68 ++++ apps/desktop/src/context/AuthContext.tsx | 50 +-- .../src/context/ConversationsContext.tsx | 19 +- apps/desktop/src/lib/cryptoBackend.ts | 2 +- apps/desktop/src/lib/deviceBackup.ts | 2 +- apps/desktop/src/lib/secureFileStore.ts | 13 +- .../src/lib/useConversationMessages.ts | 5 +- apps/desktop/src/pages/AdminPage.tsx | 2 +- apps/desktop/src/pages/ChatsPage.tsx | 152 +++++++-- apps/desktop/src/pages/ConversationPage.tsx | 300 +++++++++++++++-- apps/desktop/src/pages/FriendsPage.tsx | 4 +- apps/desktop/src/pages/SettingsPage.tsx | 2 +- .../src/types/libsodium-wrappers-sumo.d.ts | 9 + apps/desktop/vite.config.ts | 10 +- packages/shared/src/chat/conversations.ts | 67 +++- packages/shared/src/chat/types.ts | 5 + packages/shared/src/i18n/locales/de/app.json | 32 +- packages/shared/src/i18n/locales/en/app.json | 32 +- pnpm-lock.yaml | 25 +- .../20260420000001_archive_mute.sql | 16 + 28 files changed, 1574 insertions(+), 150 deletions(-) create mode 100644 apps/desktop/src/components/ConversationRowMenu.tsx create mode 100644 apps/desktop/src/components/ErrorBoundary.tsx create mode 100644 apps/desktop/src/components/ForwardDialog.tsx create mode 100644 apps/desktop/src/types/libsodium-wrappers-sumo.d.ts create mode 100644 supabase/migrations/20260420000001_archive_mute.sql diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 185ff8c..5306c47 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -30,7 +30,7 @@ "@tauri-apps/plugin-stronghold": "^2.0.1", "@tauri-apps/plugin-updater": "^2.10.1", "i18next": "^23.16.4", - "libsodium-wrappers": "0.7.15", + "libsodium-wrappers-sumo": "0.7.15", "livekit-client": "^2.7.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -41,6 +41,7 @@ "devDependencies": { "@tauri-apps/cli": "^2.1.0", "@types/libsodium-wrappers": "^0.7.14", + "@types/libsodium-wrappers-sumo": "^0.8.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index cc53f5d..910db65 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -34,6 +34,7 @@ { "identifier": "fs:scope", "allow": [ + { "path": "$APPLOCALDATA" }, { "path": "$APPLOCALDATA/**" } ] } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4c720ad..8db7304 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,6 +1,7 @@ -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom'; import { AppShell } from './components/AppShell'; +import { ErrorBoundary } from './components/ErrorBoundary'; import { UpdateToast } from './components/UpdateToast'; import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards'; import { AuthProvider } from './context/AuthContext'; @@ -17,42 +18,73 @@ import { DevicePage } from './pages/DevicePage'; import { FriendsPage } from './pages/FriendsPage'; import { SettingsPage } from './pages/SettingsPage'; -export function App() { +// Isolates each top-level route so a crash in one page doesn't take the whole +// shell down. The boundary auto-retries via `ErrorBoundary`'s backoff schedule. +function RouteBoundary({ scope }: { scope: string }) { return ( - - - - - - - - } /> - } /> - }> - } /> - }> - }> - } /> - }> - } /> - } /> - - } /> - } /> - }> - } /> - - - - - } /> - - - - - - - - + + + + ); +} + +export function App() { + return ( + + + + + + + + + }> + } /> + } /> + + }> + }> + } /> + + }> + }> + } /> + }> + }> + } /> + + + + } + /> + + + }> + } /> + + }> + } /> + + }> + }> + } /> + + + + + + } /> + + + + + + + + + ); } diff --git a/apps/desktop/src/components/ConversationHeader.tsx b/apps/desktop/src/components/ConversationHeader.tsx index 566a449..999c4e1 100644 --- a/apps/desktop/src/components/ConversationHeader.tsx +++ b/apps/desktop/src/components/ConversationHeader.tsx @@ -28,9 +28,10 @@ interface Props { conversation: ConversationSummary | null; peerPresence: PresenceState | null; onInfoClick?: () => void; + onSearchClick?: () => void; } -export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) { +export function ConversationHeader({ conversation, peerPresence, onInfoClick, onSearchClick }: Props) { if (!conversation) { return
; } @@ -41,6 +42,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick }: conversation={conversation} peerPresence={peerPresence} {...(onInfoClick ? { onInfoClick } : {})} + {...(onSearchClick ? { onSearchClick } : {})} /> @@ -51,9 +53,10 @@ interface HeaderBarProps { conversation: ConversationSummary; peerPresence: PresenceState | null; onInfoClick?: () => void; + onSearchClick?: () => void; } -function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps) { +function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: HeaderBarProps) { const { t } = useTranslation(['app']); const isDm = conversation.type === 'dm'; @@ -108,7 +111,11 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)

- + {!isDm && onInfoClick && ( diff --git a/apps/desktop/src/components/ConversationRowMenu.tsx b/apps/desktop/src/components/ConversationRowMenu.tsx new file mode 100644 index 0000000..bbfdb7e --- /dev/null +++ b/apps/desktop/src/components/ConversationRowMenu.tsx @@ -0,0 +1,264 @@ +import { + muteDurationToIso, + setConversationArchived, + setConversationMutedUntil, +} from '@chat-app/shared/chat'; +import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; + +import { supabase } from '../lib/supabase'; +import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons'; + +interface Props { + conversationId: string; + archived: boolean; + mutedUntil: string | null; +} + +interface MuteOption { + key: string; + labelKey: string; + labelDefault: string; + minutes: number | null; +} + +// Muted-forever sentinel ≈ 100 years. UI treats any future timestamp as muted +// until that moment; 100y is indistinguishable from "forever" at the UX level +// without requiring a dedicated `bool muted_forever` column. +const FOREVER_MINUTES = 100 * 365 * 24 * 60; + +const MUTE_OPTIONS: MuteOption[] = [ + { key: '1h', labelKey: 'app:chats.mute_1h', labelDefault: '1 Stunde', minutes: 60 }, + { key: '8h', labelKey: 'app:chats.mute_8h', labelDefault: '8 Stunden', minutes: 8 * 60 }, + { key: '24h', labelKey: 'app:chats.mute_24h', labelDefault: '24 Stunden', minutes: 24 * 60 }, + { key: '1w', labelKey: 'app:chats.mute_1w', labelDefault: '1 Woche', minutes: 7 * 24 * 60 }, + { + key: 'forever', + labelKey: 'app:chats.mute_forever', + labelDefault: 'Bis auf Weiteres', + minutes: FOREVER_MINUTES, + }, +]; + +interface MenuPos { + top: number; + left: number; +} + +// Per-conversation row context-menu. Renders via portal so the submenu can +// escape the sidebar's `overflow-y-auto` clipping context. Position is +// computed from the trigger's bounding rect — menu anchors right-aligned +// under the trigger so it doesn't push off-screen on narrow windows. +export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Props) { + const { t } = useTranslation(['app']); + const [open, setOpen] = useState(false); + const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null); + const [menuPos, setMenuPos] = useState(null); + const [submenuPos, setSubmenuPos] = useState(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const muteItemRef = useRef(null); + + useEffect(() => { + if (!open) return; + function onDocClick(e: MouseEvent) { + const target = e.target as Node; + if (triggerRef.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + setOpen(false); + setSubmenuOpen(null); + } + function onEsc(e: KeyboardEvent) { + if (e.key === 'Escape') { + setOpen(false); + setSubmenuOpen(null); + } + } + document.addEventListener('mousedown', onDocClick); + document.addEventListener('keydown', onEsc); + return () => { + document.removeEventListener('mousedown', onDocClick); + document.removeEventListener('keydown', onEsc); + }; + }, [open]); + + useEffect(() => { + if (!open) { + setMenuPos(null); + setSubmenuPos(null); + return; + } + const rect = triggerRef.current?.getBoundingClientRect(); + if (!rect) return; + // Anchor: right edge aligns with trigger's right edge, menu hangs below. + const menuWidth = 208; + setMenuPos({ + top: rect.bottom + 4, + left: Math.max(8, rect.right - menuWidth), + }); + }, [open]); + + useEffect(() => { + if (submenuOpen !== 'mute') { + setSubmenuPos(null); + return; + } + const rect = muteItemRef.current?.getBoundingClientRect(); + if (!rect) return; + const submenuWidth = 192; + const viewportWidth = window.innerWidth; + // Prefer right of the item. Flip to left when it would overflow viewport. + const wantLeft = rect.right + 4; + const flip = wantLeft + submenuWidth > viewportWidth - 8; + setSubmenuPos({ + top: rect.top, + left: flip ? rect.left - submenuWidth - 4 : wantLeft, + }); + }, [submenuOpen]); + + const isMuted = + mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now(); + + const handleArchive = useCallback( + async (next: boolean) => { + setOpen(false); + try { + await setConversationArchived(supabase, conversationId, next); + } catch (err: unknown) { + console.error('archive toggle failed', err); + } + }, + [conversationId], + ); + + const handleMute = useCallback( + async (minutes: number | null) => { + setOpen(false); + setSubmenuOpen(null); + try { + await setConversationMutedUntil( + supabase, + conversationId, + muteDurationToIso(minutes), + ); + } catch (err: unknown) { + console.error('mute toggle failed', err); + } + }, + [conversationId], + ); + + return ( + <> + + + {open && + menuPos && + createPortal( +
+ } + label={ + archived + ? t('app:chats.unarchive', { defaultValue: 'Entarchivieren' }) + : t('app:chats.archive', { defaultValue: 'Archivieren' }) + } + onClick={() => void handleArchive(!archived)} + /> + + ) : ( + + ) + } + label={ + isMuted + ? t('app:chats.unmute', { defaultValue: 'Stummschaltung aufheben' }) + : t('app:chats.mute', { defaultValue: 'Stummschalten' }) + } + onClick={() => { + if (isMuted) void handleMute(null); + else setSubmenuOpen((v) => (v === 'mute' ? null : 'mute')); + }} + hasSubmenu={!isMuted} + /> +
, + document.body, + )} + + {open && + submenuOpen === 'mute' && + submenuPos && + createPortal( +
+ {MUTE_OPTIONS.map((opt) => ( + void handleMute(opt.minutes)} + /> + ))} +
, + document.body, + )} + + ); +} + +interface MenuItemProps { + icon?: React.ReactNode; + label: string; + onClick: () => void; + hasSubmenu?: boolean; +} + +// React 18 requires forwardRef for function components to receive refs — +// without it the `ref` prop is stripped before reaching the component and +// measurement-dependent submenus never position. +const MenuItem = forwardRef( + ({ icon, label, onClick, hasSubmenu }, ref) => ( + + ), +); +MenuItem.displayName = 'MenuItem'; diff --git a/apps/desktop/src/components/ErrorBoundary.tsx b/apps/desktop/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..b7465a9 --- /dev/null +++ b/apps/desktop/src/components/ErrorBoundary.tsx @@ -0,0 +1,133 @@ +import { Component, type ErrorInfo, Fragment, type ReactNode } from 'react'; + +import { SpinnerIcon } from './icons'; + +interface Props { + children: ReactNode; + /** + * Optional scope label shown in logs / devtools. Defaults to `root` — set + * per boundary (e.g. `route`, `conversation`) so multiple boundaries can be + * distinguished at a glance. + */ + scope?: string; + /** + * If the retry count exceeds this, the boundary stops auto-retrying and + * shows a more helpful message (still without a button — Discord-style, + * the app keeps trying but hints the user to hold on or check network). + */ + maxAutoRetries?: number; +} + +interface State { + error: Error | null; + retryKey: number; + attempt: number; +} + +const RETRY_DELAYS_MS = [2000, 4000, 8000, 15000, 30000]; + +// Discord-style error boundary. +// - Catches render-time errors in its subtree. +// - Shows a centred spinner + status text. Never renders a manual "Reload" +// button; the boundary remounts its children on an exponential-backoff +// schedule so the UI self-heals once the underlying issue clears (typical +// causes: a realtime reconnect, a transient network blip, or a race that +// only fires once). +// - Escalates the label after each failed retry so the user sees that the +// app is trying, rather than silent infinite spinning. +export class ErrorBoundary extends Component { + state: State = { error: null, retryKey: 0, attempt: 0 }; + + private retryTimer: number | null = null; + + static getDerivedStateFromError(error: Error): Partial { + return { error }; + } + + override componentDidCatch(error: Error, info: ErrorInfo): void { + const scope = this.props.scope ?? 'root'; + // We explicitly log here — the boundary itself swallows the error from + // React, so without this the failure would be invisible in production. + console.error('[ErrorBoundary:' + scope + '] caught render error', error, info); + } + + override componentDidUpdate(_prev: Props, prevState: State): void { + if (this.state.error && !prevState.error) { + this.scheduleRetry(); + } + } + + override componentWillUnmount(): void { + if (this.retryTimer !== null) { + window.clearTimeout(this.retryTimer); + this.retryTimer = null; + } + } + + private scheduleRetry(): void { + if (this.retryTimer !== null) return; + const attempt = this.state.attempt; + const delay = + RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] ?? + RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] ?? + 30000; + this.retryTimer = window.setTimeout(() => { + this.retryTimer = null; + this.setState((prev) => ({ + error: null, + retryKey: prev.retryKey + 1, + attempt: prev.attempt + 1, + })); + }, delay); + } + + override render(): ReactNode { + if (this.state.error) { + const max = this.props.maxAutoRetries ?? RETRY_DELAYS_MS.length; + const escalated = this.state.attempt >= max; + return ; + } + // `retryKey` forces a remount of the subtree so hooks re-run cleanly after + // an error (otherwise stale state from the crashed tree can immediately + // re-throw). Use a keyed Fragment so the boundary doesn't inject an extra + // wrapper div — that would break `flex h-full` chains (e.g. AppShell → + // Outlet → page column). + return {this.props.children}; + } +} + +function RetryingScreen({ escalated, attempt }: { escalated: boolean; attempt: number }) { + const primary = escalated + ? 'Verbindungsprobleme…' + : attempt === 0 + ? 'Einen Moment bitte' + : 'Versuche erneut zu laden…'; + const secondary = escalated + ? 'Prüfe deine Internetverbindung. Wir versuchen es weiter.' + : 'Die App lädt sich gleich selbst neu.'; + return ( +
+
+
+
+
+

{primary}

+

{secondary}

+
+
+
+ ); +} diff --git a/apps/desktop/src/components/ForwardDialog.tsx b/apps/desktop/src/components/ForwardDialog.tsx new file mode 100644 index 0000000..9043009 --- /dev/null +++ b/apps/desktop/src/components/ForwardDialog.tsx @@ -0,0 +1,315 @@ +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} +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/apps/desktop/src/components/MessageBubble.tsx b/apps/desktop/src/components/MessageBubble.tsx index d78a130..b4c2303 100644 --- a/apps/desktop/src/components/MessageBubble.tsx +++ b/apps/desktop/src/components/MessageBubble.tsx @@ -15,11 +15,19 @@ import { supabase } from '../lib/supabase'; import type { AggregatedReaction } from '../lib/useMessageReactions'; import { AttachmentImage } from './AttachmentImage'; import { Avatar } from './Avatar'; -import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons'; +import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons'; const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏']; const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000; +export interface QuotedRef { + id: string; + senderName: string; + snippet: string; + isAttachment: boolean; + deleted: boolean; +} + interface Props { message: DecryptedMessage; mine: boolean; @@ -32,6 +40,16 @@ interface Props { reactions: AggregatedReaction[]; onToggleReaction: (emoji: string) => Promise; showSeen?: boolean; + /** Resolved quoted message info (parent does the lookup). */ + quoted?: QuotedRef | null; + /** Tap-to-jump on quote bubble. Receives the quoted message's id. */ + onJumpToMessage?: (id: string) => void; + /** Hover action: parent receives current message to start a reply. */ + onReply?: (m: DecryptedMessage) => void; + /** Hover action: parent opens forward dialog for current message. */ + onForward?: (m: DecryptedMessage) => void; + /** Highlighted state — set briefly after a jump. */ + highlighted?: boolean; } export function MessageBubble({ @@ -45,6 +63,11 @@ export function MessageBubble({ reactions, onToggleReaction, showSeen = false, + quoted = null, + onJumpToMessage, + onReply, + onForward, + highlighted = false, }: Props) { const { t } = useTranslation(['app']); const { session, device } = useAuth(); @@ -228,13 +251,46 @@ export function MessageBubble({ ) : (
+ {quoted && ( + + )} {message.plaintext === null ? ( …cannot decrypt ) : ( @@ -299,6 +355,20 @@ export function MessageBubble({ onClick={() => setPickerOpen((v) => !v)} icon={} /> + {onReply && !message.deletedAt && ( + onReply(message)} + icon={} + /> + )} + {onForward && !message.deletedAt && ( + onForward(message)} + icon={} + /> + )} {canEdit && ( + + + + + ); +} + +export function BellIcon(props: IconProps) { + return ( + + + + + ); +} + +export function BellOffIcon(props: IconProps) { + return ( + + + + + + + + + ); +} + +export function MoreVerticalIcon(props: IconProps) { + return ( + + + + + + ); +} + +export function ReplyIcon(props: IconProps) { + return ( + + + + + ); +} + +export function ForwardIcon(props: IconProps) { + return ( + + + + + ); +} + +export function ChevronUpIcon(props: IconProps) { + return ( + + + + ); +} + export function AddUserIcon(props: IconProps) { return ( diff --git a/apps/desktop/src/context/AuthContext.tsx b/apps/desktop/src/context/AuthContext.tsx index 29a7516..bfd1ece 100644 --- a/apps/desktop/src/context/AuthContext.tsx +++ b/apps/desktop/src/context/AuthContext.tsx @@ -54,30 +54,34 @@ export function AuthProvider({ children }: { children: ReactNode }) { (async () => { const { data: sessionRes } = await supabase.auth.getSession(); if (cancelled) return; - if (sessionRes.session) { - const { error } = await supabase.auth.getUser(); - if (cancelled) return; - if (error) { - const status = (error as { status?: number }).status; - if (status === 401 || status === 403) { - // Token genuinely invalid — wipe. - await supabase.auth.signOut({ scope: 'local' }).catch(() => { - /* ignore */ - }); - setSession(null); - } else { - // Network / server unreachable — keep cached session, let reads - // fail gracefully and recover when the stack is back. - console.warn('auth.getUser failed, keeping cached session:', error); - setSession(sessionRes.session); - } - } else { - setSession(sessionRes.session); - } - } else { - setSession(null); - } + // Flip `ready` immediately on cached session read so the UI unblocks even + // if the network is slow/down. Validate the token in the background and + // only wipe on an unambiguous 401/403 — a stalled getUser (Tauri WebView + // with no network, server unreachable) must not keep the app on the + // loading spinner forever. + setSession(sessionRes.session ?? null); setReady(true); + + if (sessionRes.session) { + supabase.auth + .getUser() + .then(({ error }) => { + if (cancelled || !error) return; + const status = (error as { status?: number }).status; + if (status === 401 || status === 403) { + void supabase.auth.signOut({ scope: 'local' }).catch(() => { + /* ignore */ + }); + setSession(null); + } else { + // Network / server unreachable — keep cached session. + console.warn('auth.getUser failed, keeping cached session:', error); + } + }) + .catch((err: unknown) => { + console.warn('auth.getUser rejected, keeping cached session:', err); + }); + } })(); const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => { setSession(s); diff --git a/apps/desktop/src/context/ConversationsContext.tsx b/apps/desktop/src/context/ConversationsContext.tsx index 7b070e0..d3549b2 100644 --- a/apps/desktop/src/context/ConversationsContext.tsx +++ b/apps/desktop/src/context/ConversationsContext.tsx @@ -1,4 +1,8 @@ -import { type ConversationSummary, listConversations } from '@chat-app/shared/chat'; +import { + type ConversationSummary, + isConversationMuted, + listConversations, +} from '@chat-app/shared/chat'; import { createContext, type ReactNode, @@ -177,10 +181,15 @@ export function ConversationsProvider({ children }: { children: ReactNode }) { ...prev, [row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1, })); - // Notification sound + OS notification — respect DND. Body stays - // empty because message content is E2E-encrypted and only - // decryptable in the conversation view (not at this hook level). - if (presenceRef.current !== 'dnd') { + // Notification sound + OS notification — respect DND and + // per-conversation mute. Body stays empty because message + // content is E2E-encrypted and only decryptable in the + // conversation view (not at this hook level). + const convForMute = conversationsRef.current.find( + (c) => c.id === row.conversation_id, + ); + const muted = isConversationMuted(convForMute?.mutedUntil ?? null); + if (presenceRef.current !== 'dnd' && !muted) { playNotificationTone(); const conv = conversationsRef.current.find( (c) => c.id === row.conversation_id, diff --git a/apps/desktop/src/lib/cryptoBackend.ts b/apps/desktop/src/lib/cryptoBackend.ts index 3a8306e..776fea0 100644 --- a/apps/desktop/src/lib/cryptoBackend.ts +++ b/apps/desktop/src/lib/cryptoBackend.ts @@ -1,5 +1,5 @@ import type { CryptoBackend } from '@chat-app/shared/crypto'; -import _sodium from 'libsodium-wrappers'; +import _sodium from 'libsodium-wrappers-sumo'; // Build the libsodium-backed CryptoBackend. Awaits the WASM ready-gate once, // then returns a synchronous implementation of the CryptoBackend contract. diff --git a/apps/desktop/src/lib/deviceBackup.ts b/apps/desktop/src/lib/deviceBackup.ts index 68adfca..b3569c3 100644 --- a/apps/desktop/src/lib/deviceBackup.ts +++ b/apps/desktop/src/lib/deviceBackup.ts @@ -1,5 +1,5 @@ import { getCryptoBackend } from '@chat-app/shared/crypto'; -import sodium from 'libsodium-wrappers'; +import sodium from 'libsodium-wrappers-sumo'; // Encrypts/decrypts the device private key with a user-provided passphrase // so the backup string can be safely written down or stored in a password diff --git a/apps/desktop/src/lib/secureFileStore.ts b/apps/desktop/src/lib/secureFileStore.ts index e132d3a..fd4434e 100644 --- a/apps/desktop/src/lib/secureFileStore.ts +++ b/apps/desktop/src/lib/secureFileStore.ts @@ -1,7 +1,9 @@ import type { SecretStore } from '@chat-app/shared/auth'; import { exists, mkdir, readFile, rename, writeFile } from '@tauri-apps/plugin-fs'; import { appLocalDataDir } from '@tauri-apps/api/path'; -import sodium from 'libsodium-wrappers'; +// sumo variant ships crypto_pwhash (Argon2id). Standard `libsodium-wrappers` +// is the compact build without Argon2 — vault KDF would error otherwise. +import sodium from 'libsodium-wrappers-sumo'; // Encrypted single-file SecretStore for Tauri. Replaces the flaky // tauri-plugin-stronghold implementation. @@ -93,10 +95,13 @@ async function loadOrCreateVault(userId: string): Promise { const path = joinPath(dir, fileName); const tmpPath = path + '.tmp'; - try { + // First-run: AppLocalData dir may not exist yet. `mkdir(recursive)` is + // idempotent on macOS/Linux, but we need to surface genuine permission + // errors (silent catch masked a previous bug where the dir was never + // created and every subsequent writeFile failed with ENOENT). + const dirExists = await exists(dir).catch(() => false); + if (!dirExists) { await mkdir(dir, { recursive: true }); - } catch { - /* parent likely already exists */ } const fileExists = await exists(path).catch(() => false); diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts index de8ae6a..28fecc2 100644 --- a/apps/desktop/src/lib/useConversationMessages.ts +++ b/apps/desktop/src/lib/useConversationMessages.ts @@ -51,7 +51,7 @@ function rowToMessage(row: Record): MessageWithCipher { } export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & { - send: (text: string, images?: File[]) => Promise; + send: (text: string, images?: File[], replyToId?: string | null) => Promise; refresh: () => Promise; } { const [state, setState] = useState({ messages: [], loading: true, error: null }); @@ -262,7 +262,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar }, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]); const send = useCallback( - async (text: string, images: File[] = []) => { + async (text: string, images: File[] = [], replyToId: string | null = null) => { const trimmed = text.trim(); if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return; const priv = privateKeyRef.current; @@ -299,6 +299,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar senderDeviceId: deviceId, senderPrivateKey: priv, ...(handles.length > 0 ? { attachmentHandles: handles } : {}), + ...(replyToId ? { replyToId } : {}), }); // 3. Optimistic insert — we already have the plaintext in hand and the diff --git a/apps/desktop/src/pages/AdminPage.tsx b/apps/desktop/src/pages/AdminPage.tsx index a90d269..8c761e5 100644 --- a/apps/desktop/src/pages/AdminPage.tsx +++ b/apps/desktop/src/pages/AdminPage.tsx @@ -65,7 +65,7 @@ export function AdminPage() { }, [refresh]); return ( -
+

diff --git a/apps/desktop/src/pages/ChatsPage.tsx b/apps/desktop/src/pages/ChatsPage.tsx index 755c979..5bdbc69 100644 --- a/apps/desktop/src/pages/ChatsPage.tsx +++ b/apps/desktop/src/pages/ChatsPage.tsx @@ -1,12 +1,19 @@ -import { acceptDm, type ConversationSummary } from '@chat-app/shared/chat'; +import { + acceptDm, + type ConversationSummary, + isConversationMuted, +} from '@chat-app/shared/chat'; import { extractErrorCode } from '@chat-app/shared/i18n'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { NavLink, Outlet, useParams } from 'react-router-dom'; +import { ConversationRowMenu } from '../components/ConversationRowMenu'; import { CreateGroupDialog } from '../components/CreateGroupDialog'; import { AddUserIcon, + ArchiveIcon, + BellOffIcon, ChatBubbleIcon, SearchIcon, SpinnerIcon, @@ -21,6 +28,7 @@ export function ChatsPage() { const { id: activeId } = useParams<{ id: string }>(); const [createOpen, setCreateOpen] = useState(false); const [query, setQuery] = useState(''); + const [showArchived, setShowArchived] = useState(false); const sorted = useMemo(() => { return [...conversations].sort((a, b) => { @@ -30,7 +38,7 @@ export function ChatsPage() { }); }, [conversations]); - const filtered = useMemo(() => { + const queryFiltered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return sorted; return sorted.filter((c) => { @@ -40,10 +48,24 @@ export function ChatsPage() { }); }, [sorted, query]); + // Split into active vs archived — the user toggles which bucket shows in the + // main list. Archived conversations with unread messages still surface so + // the user can't accidentally silence an ongoing conversation permanently. + const activeItems = useMemo( + () => queryFiltered.filter((c) => !c.archived), + [queryFiltered], + ); + const archivedItems = useMemo( + () => queryFiltered.filter((c) => c.archived), + [queryFiltered], + ); + + const archivedUnread = archivedItems.reduce((s, c) => s + (unread[c.id] ?? 0), 0); + return (
setCreateOpen(true)} + showArchived={showArchived} + onToggleArchived={() => setShowArchived((v) => !v)} + archivedUnread={archivedUnread} />
@@ -79,6 +104,9 @@ interface ConversationListProps { onQueryChange: (q: string) => void; onAccept: (id: string) => void; onNewGroup: () => void; + showArchived: boolean; + onToggleArchived: () => void; + archivedUnread: number; } function ConversationList({ @@ -91,6 +119,9 @@ function ConversationList({ onQueryChange, onAccept, onNewGroup, + showArchived, + onToggleArchived, + archivedUnread, }: ConversationListProps) { const { t } = useTranslation(['app']); @@ -101,17 +132,53 @@ function ConversationList({ >

- {t('app:nav.chats')} + {showArchived + ? t('app:chats.archived_title', { defaultValue: 'Archiv' }) + : t('app:nav.chats')}

- +
+ + {!showArchived && ( + + )} +
@@ -140,10 +207,24 @@ function ConversationList({ ) : items.length === 0 ? (
- + {showArchived ? ( + + ) : ( + + )}
-

{t('app:chats.empty_title')}

-

{t('app:chats.empty_subtitle')}

+

+ {showArchived + ? t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' }) + : t('app:chats.empty_title')} +

+

+ {showArchived + ? t('app:chats.archived_empty_subtitle', { + defaultValue: 'Archivierte Unterhaltungen erscheinen hier.', + }) + : t('app:chats.empty_subtitle')} +

) : (
    @@ -213,11 +294,12 @@ function ConversationRow({ ); } + const muted = isConversationMuted(item.mutedUntil); return (
    -

    0 ? 'font-bold' : 'font-semibold') - } - > - {title} -

    +
    +

    0 && !muted ? 'font-bold' : 'font-semibold') + } + > + {title} +

    + {muted && ( +

    {preview}

    {unreadCount > 0 && ( {unreadCount > 99 ? '99+' : unreadCount} )} +
    ); } diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index bc3a9bd..c039984 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -5,12 +5,24 @@ import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { ConversationHeader } from '../components/ConversationHeader'; +import { ForwardDialog } from '../components/ForwardDialog'; import { GroupInfoPanel } from '../components/GroupInfoPanel'; -import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons'; +import { + AlertIcon, + ArrowRightIcon, + ChevronDownIcon, + ChevronUpIcon, + PlusIcon, + ReplyIcon, + SearchIcon, + SpinnerIcon, + XIcon, +} from '../components/icons'; import { InCallPanel } from '../components/InCallPanel'; import { IncomingCallPanel } from '../components/IncomingCallPanel'; -import { MessageBubble } from '../components/MessageBubble'; +import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; import { TypingIndicator } from '../components/TypingIndicator'; +import type { DecryptedMessage } from '@chat-app/shared/chat'; import { useAuth } from '../context/AuthContext'; import { useCall } from '../context/CallContext'; import { useConversationsContext } from '../context/ConversationsContext'; @@ -83,8 +95,114 @@ export function ConversationPage() { const [stickToBottom, setStickToBottom] = useState(true); const [attachments, setAttachments] = useState([]); const [infoPanelOpen, setInfoPanelOpen] = useState(false); + const [replyTo, setReplyTo] = useState(null); + const [forwardTarget, setForwardTarget] = useState(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [searchIdx, setSearchIdx] = useState(0); + const [highlightedId, setHighlightedId] = useState(null); const scrollRef = useRef(null); const fileInputRef = useRef(null); + const composerRef = useRef(null); + + // Drop reply-to / clear search state when switching conversation. + useEffect(() => { + setReplyTo(null); + setForwardTarget(null); + setSearchOpen(false); + setSearchQuery(''); + }, [id]); + + const messageById = useMemo(() => { + const m = new Map(); + for (const msg of messages) m.set(msg.id, msg); + return m; + }, [messages]); + + const senderNameFor = useCallback( + (senderId: string): string => { + if (senderId === myId) return t('app:chats.you', { defaultValue: 'Du' }); + const profile = + conversation?.members.find((mm) => mm.userId === senderId)?.profile ?? + (senderId !== myId ? conversation?.peer ?? null : null); + return profile?.displayName ?? '?'; + }, + [conversation, myId, t], + ); + + const buildQuoted = useCallback( + (replyToId: string | null): QuotedRef | null => { + if (!replyToId) return null; + const target = messageById.get(replyToId); + if (!target) { + return { + id: replyToId, + senderName: '…', + snippet: t('app:chats.quote_unavailable', { defaultValue: 'Nachricht nicht verfügbar' }), + isAttachment: false, + deleted: true, + }; + } + const parsed = parseMessagePayload(target.plaintext); + const text = parsed.kind === 'text' ? parsed.text : ''; + const hasAttachment = parsed.kind === 'text' && parsed.attachments.length > 0; + return { + id: target.id, + senderName: senderNameFor(target.senderId), + snippet: text.length > 120 ? text.slice(0, 120) + '…' : text, + isAttachment: hasAttachment, + deleted: !!target.deletedAt, + }; + }, + [messageById, senderNameFor, t], + ); + + const jumpToMessage = useCallback((targetId: string) => { + const el = scrollRef.current?.querySelector( + '[data-message-id="' + CSS.escape(targetId) + '"]', + ); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setHighlightedId(targetId); + window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600); + }, []); + + const handleReply = useCallback((m: DecryptedMessage) => { + setReplyTo(m); + composerRef.current?.focus(); + }, []); + + const handleForward = useCallback((m: DecryptedMessage) => { + setForwardTarget(m); + }, []); + + // Search matches: messages whose decrypted text includes the query. + const searchMatches = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return [] as DecryptedMessage[]; + return messages.filter((m) => { + if (!m.plaintext) return false; + const parsed = parseMessagePayload(m.plaintext); + if (parsed.kind !== 'text') return false; + return parsed.text.toLowerCase().includes(q); + }); + }, [messages, searchQuery]); + + // Reset/clamp the active match index when the match set changes. + useEffect(() => { + if (searchMatches.length === 0) { + setSearchIdx(0); + return; + } + setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1)); + }, [searchMatches.length]); + + // Auto-jump to current match. + useEffect(() => { + if (!searchOpen || searchMatches.length === 0) return; + const target = searchMatches[searchIdx]; + if (target) jumpToMessage(target.id); + }, [searchOpen, searchMatches, searchIdx, jumpToMessage]); useEffect(() => { if (!id) return; @@ -123,9 +241,10 @@ export function ConversationPage() { setSending(true); setSendError(null); try { - await send(text, attachments); + await send(text, attachments, replyTo?.id ?? null); setText(''); setAttachments([]); + setReplyTo(null); if (fileInputRef.current) fileInputRef.current.value = ''; setStickToBottom(true); notifyStopTyping(); @@ -178,10 +297,32 @@ export function ConversationPage() { setSearchOpen((v) => !v)} {...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})} /> )} + {!callHereActive && searchOpen && ( + + setSearchIdx((cur) => + searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length, + ) + } + onNext={() => + setSearchIdx((cur) => (searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length)) + } + onClose={() => { + setSearchOpen(false); + setSearchQuery(''); + }} + /> + )} + {isGroup && conversation && ( {messages.map((m, idx) => { - // Skip call_event messages when computing run boundaries — they - // render as centred separators, not chat bubbles, so they - // shouldn't count toward sender continuity. Without this, - // a real bubble followed by a call event from the same sender - // would be treated as "in the middle of a run" and lose its - // avatar. - const prev = findAdjacent(messages, idx, -1); - const next = findAdjacent(messages, idx, +1); - const grouped = !!prev && prev.senderId === m.senderId; + // A "run" is consecutive bubbles from the same sender with + // nothing between them. Call-event separators break the run — + // a bubble whose immediate next neighbour is a call_event must + // anchor the avatar, even if another bubble from the same + // sender appears after the separator. + const prevRaw = messages[idx - 1]; + const nextRaw = messages[idx + 1]; + const prevIsCallEvent = + !!prevRaw && parseMessagePayload(prevRaw.plaintext).kind === 'call_event'; + const nextIsCallEvent = + !!nextRaw && parseMessagePayload(nextRaw.plaintext).kind === 'call_event'; + const grouped = + !!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent; // Anchor avatar on the LAST message of a run so it aligns with // the bubble's tail (bottom corner). Tail is bottom-left for // mine, bottom-right for peer — see rounded-[…_4px_…] above. - const isLastOfRun = !next || next.senderId !== m.senderId; + const isLastOfRun = + !nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent; // DM fallback: if member lookup fails (e.g. transient sync), fall // back to conversation.peer so the peer's avatar still resolves. const memberProfile = @@ -238,6 +384,11 @@ export function ConversationPage() { reactions={reactionsByMessage.get(m.id) ?? []} onToggleReaction={(emoji) => toggleReaction(m.id, emoji)} showSeen={m.id === lastSeenMessageId} + quoted={buildQuoted(m.replyToId)} + onJumpToMessage={jumpToMessage} + onReply={handleReply} + onForward={handleForward} + highlighted={highlightedId === m.id} /> ); @@ -258,6 +409,38 @@ export function ConversationPage() {
)} + {replyTo && ( +
+
+ )} + {attachments.length > 0 && (
{attachments.map((file, idx) => ( @@ -291,6 +474,7 @@ export function ConversationPage() {