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, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { Avatar } from '../components/Avatar'; import { EmptyState } from '../components/EmptyState'; import { NicknameDialog } from '../components/NicknameDialog'; import { AddUserIcon, 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 navigate = useNavigate(); const [tab, setTab] = useState('friends'); const [query, setQuery] = useState(''); const [debounced, setDebounced] = useState(''); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [searchError, setSearchError] = useState(null); const [pendingId, setPendingId] = useState(null); const [actionError, setActionError] = useState(null); const [nicknameDialog, setNicknameDialog] = useState<{ userId: string; displayName: string } | null>(null); const searchInputRef = useRef(null); const focusSearch = useCallback(() => { searchInputRef.current?.focus(); }, []); 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(); 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) => { setPendingId(id); setActionError(null); try { await fn(); await refresh(); } catch (err: unknown) { setActionError(translateError(err, t)); } finally { setPendingId(null); } }, [refresh, t], ); return (

{t('app:friends.title')}

setQuery(e.target.value)} placeholder={t('app:friends.search_placeholder')} autoComplete="off" autoCapitalize="none" spellCheck={false} className="w-full rounded-lg border border-line bg-surface-2 py-2.5 pl-10 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30" />
{query.length > 0 && ( )}
setTab('friends')}> {t('app:friends.tab_friends')} · {accepted.length} setTab('pending')}> {t('app:friends.tab_pending')} · {outgoing.length} setTab('requests')}> {t('app:friends.tab_requests')} · {incoming.length}
{error && {error}} {actionError && {actionError}} {loading ? ( ) : tab === 'friends' ? ( accepted.length === 0 ? ( } title={t('app:friends.empty_title', { defaultValue: 'Noch keine Freunde' })} description={t('app:friends.empty_desc', { defaultValue: 'Suche einen Friend per Username oder schicke eine Einladung.', })} action={{ label: t('app:friends.empty_cta', { defaultValue: 'Friend hinzufügen' }), onClick: focusSearch, }} /> ) : ( setNicknameDialog({ userId: f.peer.userId, displayName: f.peer.displayName ?? f.peer.username ?? 'Freund', }) } renderActions={(f) => ( performAction('msg-' + f.peer.userId, async () => { const id = await createDm(supabase, f.peer.userId); navigate('/chats/' + id); }) } onUnfriend={() => performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId)) } /> )} /> ) ) : tab === 'pending' ? ( ( performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId)) } > {t('app:friends.action_cancel')} )} /> ) : ( ( performAction(f.peer.userId, () => acceptFriendRequest(supabase, f.peer.userId)) } onDecline={() => performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId)) } /> )} /> )}
setNicknameDialog(null)} />
); } function translateError(err: unknown, t: ReturnType['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 ( ); } function FriendList({ items, emptyKey, renderActions, onRowContextMenu, }: { items: Friendship[]; emptyKey: string; renderActions: (f: Friendship) => React.ReactNode; onRowContextMenu?: (f: Friendship) => void; }) { const { t } = useTranslation(['app']); if (items.length === 0) { return (

{t(emptyKey)}

); } return (
    {items.map((f) => (
  • ) => { e.preventDefault(); onRowContextMenu(f); }, } : {})} > {renderActions(f)}
  • ))}
); } function FriendRow({ profile, children, onContextMenu, }: { profile: ProfileBrief; children: React.ReactNode; onContextMenu?: (e: React.MouseEvent) => void; }) { return (

{profile.displayName}

@{profile.username}

{children}
); } function FriendActions({ busy, onMessage, onUnfriend, }: { busy: boolean; onMessage: () => void; onUnfriend: () => void; }) { const { t } = useTranslation(['app']); return ( <> } > {t('app:friends.action_message')} {t('app:friends.action_unfriend')} ); } function RequestActions({ busy, onAccept, onDecline, }: { busy: boolean; onAccept: () => void; onDecline: () => void; }) { const { t } = useTranslation(['app']); return ( <> } > {t('app:friends.action_accept')} {t('app:friends.action_decline')} ); } function PrimaryButton({ busy, onClick, icon, children, }: { busy: boolean; onClick: () => void; icon?: React.ReactNode; children: React.ReactNode; }) { return ( ); } function SecondaryButton({ busy, onClick, children, }: { busy: boolean; onClick: () => void; children: React.ReactNode; }) { return ( ); } function DangerButton({ busy, onClick, children, }: { busy: boolean; onClick: () => void; children: React.ReactNode; }) { return ( ); } function LoadingRow() { return (
); } function Banner({ kind, children }: { kind: 'error' | 'info'; children: React.ReactNode }) { const isError = kind === 'error'; return (
{isError ? ( ) : ( )}

{children}

); } function SearchResults({ query, searching, results, error, friendsByPeerId, pendingId, onAction, }: { query: string; searching: boolean; results: ProfileBrief[]; error: string | null; friendsByPeerId: Map; pendingId: string | null; onAction: (id: string, fn: () => Promise) => Promise; }) { const { t } = useTranslation(['app']); if (query.length < 2) { return

{t('app:friends.search_min_chars')}

; } if (searching) { return (
); } if (error) return {error}; if (results.length === 0) { return

{t('app:friends.search_no_results')}

; } return (

{t('app:friends.search_results_title')}

    {results.map((p) => { const existing = friendsByPeerId.get(p.userId); return (
  • ); })}
); } function SearchActionButton({ profile, existing, busy, onAction, }: { profile: ProfileBrief; existing: Friendship | undefined; busy: boolean; onAction: (id: string, fn: () => Promise) => Promise; }) { const { t } = useTranslation(['app']); if (existing?.status === 'accepted') { return ( {t('app:friends.already_friends')} ); } if (existing?.status === 'pending' && existing.direction === 'outgoing') { return ( {t('app:friends.request_sent')} ); } if (existing?.status === 'pending' && existing.direction === 'incoming') { return ( void onAction(profile.userId, () => acceptFriendRequest(supabase, profile.userId)) } icon={} > {t('app:friends.action_accept')} ); } return ( void onAction(profile.userId, () => sendFriendRequest(supabase, profile.userId)) } icon={} > {t('app:friends.send_request')} ); }