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
This commit is contained in:
2026-04-20 15:42:49 +02:00
parent 1fab2edc57
commit de431386ea
28 changed files with 1574 additions and 150 deletions
+69 -37
View File
@@ -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 (
<ThemeProvider>
<AuthProvider>
<FriendshipsProvider>
<ConversationsProvider>
<CallProvider>
<BrowserRouter>
<Routes>
<Route path="/auth" element={<AuthPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route element={<RequireAuth />}>
<Route path="/device" element={<DevicePage />} />
<Route element={<RequireDevice />}>
<Route element={<AppShell />}>
<Route index element={<Navigate to="/chats" replace />} />
<Route path="/chats" element={<ChatsPage />}>
<Route index element={<ChatsEmptyState />} />
<Route path=":id" element={<ConversationPage />} />
</Route>
<Route path="/friends" element={<FriendsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route element={<RequireAdmin />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/chats" replace />} />
</Routes>
<UpdateToast />
</BrowserRouter>
</CallProvider>
</ConversationsProvider>
</FriendshipsProvider>
</AuthProvider>
</ThemeProvider>
<ErrorBoundary scope={scope}>
<Outlet />
</ErrorBoundary>
);
}
export function App() {
return (
<ErrorBoundary scope="root">
<ThemeProvider>
<AuthProvider>
<FriendshipsProvider>
<ConversationsProvider>
<CallProvider>
<BrowserRouter>
<Routes>
<Route element={<RouteBoundary scope="auth" />}>
<Route path="/auth" element={<AuthPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
</Route>
<Route element={<RequireAuth />}>
<Route element={<RouteBoundary scope="device" />}>
<Route path="/device" element={<DevicePage />} />
</Route>
<Route element={<RequireDevice />}>
<Route element={<AppShell />}>
<Route index element={<Navigate to="/chats" replace />} />
<Route element={<RouteBoundary scope="chats" />}>
<Route path="/chats" element={<ChatsPage />}>
<Route index element={<ChatsEmptyState />} />
<Route
path=":id"
element={
<ErrorBoundary scope="conversation">
<ConversationPage />
</ErrorBoundary>
}
/>
</Route>
</Route>
<Route element={<RouteBoundary scope="friends" />}>
<Route path="/friends" element={<FriendsPage />} />
</Route>
<Route element={<RouteBoundary scope="settings" />}>
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route element={<RequireAdmin />}>
<Route element={<RouteBoundary scope="admin" />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/chats" replace />} />
</Routes>
<UpdateToast />
</BrowserRouter>
</CallProvider>
</ConversationsProvider>
</FriendshipsProvider>
</AuthProvider>
</ThemeProvider>
</ErrorBoundary>
);
}