de431386ea
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
177 lines
5.8 KiB
TypeScript
177 lines
5.8 KiB
TypeScript
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<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' 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<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 }
|
|
| 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<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();
|
|
}
|