import type { ProfileBrief } from '../friends/index.js'; import type { AppSupabaseClient } from '../supabase/client.js'; import type { ConversationSummary } from './types.js'; const PROFILE_BRIEF_COLS = 'user_id, username, display_name, avatar_url'; function mapBrief(row: { user_id: string; username: string; display_name: string; avatar_url: string | null; }): ProfileBrief { return { userId: row.user_id, username: row.username, displayName: row.display_name, avatarUrl: row.avatar_url, }; } async function currentUserId(client: AppSupabaseClient): Promise { const { data, error } = await client.auth.getUser(); if (error) throw error; if (!data.user) throw new Error('not authenticated'); return data.user.id; } export async function listConversations(client: AppSupabaseClient): Promise { const myId = await currentUserId(client); // 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, archived, muted_until' as '*') .eq('user_id', myId); if (mErr) throw mErr; 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); // 2. Conversations const { data: convs, error: cErr } = await client .from('conversations') .select('id, type, name, avatar_url, created_at') .in('id', convIds); if (cErr) throw cErr; // 3. All members across these conversations const { data: allMembers, error: aErr } = await client .from('conversation_members') .select('conversation_id, user_id, role, accepted') .in('conversation_id', convIds); if (aErr) throw aErr; // 4. Profiles for ALL distinct member user ids (including self — the // member list in GroupInfoPanel needs our own display name too). const memberIdsAll = Array.from(new Set((allMembers ?? []).map((m) => m.user_id))); const profileMap = new Map(); if (memberIdsAll.length > 0) { const { data: profiles, error: pErr } = await client .from('profiles') .select(PROFILE_BRIEF_COLS) .in('user_id', memberIdsAll); if (pErr) throw pErr; for (const p of profiles ?? []) { const b = mapBrief(p); profileMap.set(b.userId, b); } } // 5. Latest message per conversation const { data: latest, error: lErr } = await client .from('messages') .select('conversation_id, created_at') .in('conversation_id', convIds) .order('created_at', { ascending: false }); if (lErr) throw lErr; const lastSeen = new Map(); for (const m of latest ?? []) { if (!lastSeen.has(m.conversation_id)) { lastSeen.set(m.conversation_id, m.created_at); } } const myMap = new Map(myMembersList.map((m) => [m.conversation_id, m])); const memberMap = new Map(); for (const m of allMembers ?? []) { const list = memberMap.get(m.conversation_id) ?? []; list.push(m); memberMap.set(m.conversation_id, list); } return (convs ?? []).map((c) => { const mine = myMap.get(c.id); const members = (memberMap.get(c.id) ?? []).map((m) => ({ userId: m.user_id, role: m.role, accepted: m.accepted, profile: profileMap.get(m.user_id) ?? null, })); const peer = 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, name: c.name, avatarUrl: c.avatar_url, createdAt: c.created_at, peer, 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 { 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(); }