This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+573
View File
@@ -0,0 +1,573 @@
import { createDm } from '@chat-app/shared/chat';
import {
acceptFriendRequest,
type Friendship,
type ProfileBrief,
removeFriendship,
searchProfiles,
sendFriendRequest,
} from '@chat-app/shared/friends';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertIcon,
ChatBubbleIcon,
CheckCircleIcon,
PlusIcon,
SearchIcon,
SpinnerIcon,
UsersIcon,
} from '../components/icons';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { supabase } from '../lib/supabase';
type Tab = 'friends' | 'pending' | 'requests';
export function FriendsPage() {
const { t } = useTranslation(['app', 'errors']);
const { friendships, loading, error, refresh } = useFriendshipsContext();
const [tab, setTab] = useState<Tab>('friends');
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
const [results, setResults] = useState<ProfileBrief[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(query.trim()), 250);
return () => window.clearTimeout(id);
}, [query]);
useEffect(() => {
if (debounced.length < 2) {
setResults([]);
setSearchError(null);
return;
}
let cancelled = false;
setSearching(true);
searchProfiles(supabase, debounced)
.then((data) => {
if (!cancelled) {
setResults(data);
setSearchError(null);
}
})
.catch((err: unknown) => {
if (!cancelled) setSearchError(translateError(err, t));
})
.finally(() => {
if (!cancelled) setSearching(false);
});
return () => {
cancelled = true;
};
}, [debounced, t]);
const friendsByPeerId = useMemo(() => {
const map = new Map<string, Friendship>();
for (const f of friendships) map.set(f.peer.userId, f);
return map;
}, [friendships]);
const accepted = useMemo(
() => friendships.filter((f) => f.status === 'accepted'),
[friendships],
);
const outgoing = useMemo(
() => friendships.filter((f) => f.status === 'pending' && f.direction === 'outgoing'),
[friendships],
);
const incoming = useMemo(
() => friendships.filter((f) => f.status === 'pending' && f.direction === 'incoming'),
[friendships],
);
const performAction = useCallback(
async (id: string, fn: () => Promise<void>) => {
setPendingId(id);
setActionError(null);
try {
await fn();
await refresh();
} catch (err: unknown) {
setActionError(translateError(err, t));
} finally {
setPendingId(null);
}
},
[refresh, t],
);
return (
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
<header className="flex items-center justify-between gap-4">
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
{t('app:friends.title')}
</h1>
</header>
<div className="space-y-3">
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value.toLowerCase())}
placeholder={t('app:friends.search_placeholder')}
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
className="w-full rounded-lg border border-white/10 bg-ink-900/60 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
</div>
{query.length > 0 && (
<SearchResults
query={debounced}
searching={searching}
results={results}
error={searchError}
friendsByPeerId={friendsByPeerId}
pendingId={pendingId}
onAction={performAction}
/>
)}
</div>
<div className="flex gap-2 border-b border-white/5">
<TabButton active={tab === 'friends'} onClick={() => setTab('friends')}>
{t('app:friends.tab_friends')} · {accepted.length}
</TabButton>
<TabButton active={tab === 'pending'} onClick={() => setTab('pending')}>
{t('app:friends.tab_pending')} · {outgoing.length}
</TabButton>
<TabButton active={tab === 'requests'} onClick={() => setTab('requests')}>
{t('app:friends.tab_requests')} · {incoming.length}
</TabButton>
</div>
{error && <Banner kind="error">{error}</Banner>}
{actionError && <Banner kind="error">{actionError}</Banner>}
{loading ? (
<LoadingRow />
) : tab === 'friends' ? (
<FriendList
items={accepted}
emptyKey="app:friends.empty_friends"
renderActions={(f) => (
<FriendActions
busy={pendingId === f.peer.userId || pendingId === 'msg-' + f.peer.userId}
onMessage={() =>
performAction('msg-' + f.peer.userId, async () => {
const id = await createDm(supabase, f.peer.userId);
navigateToChat(id);
})
}
onUnfriend={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
/>
)}
/>
) : tab === 'pending' ? (
<FriendList
items={outgoing}
emptyKey="app:friends.empty_pending"
renderActions={(f) => (
<SecondaryButton
busy={pendingId === f.peer.userId}
onClick={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
>
{t('app:friends.action_cancel')}
</SecondaryButton>
)}
/>
) : (
<FriendList
items={incoming}
emptyKey="app:friends.empty_requests"
renderActions={(f) => (
<RequestActions
busy={pendingId === f.peer.userId}
onAccept={() =>
performAction(f.peer.userId, () => acceptFriendRequest(supabase, f.peer.userId))
}
onDecline={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
/>
)}
/>
)}
</div>
);
}
function navigateToChat(id: string): void {
// Push history + dispatch popstate so React Router re-evaluates the route.
window.history.pushState(null, '', '/chats/' + id);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function translateError(err: unknown, t: ReturnType<typeof useTranslation>['t']): string {
const code = extractErrorCode(err);
if (code) return t('errors:' + code, { defaultValue: t('errors:generic') });
if (err instanceof Error) return err.message;
return t('errors:generic');
}
// ---------------------------------------------------------------------------
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' +
(active
? 'border-brand-400 text-white'
: 'border-transparent text-neutral-400 hover:text-neutral-200')
}
>
{children}
</button>
);
}
function FriendList({
items,
emptyKey,
renderActions,
}: {
items: Friendship[];
emptyKey: string;
renderActions: (f: Friendship) => React.ReactNode;
}) {
const { t } = useTranslation(['app']);
if (items.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-neutral-500">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-neutral-400">
<UsersIcon className="h-5 w-5" />
</div>
<p>{t(emptyKey)}</p>
</div>
);
}
return (
<ul className="space-y-1.5">
{items.map((f) => (
<li key={f.peer.userId}>
<FriendRow profile={f.peer}>{renderActions(f)}</FriendRow>
</li>
))}
</ul>
);
}
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-ink-900/50 px-4 py-3 backdrop-blur-sm">
<Avatar profile={profile} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{profile.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{profile.username}</p>
</div>
<div className="flex shrink-0 gap-2">{children}</div>
</div>
);
}
function Avatar({ profile }: { profile: ProfileBrief }) {
const letter = (profile.displayName ?? profile.username ?? '?').trim().charAt(0).toUpperCase();
return (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{letter || '?'}
</div>
);
}
function FriendActions({
busy,
onMessage,
onUnfriend,
}: {
busy: boolean;
onMessage: () => void;
onUnfriend: () => void;
}) {
const { t } = useTranslation(['app']);
return (
<>
<PrimaryButton
busy={busy}
onClick={onMessage}
icon={<ChatBubbleIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.action_message')}
</PrimaryButton>
<DangerButton busy={busy} onClick={onUnfriend}>
{t('app:friends.action_unfriend')}
</DangerButton>
</>
);
}
function RequestActions({
busy,
onAccept,
onDecline,
}: {
busy: boolean;
onAccept: () => void;
onDecline: () => void;
}) {
const { t } = useTranslation(['app']);
return (
<>
<PrimaryButton
busy={busy}
onClick={onAccept}
icon={<CheckCircleIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.action_accept')}
</PrimaryButton>
<SecondaryButton busy={busy} onClick={onDecline}>
{t('app:friends.action_decline')}
</SecondaryButton>
</>
);
}
function PrimaryButton({
busy,
onClick,
icon,
children,
}: {
busy: boolean;
onClick: () => void;
icon?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : icon}
<span>{children}</span>
</button>
);
}
function SecondaryButton({
busy,
onClick,
children,
}: {
busy: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span>
</button>
);
}
function DangerButton({
busy,
onClick,
children,
}: {
busy: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span>
</button>
);
}
function LoadingRow() {
return (
<div className="flex items-center gap-3 text-sm text-neutral-500">
<SpinnerIcon className="h-4 w-4 text-brand-400" />
<span></span>
</div>
);
}
function Banner({ kind, children }: { kind: 'error' | 'info'; children: React.ReactNode }) {
const isError = kind === 'error';
return (
<div
role={isError ? 'alert' : 'status'}
className={
'flex items-start gap-3 rounded-lg border p-3 text-sm ' +
(isError
? 'border-rose-500/20 bg-rose-500/10 text-rose-100'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-100')
}
>
{isError ? (
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
) : (
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" />
)}
<p className="min-w-0 flex-1 break-words">{children}</p>
</div>
);
}
function SearchResults({
query,
searching,
results,
error,
friendsByPeerId,
pendingId,
onAction,
}: {
query: string;
searching: boolean;
results: ProfileBrief[];
error: string | null;
friendsByPeerId: Map<string, Friendship>;
pendingId: string | null;
onAction: (id: string, fn: () => Promise<void>) => Promise<void>;
}) {
const { t } = useTranslation(['app']);
if (query.length < 2) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_min_chars')}</p>;
}
if (searching) {
return (
<div className="flex items-center gap-2 text-xs text-neutral-500">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
<span></span>
</div>
);
}
if (error) return <Banner kind="error">{error}</Banner>;
if (results.length === 0) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_no_results')}</p>;
}
return (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
{t('app:friends.search_results_title')}
</p>
<ul className="space-y-1.5">
{results.map((p) => {
const existing = friendsByPeerId.get(p.userId);
return (
<li key={p.userId}>
<FriendRow profile={p}>
<SearchActionButton
profile={p}
existing={existing}
busy={pendingId === p.userId}
onAction={onAction}
/>
</FriendRow>
</li>
);
})}
</ul>
</div>
);
}
function SearchActionButton({
profile,
existing,
busy,
onAction,
}: {
profile: ProfileBrief;
existing: Friendship | undefined;
busy: boolean;
onAction: (id: string, fn: () => Promise<void>) => Promise<void>;
}) {
const { t } = useTranslation(['app']);
if (existing?.status === 'accepted') {
return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-200">
<CheckCircleIcon className="h-3.5 w-3.5" />
{t('app:friends.already_friends')}
</span>
);
}
if (existing?.status === 'pending' && existing.direction === 'outgoing') {
return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
{t('app:friends.request_sent')}
</span>
);
}
if (existing?.status === 'pending' && existing.direction === 'incoming') {
return (
<PrimaryButton
busy={busy}
onClick={() =>
void onAction(profile.userId, () => acceptFriendRequest(supabase, profile.userId))
}
icon={<CheckCircleIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.action_accept')}
</PrimaryButton>
);
}
return (
<PrimaryButton
busy={busy}
onClick={() =>
void onAction(profile.userId, () => sendFriendRequest(supabase, profile.userId))
}
icon={<PlusIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.send_request')}
</PrimaryButton>
);
}