Files
ChatApp/apps/desktop/src/components/GroupInfoPanel.tsx
T
byGalax 4db65993d5
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
feat: redesign + avatars + theme + call presence fixes (v0.6.0)
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
  ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel

Calls:
- Split call UI: IncomingCallPanel, InCallPanel, CallControls,
  CallParticipantTile, ScreenShareViewer
- Active speaker hook (useActiveSpeakers)
- Fix: ActiveCallBanner stayed hidden after hangup while peers in room.
  - useCallPresence: bind presence callbacks only when we own subscribe
    (Supabase forbids .on() after .subscribe() on shared dedup'd channels)
  - useCallPresence: never removeChannel — channel is shared with CallContext
    so tearing it down on ConversationHeader unmount killed live tracking
  - ActiveCallBanner: lastCallConversationId fallback so banner shows
    instantly after hangup, auto-dismiss when room confirmed empty
- Drop unused useAnyActiveCall

Bump tauri version 0.5.0 -> 0.6.0
2026-04-20 00:26:19 +02:00

260 lines
10 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="absolute inset-y-0 right-0 z-20 flex w-[320px] flex-col border-l border-white/5 bg-ink-900/95 shadow-xl backdrop-blur-xl animate-slide-up"
>
<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>
);
}