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:
@@ -28,13 +28,21 @@ async function currentUserId(client: AppSupabaseClient): Promise<string> {
|
||||
export async function listConversations(client: AppSupabaseClient): Promise<ConversationSummary[]> {
|
||||
const myId = await currentUserId(client);
|
||||
|
||||
// 1. Caller's memberships
|
||||
// 1. Caller's memberships. `archived` / `muted_until` live on the members
|
||||
// row (see migration 20260420000001). db-types snapshot predates them so
|
||||
// cast the select to bypass typing.
|
||||
const { data: myMembers, error: mErr } = await client
|
||||
.from('conversation_members')
|
||||
.select('conversation_id, role, accepted')
|
||||
.select('conversation_id, role, accepted, archived, muted_until' as '*')
|
||||
.eq('user_id', myId);
|
||||
if (mErr) throw mErr;
|
||||
const myMembersList = myMembers ?? [];
|
||||
const myMembersList = (myMembers ?? []) as unknown as Array<{
|
||||
conversation_id: string;
|
||||
role: string;
|
||||
accepted: boolean;
|
||||
archived: boolean | null;
|
||||
muted_until: string | null;
|
||||
}>;
|
||||
if (myMembersList.length === 0) return [];
|
||||
|
||||
const convIds = myMembersList.map((m) => m.conversation_id);
|
||||
@@ -103,6 +111,9 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
||||
c.type === 'dm'
|
||||
? (members.find((m) => m.userId !== myId)?.profile ?? null)
|
||||
: null;
|
||||
const mineRow = mine as
|
||||
| { accepted: boolean; role: string; archived?: boolean; muted_until?: string | null }
|
||||
| undefined;
|
||||
return {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
@@ -110,10 +121,56 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
||||
avatarUrl: c.avatar_url,
|
||||
createdAt: c.created_at,
|
||||
peer,
|
||||
acceptedByMe: mine?.accepted ?? false,
|
||||
myRole: mine?.role ?? 'member',
|
||||
acceptedByMe: mineRow?.accepted ?? false,
|
||||
myRole: (mineRow?.role ?? 'member') as ConversationSummary['myRole'],
|
||||
members,
|
||||
lastMessageAt: lastSeen.get(c.id) ?? null,
|
||||
archived: mineRow?.archived ?? false,
|
||||
mutedUntil: mineRow?.muted_until ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle archive flag on the caller's own conversation_members row.
|
||||
export async function setConversationArchived(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
archived: boolean,
|
||||
): Promise<void> {
|
||||
const myId = await currentUserId(client);
|
||||
const { error } = await client
|
||||
.from('conversation_members')
|
||||
.update({ archived } as never)
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('user_id', myId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Set mute until a specific ISO timestamp (null clears the mute). A
|
||||
// far-future timestamp is the "muted forever" representation.
|
||||
export async function setConversationMutedUntil(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
until: string | null,
|
||||
): Promise<void> {
|
||||
const myId = await currentUserId(client);
|
||||
const { error } = await client
|
||||
.from('conversation_members')
|
||||
.update({ muted_until: until } as never)
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('user_id', myId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Convenience: `null` unmutes, number means minutes from now. For "forever"
|
||||
// pass a very large number (e.g. 100 years worth of minutes).
|
||||
export function muteDurationToIso(minutes: number | null): string | null {
|
||||
if (minutes === null) return null;
|
||||
return new Date(Date.now() + minutes * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
// True iff the member is currently muted (mutedUntil present and > now).
|
||||
export function isConversationMuted(mutedUntil: string | null): boolean {
|
||||
if (!mutedUntil) return false;
|
||||
return new Date(mutedUntil).getTime() > Date.now();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface ConversationSummary {
|
||||
members: ConversationMember[];
|
||||
// Latest message timestamp (server can't see content, only metadata).
|
||||
lastMessageAt: string | null;
|
||||
// Caller's per-member preferences.
|
||||
archived: boolean;
|
||||
// ISO timestamp. null = not muted. Past timestamp = expired mute (treat as
|
||||
// not muted — the server row is kept for history until the next toggle).
|
||||
mutedUntil: string | null;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
@@ -33,7 +33,37 @@
|
||||
"call_incoming": "Eingehender Anruf",
|
||||
"call_missed": "Verpasster Anruf",
|
||||
"call_no_answer": "Keine Antwort",
|
||||
"call_declined": "Anruf abgelehnt"
|
||||
"call_declined": "Anruf abgelehnt",
|
||||
"you": "Du",
|
||||
"attachment": "Anhang",
|
||||
"reply": "Antworten",
|
||||
"forward": "Weiterleiten",
|
||||
"replying_to": "Antwort an {{name}}",
|
||||
"quote_unavailable": "Nachricht nicht verfügbar",
|
||||
"search_in_conv": "In Unterhaltung suchen…",
|
||||
"search_none": "Keine Treffer",
|
||||
"forward_preview": "Vorschau",
|
||||
"forward_attachments_dropped": "Anhänge werden nicht mit weitergeleitet.",
|
||||
"forward_no_targets": "Keine anderen Unterhaltungen verfügbar.",
|
||||
"forward_done": "Gesendet",
|
||||
"forward_send": "An {{count}} senden",
|
||||
"forward_attachments_count_one": "{{count}} Anhang wird mit weitergeleitet",
|
||||
"forward_attachments_count_other": "{{count}} Anhänge werden mit weitergeleitet",
|
||||
"archive": "Archivieren",
|
||||
"unarchive": "Entarchivieren",
|
||||
"archived_title": "Archiv",
|
||||
"show_archived": "Archiv anzeigen",
|
||||
"show_active": "Aktive anzeigen",
|
||||
"archived_empty_title": "Nichts archiviert",
|
||||
"archived_empty_subtitle": "Archivierte Unterhaltungen erscheinen hier.",
|
||||
"mute": "Stummschalten",
|
||||
"unmute": "Stummschaltung aufheben",
|
||||
"mute_1h": "1 Stunde",
|
||||
"mute_8h": "8 Stunden",
|
||||
"mute_24h": "24 Stunden",
|
||||
"mute_1w": "1 Woche",
|
||||
"mute_forever": "Bis auf Weiteres",
|
||||
"row_menu": "Aktionen"
|
||||
},
|
||||
"call": {
|
||||
"start_audio": "Sprachanruf",
|
||||
|
||||
@@ -33,7 +33,37 @@
|
||||
"call_incoming": "Incoming call",
|
||||
"call_missed": "Missed call",
|
||||
"call_no_answer": "No answer",
|
||||
"call_declined": "Call declined"
|
||||
"call_declined": "Call declined",
|
||||
"you": "You",
|
||||
"attachment": "Attachment",
|
||||
"reply": "Reply",
|
||||
"forward": "Forward",
|
||||
"replying_to": "Replying to {{name}}",
|
||||
"quote_unavailable": "Message not available",
|
||||
"search_in_conv": "Search in conversation…",
|
||||
"search_none": "No matches",
|
||||
"forward_preview": "Preview",
|
||||
"forward_attachments_dropped": "Attachments are not forwarded.",
|
||||
"forward_no_targets": "No other conversations available.",
|
||||
"forward_done": "Sent",
|
||||
"forward_send": "Send to {{count}}",
|
||||
"forward_attachments_count_one": "{{count}} attachment forwarded",
|
||||
"forward_attachments_count_other": "{{count}} attachments forwarded",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Unarchive",
|
||||
"archived_title": "Archive",
|
||||
"show_archived": "Show archive",
|
||||
"show_active": "Show active",
|
||||
"archived_empty_title": "Nothing archived",
|
||||
"archived_empty_subtitle": "Archived conversations appear here.",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"mute_1h": "1 hour",
|
||||
"mute_8h": "8 hours",
|
||||
"mute_24h": "24 hours",
|
||||
"mute_1w": "1 week",
|
||||
"mute_forever": "Until further notice",
|
||||
"row_menu": "Actions"
|
||||
},
|
||||
"call": {
|
||||
"start_audio": "Voice call",
|
||||
|
||||
Reference in New Issue
Block a user