630 lines
18 KiB
TypeScript
630 lines
18 KiB
TypeScript
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<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);
|
|
const [nicknameDialog, setNicknameDialog] = useState<{ userId: string; displayName: string } | null>(null);
|
|
const searchInputRef = useRef<HTMLInputElement | null>(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<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="min-h-full bg-surface-3 text-fg">
|
|
<div className="mx-auto flex min-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-fg">
|
|
{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-fg-muted" />
|
|
<input
|
|
ref={searchInputRef}
|
|
type="search"
|
|
value={query}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</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-line">
|
|
<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' ? (
|
|
accepted.length === 0 ? (
|
|
<EmptyState
|
|
icon={<AddUserIcon className="h-8 w-8" />}
|
|
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,
|
|
}}
|
|
/>
|
|
) : (
|
|
<FriendList
|
|
items={accepted}
|
|
emptyKey="app:friends.empty_friends"
|
|
onRowContextMenu={(f) =>
|
|
setNicknameDialog({
|
|
userId: f.peer.userId,
|
|
displayName: f.peer.displayName ?? f.peer.username ?? 'Freund',
|
|
})
|
|
}
|
|
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);
|
|
navigate('/chats/' + 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>
|
|
<NicknameDialog
|
|
open={nicknameDialog !== null}
|
|
userId={nicknameDialog?.userId ?? ''}
|
|
displayName={nicknameDialog?.displayName ?? ''}
|
|
onClose={() => setNicknameDialog(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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-accent text-fg'
|
|
: 'border-transparent text-fg-muted hover:text-fg')
|
|
}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-fg-muted">
|
|
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-2 text-fg-muted">
|
|
<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}
|
|
{...(onRowContextMenu
|
|
? {
|
|
onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
onRowContextMenu(f);
|
|
},
|
|
}
|
|
: {})}
|
|
>
|
|
{renderActions(f)}
|
|
</FriendRow>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
function FriendRow({
|
|
profile,
|
|
children,
|
|
onContextMenu,
|
|
}: {
|
|
profile: ProfileBrief;
|
|
children: React.ReactNode;
|
|
onContextMenu?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
onContextMenu={onContextMenu}
|
|
className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3"
|
|
>
|
|
<Avatar
|
|
url={profile.avatarUrl}
|
|
displayName={profile.displayName ?? profile.username}
|
|
className="h-9 w-9 text-sm"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-semibold text-fg">{profile.displayName}</p>
|
|
<p className="truncate text-xs text-fg-muted">@{profile.username}</p>
|
|
</div>
|
|
<div className="flex shrink-0 gap-2">{children}</div>
|
|
</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-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/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-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg transition hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/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/40 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-600 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 dark:text-rose-300"
|
|
>
|
|
{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-fg-muted">
|
|
<SpinnerIcon className="h-4 w-4 text-accent" />
|
|
<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/30 bg-rose-500/10 text-rose-700 dark:text-rose-100'
|
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-100')
|
|
}
|
|
>
|
|
{isError ? (
|
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
|
|
) : (
|
|
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600 dark: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-fg-muted">{t('app:friends.search_min_chars')}</p>;
|
|
}
|
|
if (searching) {
|
|
return (
|
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
|
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
|
<span>…</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (error) return <Banner kind="error">{error}</Banner>;
|
|
if (results.length === 0) {
|
|
return <p className="text-xs text-fg-muted">{t('app:friends.search_no_results')}</p>;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<p className="mb-2 text-xs font-medium uppercase tracking-[0.1em] text-fg-muted">
|
|
{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/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-700 dark: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-line bg-surface-2 px-3 py-1.5 text-xs text-fg-muted">
|
|
{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>
|
|
);
|
|
}
|