Files
ChatApp/packages/shared/src/chat/conversations.ts
T

205 lines
6.9 KiB
TypeScript

import type { ProfileBrief } from '../friends/index';
import type { AppSupabaseClient } from '../supabase/client';
import type { ConversationSummary } from './types';
const PROFILE_BRIEF_COLS = 'user_id, username, display_name, avatar_url, banner_url';
function mapBrief(row: {
user_id: string;
username: string;
display_name: string;
avatar_url: string | null;
banner_url: string | null;
}): ProfileBrief {
return {
userId: row.user_id,
username: row.username,
displayName: row.display_name,
avatarUrl: row.avatar_url,
bannerUrl: row.banner_url,
};
}
async function currentUserId(client: AppSupabaseClient): Promise<string> {
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<ConversationSummary[]> {
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, mentions_only' 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;
mentions_only: boolean | 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<string, ProfileBrief>();
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<string, string>();
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<string, typeof allMembers>();
for (const m of allMembers ?? []) {
const list = memberMap.get(m.conversation_id) ?? [];
list.push(m);
memberMap.set(m.conversation_id, list);
}
return (convs ?? []).map<ConversationSummary>((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;
mentions_only?: boolean | 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,
mentionsOnly: mineRow?.mentions_only ?? false,
};
});
}
// 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;
}
// Toggle "mentions only" — when true the renderer's notification gate
// suppresses non-mention alerts for this conversation. Mentions still fire
// via the independent useMentionNotifications subscription on
// message_mentions, so the @-alerts are never lost.
export async function setConversationMentionsOnly(
client: AppSupabaseClient,
params: { conversationId: string; mentionsOnly: boolean },
): Promise<void> {
const { data: session } = await client.auth.getUser();
if (!session.user) throw new Error('not authenticated');
const { error } = await client
.from('conversation_members')
.update({ mentions_only: params.mentionsOnly } as never)
.eq('conversation_id', params.conversationId)
.eq('user_id', session.user.id);
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();
}