aa609389fa
Both right-hand panels used absolute inset-y-0 right-0 and floated on top of the conversation, hiding the messages directly underneath the panel and looking unlike Discord's actual layout. Restructure: * MediaFilesDrawer: drop absolute/z-index/shadow chrome, become a static flex column (w-[380px] shrink-0) with a left border. Internal layout unchanged. * GroupInfoPanel: same treatment (w-[320px] shrink-0). Dropped the backdrop-blur and slide-up animation that only made sense as a modal. * ConversationPage: wrap the chat content (voice rail, in-call panel, messages list, drag-overlay, input form) in a new `flex min-w-0 flex-1 flex-col` chat-column, and make that column a sibling of the drawers inside a new `flex flex-1 flex-row` row. The conversation header + search bar stay full-width above the row. Result: opening a drawer narrows the chat column instead of covering it, matching Discord's behaviour. The chat-column wrapper also carries the `relative` anchor previously held by the outer wrapper so the drag-and-drop overlay positions correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
260 lines
9.9 KiB
TypeScript
260 lines
9.9 KiB
TypeScript
import { addGroupMember, type ConversationSummary, leaveGroup } from '@chat-app/shared/chat';
|
|
import { extractErrorCode } from '@chat-app/shared/i18n';
|
|
import { useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
|
import { supabase } from '../lib/supabase';
|
|
import { AlertIcon, PlusIcon, SignOutIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
conversation: ConversationSummary;
|
|
}
|
|
|
|
export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
|
const { t } = useTranslation(['app', 'errors']);
|
|
const { session } = useAuth();
|
|
const { friendships } = useFriendshipsContext();
|
|
const navigate = useNavigate();
|
|
const [busyLeave, setBusyLeave] = useState(false);
|
|
const [busyAddId, setBusyAddId] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const myId = session?.user.id;
|
|
const myRole =
|
|
conversation.members.find((m) => m.userId === myId)?.role ?? conversation.myRole;
|
|
const canAdd = myRole === 'admin' || myRole === 'mod';
|
|
|
|
const addableFriends = useMemo(() => {
|
|
const memberIds = new Set(conversation.members.map((m) => m.userId));
|
|
return friendships
|
|
.filter((f) => f.status === 'accepted')
|
|
.map((f) => f.peer)
|
|
.filter((p) => !memberIds.has(p.userId));
|
|
}, [friendships, conversation.members]);
|
|
|
|
async function handleAdd(userId: string) {
|
|
if (busyAddId) return;
|
|
setBusyAddId(userId);
|
|
setError(null);
|
|
try {
|
|
await addGroupMember(supabase, conversation.id, userId);
|
|
} catch (err: unknown) {
|
|
const code = extractErrorCode(err);
|
|
setError(
|
|
code
|
|
? t('errors:' + code, { defaultValue: t('errors:generic') })
|
|
: err instanceof Error
|
|
? err.message
|
|
: t('errors:generic'),
|
|
);
|
|
} finally {
|
|
setBusyAddId(null);
|
|
}
|
|
}
|
|
|
|
async function handleLeave() {
|
|
if (busyLeave) return;
|
|
if (!window.confirm(t('app:group.info_leave_confirm'))) return;
|
|
setBusyLeave(true);
|
|
setError(null);
|
|
try {
|
|
await leaveGroup(supabase, conversation.id);
|
|
onClose();
|
|
navigate('/chats');
|
|
} catch (err: unknown) {
|
|
const code = extractErrorCode(err);
|
|
setError(
|
|
code
|
|
? t('errors:' + code, { defaultValue: t('errors:generic') })
|
|
: err instanceof Error
|
|
? err.message
|
|
: t('errors:generic'),
|
|
);
|
|
} finally {
|
|
setBusyLeave(false);
|
|
}
|
|
}
|
|
|
|
if (!open) return null;
|
|
|
|
const roleLabel = (role: string) =>
|
|
role === 'admin'
|
|
? t('app:group.info_role_admin')
|
|
: role === 'mod'
|
|
? t('app:group.info_role_mod')
|
|
: t('app:group.info_role_member');
|
|
|
|
return (
|
|
<aside
|
|
role="dialog"
|
|
aria-label={t('app:group.info_title')}
|
|
className="flex w-[320px] shrink-0 flex-col border-l border-white/5 bg-ink-900/95"
|
|
>
|
|
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-neutral-500">
|
|
{t('app:group.info_title')}
|
|
</p>
|
|
<h3 className="mt-0.5 font-display text-base font-semibold text-white">
|
|
{conversation.name ?? '—'}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100"
|
|
>
|
|
<XIcon className="h-4 w-4" />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex-1 space-y-6 overflow-y-auto p-5">
|
|
<section>
|
|
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
|
|
{t('app:group.info_members')} ({conversation.members.length})
|
|
</h4>
|
|
<ul className="space-y-1">
|
|
{conversation.members.map((m) => {
|
|
const name = m.profile?.displayName ?? '?';
|
|
const handle = m.profile?.username ? '@' + m.profile.username : '';
|
|
const avatarUrl = m.profile?.avatarUrl ?? null;
|
|
const letter = name.trim().charAt(0).toUpperCase() || '?';
|
|
return (
|
|
<li
|
|
key={m.userId}
|
|
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
|
|
>
|
|
{avatarUrl ? (
|
|
<img
|
|
src={avatarUrl}
|
|
alt=""
|
|
className="h-8 w-8 shrink-0 rounded-full object-cover"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
|
{letter}
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm text-white">
|
|
{name}
|
|
{m.userId === myId && (
|
|
<span className="ml-1.5 text-xs text-neutral-500">· you</span>
|
|
)}
|
|
</p>
|
|
<p className="truncate text-xs text-neutral-500">{handle}</p>
|
|
</div>
|
|
<span
|
|
className={
|
|
'rounded-full border px-2 py-0.5 text-[10px] font-medium ' +
|
|
(m.role === 'admin'
|
|
? 'border-brand-400/30 bg-brand-500/15 text-brand-200'
|
|
: m.role === 'mod'
|
|
? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
|
|
: 'border-white/10 bg-white/5 text-neutral-300')
|
|
}
|
|
>
|
|
{roleLabel(m.role)}
|
|
</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</section>
|
|
|
|
{canAdd && (
|
|
<section>
|
|
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
|
|
{t('app:group.info_add_title')}
|
|
</h4>
|
|
{addableFriends.length === 0 ? (
|
|
<div className="flex items-center gap-3 rounded-lg border border-white/5 bg-ink-800/60 p-3 text-xs text-neutral-400">
|
|
<UsersIcon className="h-4 w-4" />
|
|
<span>{t('app:group.info_add_empty')}</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p className="mb-2 text-xs text-neutral-500">
|
|
{t('app:group.info_add_help')}
|
|
</p>
|
|
<ul className="space-y-1">
|
|
{addableFriends.map((f) => {
|
|
const letter =
|
|
(f.displayName ?? f.username ?? '?').trim().charAt(0).toUpperCase() || '?';
|
|
const busy = busyAddId === f.userId;
|
|
return (
|
|
<li key={f.userId}>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => void handleAdd(f.userId)}
|
|
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60"
|
|
>
|
|
{f.avatarUrl ? (
|
|
<img
|
|
src={f.avatarUrl}
|
|
alt=""
|
|
className="h-8 w-8 shrink-0 rounded-full object-cover"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
|
{letter}
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm text-white">{f.displayName}</p>
|
|
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
|
|
</div>
|
|
{busy ? (
|
|
<SpinnerIcon className="h-4 w-4 text-brand-400" />
|
|
) : (
|
|
<PlusIcon className="h-4 w-4 text-neutral-400" />
|
|
)}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
|
>
|
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
|
<p className="min-w-0 flex-1 break-words">{error}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<footer className="border-t border-white/5 p-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleLeave()}
|
|
disabled={busyLeave}
|
|
className="inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs font-medium text-rose-200 transition hover:bg-rose-500/20 disabled:opacity-60"
|
|
>
|
|
{busyLeave ? (
|
|
<SpinnerIcon className="h-3.5 w-3.5" />
|
|
) : (
|
|
<SignOutIcon className="h-3.5 w-3.5" />
|
|
)}
|
|
<span>{t('app:group.info_leave')}</span>
|
|
</button>
|
|
</footer>
|
|
</aside>
|
|
);
|
|
}
|