diff --git a/apps/desktop/src/types/libsodium-wrappers-sumo.d.ts b/apps/desktop/src/types/libsodium-wrappers-sumo.d.ts
new file mode 100644
index 0000000..48e0436
--- /dev/null
+++ b/apps/desktop/src/types/libsodium-wrappers-sumo.d.ts
@@ -0,0 +1,9 @@
+// Shim sumo types to the non-sumo types package. sumo is an API superset of
+// libsodium-wrappers; its runtime exports match the standard wrappers module
+// and additionally include `crypto_pwhash` (Argon2id). Reuse the existing
+// `@types/libsodium-wrappers` definitions rather than duplicating them.
+declare module 'libsodium-wrappers-sumo' {
+ import sodium from 'libsodium-wrappers';
+ export default sodium;
+ export * from 'libsodium-wrappers';
+}
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts
index 63a8c7f..cb1d64f 100644
--- a/apps/desktop/vite.config.ts
+++ b/apps/desktop/vite.config.ts
@@ -16,11 +16,11 @@ export default defineConfig({
},
},
optimizeDeps: {
- // libsodium-wrappers 0.7.16 ships a broken "import" condition in its
- // package exports (the ESM bundle references a sibling ./libsodium.mjs
- // that isn't in the published artefact). Force esbuild to pick the
- // "require" condition so the self-contained CJS build is used.
- include: ['libsodium-wrappers'],
+ // libsodium-wrappers-sumo (and the compact variant) ship broken "import"
+ // conditions in package exports — the ESM bundle references a sibling
+ // ./libsodium.mjs that isn't in the published artefact. Force esbuild to
+ // pick the "require" condition so the self-contained CJS build is used.
+ include: ['libsodium-wrappers-sumo'],
esbuildOptions: {
conditions: ['require', 'node', 'default'],
},
diff --git a/packages/shared/src/chat/conversations.ts b/packages/shared/src/chat/conversations.ts
index 38f484d..ae23da0 100644
--- a/packages/shared/src/chat/conversations.ts
+++ b/packages/shared/src/chat/conversations.ts
@@ -28,13 +28,21 @@ async function currentUserId(client: AppSupabaseClient): Promise {
export async function listConversations(client: AppSupabaseClient): Promise {
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 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 {
+ 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 {
+ 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();
+}
diff --git a/packages/shared/src/chat/types.ts b/packages/shared/src/chat/types.ts
index e07defa..2d7612f 100644
--- a/packages/shared/src/chat/types.ts
+++ b/packages/shared/src/chat/types.ts
@@ -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 {
diff --git a/packages/shared/src/i18n/locales/de/app.json b/packages/shared/src/i18n/locales/de/app.json
index 4f0abc7..5a09f9c 100644
--- a/packages/shared/src/i18n/locales/de/app.json
+++ b/packages/shared/src/i18n/locales/de/app.json
@@ -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",
diff --git a/packages/shared/src/i18n/locales/en/app.json b/packages/shared/src/i18n/locales/en/app.json
index e6952f5..96a7776 100644
--- a/packages/shared/src/i18n/locales/en/app.json
+++ b/packages/shared/src/i18n/locales/en/app.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7b40a0e..4153a5b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -86,7 +86,7 @@ importers:
i18next:
specifier: ^23.16.4
version: 23.16.8
- libsodium-wrappers:
+ libsodium-wrappers-sumo:
specifier: 0.7.15
version: 0.7.15
livekit-client:
@@ -114,6 +114,9 @@ importers:
'@types/libsodium-wrappers':
specifier: ^0.7.14
version: 0.7.14
+ '@types/libsodium-wrappers-sumo':
+ specifier: ^0.8.2
+ version: 0.8.2
'@types/react':
specifier: ^18.3.12
version: 18.3.28
@@ -1853,6 +1856,10 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ '@types/libsodium-wrappers-sumo@0.8.2':
+ resolution: {integrity: sha512-uFOBpg/r21hExVlh2ty8YpDfSR+Yy3Jn8XS4+SSjitbhTxdYq+pBz/49XRxyUFe8SzqujHf/Wu0/O4d+FUtNfQ==}
+ deprecated: This is a stub types definition. libsodium-wrappers-sumo provides its own type definitions, so you do not need this installed.
+
'@types/libsodium-wrappers@0.7.14':
resolution: {integrity: sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==}
@@ -3642,9 +3649,15 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ libsodium-sumo@0.7.16:
+ resolution: {integrity: sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==}
+
libsodium-sumo@0.8.3:
resolution: {integrity: sha512-z5CLkGJqilCXpfYxrXWh8fHVv2C8lpnIVxsZAHkxbEFyS+zZtL8VyM8FjtAmuDYP/rHgw8ftdwxSV8Efhzb8GQ==}
+ libsodium-wrappers-sumo@0.7.15:
+ resolution: {integrity: sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==}
+
libsodium-wrappers-sumo@0.8.3:
resolution: {integrity: sha512-EfLSlxKJ7RUGVospOlvbvse0suAAVPR+CkZfFcFjPzPtTEgVfvIaZUPsWndVjxYp/om2HDc0iLeR5wLF8YbHZg==}
@@ -7492,6 +7505,10 @@ snapshots:
'@types/json5@0.0.29': {}
+ '@types/libsodium-wrappers-sumo@0.8.2':
+ dependencies:
+ libsodium-wrappers-sumo: 0.7.15
+
'@types/libsodium-wrappers@0.7.14': {}
'@types/node-forge@1.3.14':
@@ -9591,8 +9608,14 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ libsodium-sumo@0.7.16: {}
+
libsodium-sumo@0.8.3: {}
+ libsodium-wrappers-sumo@0.7.15:
+ dependencies:
+ libsodium-sumo: 0.7.16
+
libsodium-wrappers-sumo@0.8.3:
dependencies:
libsodium-sumo: 0.8.3
diff --git a/supabase/migrations/20260420000001_archive_mute.sql b/supabase/migrations/20260420000001_archive_mute.sql
new file mode 100644
index 0000000..009e791
--- /dev/null
+++ b/supabase/migrations/20260420000001_archive_mute.sql
@@ -0,0 +1,16 @@
+-- Per-member conversation preferences: archive + mute.
+--
+-- Both live on `conversation_members` because they are per-user state, not
+-- shared across the conversation. Archive hides the conversation from the
+-- main list until explicitly unarchived. Mute silences notifications until
+-- `muted_until` (null = not muted, a far-future timestamp = muted forever).
+
+alter table public.conversation_members
+ add column if not exists archived boolean not null default false,
+ add column if not exists muted_until timestamptz null;
+
+-- Update policy on conversation_members already allows self-updates via
+-- `members_update_self_or_admin`. No additional policy needed — users can
+-- toggle their own archive / mute flags.
+
+-- Realtime publication already includes `conversation_members`.