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:
@@ -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<Props, State> {
|
||||
state: State = { error: null, retryKey: 0, attempt: 0 };
|
||||
|
||||
private retryTimer: number | null = null;
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
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 <RetryingScreen escalated={escalated} attempt={this.state.attempt} />;
|
||||
}
|
||||
// `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 <Fragment key={this.state.retryKey}>{this.props.children}</Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex min-h-screen w-full items-center justify-center bg-surface-3 px-6"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="relative flex h-16 w-16 items-center justify-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 rounded-full border-2 border-accent/20"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 rounded-full border-2 border-accent border-r-transparent border-b-transparent animate-spin"
|
||||
/>
|
||||
<SpinnerIcon className="hidden" />
|
||||
</div>
|
||||
<div className="max-w-sm space-y-1.5">
|
||||
<p className="font-display text-lg font-semibold text-fg">{primary}</p>
|
||||
<p className="text-sm text-fg-muted">{secondary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user