b61f929cf7
packages/shared/src/index.ts and all sub-modules used .js extensions on relative imports (e.g. './admin/index.js') pointing at .ts source files. TypeScript with moduleResolution: "Bundler" doesn't need them, and Metro's eager exporter (used for preview / production builds) reads them literally and fails — only the dev-server Metro fell back to .ts. Workspace typecheck remains 8/8 green; Vite and TS Bundler resolution already accept both styles, so desktop is unaffected.
165 lines
4.6 KiB
TypeScript
165 lines
4.6 KiB
TypeScript
import type { AppSupabaseClient } from '../supabase/client';
|
|
import type { FriendshipStatus } from '../supabase/types';
|
|
|
|
export interface ProfileBrief {
|
|
userId: string;
|
|
username: string;
|
|
displayName: string;
|
|
avatarUrl: string | null;
|
|
bannerUrl: string | null;
|
|
}
|
|
|
|
export type FriendDirection = 'outgoing' | 'incoming';
|
|
|
|
export interface Friendship {
|
|
peer: ProfileBrief;
|
|
status: FriendshipStatus;
|
|
// Only meaningful when status === 'pending'.
|
|
direction: FriendDirection;
|
|
createdAt: string;
|
|
acceptedAt: string | null;
|
|
}
|
|
|
|
const PROFILE_BRIEF_COLS = 'user_id, username, display_name, avatar_url, banner_url';
|
|
|
|
function mapProfileBrief(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;
|
|
}
|
|
|
|
function pairKey(a: string, b: string): { lo: string; hi: string } {
|
|
return a < b ? { lo: a, hi: b } : { lo: b, hi: a };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Search
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function searchProfiles(
|
|
client: AppSupabaseClient,
|
|
query: string,
|
|
limit = 10,
|
|
): Promise<ProfileBrief[]> {
|
|
const trimmed = query.trim();
|
|
if (trimmed.length < 2) return [];
|
|
const myId = await currentUserId(client);
|
|
|
|
const { data, error } = await client
|
|
.from('profiles')
|
|
.select(PROFILE_BRIEF_COLS)
|
|
.ilike('username', trimmed + '%')
|
|
.neq('user_id', myId)
|
|
.limit(limit);
|
|
if (error) throw error;
|
|
return (data ?? []).map(mapProfileBrief);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Friendships list
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function listFriendships(client: AppSupabaseClient): Promise<Friendship[]> {
|
|
const myId = await currentUserId(client);
|
|
|
|
const { data: rows, error } = await client
|
|
.from('friendships')
|
|
.select('user_lo, user_hi, status, requested_by, created_at, accepted_at')
|
|
.order('created_at', { ascending: false });
|
|
if (error) throw error;
|
|
if (!rows || rows.length === 0) return [];
|
|
|
|
const peerIds = Array.from(
|
|
new Set(rows.map((r) => (r.user_lo === myId ? r.user_hi : r.user_lo))),
|
|
);
|
|
|
|
const { data: profiles, error: pErr } = await client
|
|
.from('profiles')
|
|
.select(PROFILE_BRIEF_COLS)
|
|
.in('user_id', peerIds);
|
|
if (pErr) throw pErr;
|
|
|
|
const profileMap = new Map<string, ProfileBrief>();
|
|
for (const p of profiles ?? []) {
|
|
const brief = mapProfileBrief(p);
|
|
profileMap.set(brief.userId, brief);
|
|
}
|
|
|
|
return rows.map<Friendship>((r) => {
|
|
const peerId = r.user_lo === myId ? r.user_hi : r.user_lo;
|
|
const peer = profileMap.get(peerId) ?? {
|
|
userId: peerId,
|
|
username: '?',
|
|
displayName: '?',
|
|
avatarUrl: null,
|
|
bannerUrl: null,
|
|
};
|
|
return {
|
|
peer,
|
|
status: r.status,
|
|
direction: r.requested_by === myId ? 'outgoing' : 'incoming',
|
|
createdAt: r.created_at,
|
|
acceptedAt: r.accepted_at,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mutations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function sendFriendRequest(
|
|
client: AppSupabaseClient,
|
|
targetUserId: string,
|
|
): Promise<void> {
|
|
const { error } = await client.rpc('send_friend_request', { target_user_id: targetUserId });
|
|
if (error) throw error;
|
|
}
|
|
|
|
export async function acceptFriendRequest(
|
|
client: AppSupabaseClient,
|
|
peerUserId: string,
|
|
): Promise<void> {
|
|
const myId = await currentUserId(client);
|
|
const { lo, hi } = pairKey(myId, peerUserId);
|
|
const { error } = await client
|
|
.from('friendships')
|
|
.update({ status: 'accepted' })
|
|
.eq('user_lo', lo)
|
|
.eq('user_hi', hi);
|
|
if (error) throw error;
|
|
}
|
|
|
|
// Used for both "decline incoming" and "cancel outgoing" — semantically a
|
|
// bilateral break.
|
|
export async function removeFriendship(
|
|
client: AppSupabaseClient,
|
|
peerUserId: string,
|
|
): Promise<void> {
|
|
const myId = await currentUserId(client);
|
|
const { lo, hi } = pairKey(myId, peerUserId);
|
|
const { error } = await client
|
|
.from('friendships')
|
|
.delete()
|
|
.eq('user_lo', lo)
|
|
.eq('user_hi', hi);
|
|
if (error) throw error;
|
|
}
|