Files
ChatApp/apps/desktop/src/components/UserProfilePopover.tsx
T
byGalax 1303c8e26f feat: backup/restore, user profile popover, image compress, video blur, wake lock
- backup/restore dialog + user profile popover components
- image compression, video blur, wake lock utilities
- message cache + conversation messages hook refinements
- call context, active speakers, screen share dialog tweaks
- audio + screen share settings persistence
- refreshed app icons (smaller sizes) across all platforms
2026-04-21 12:11:09 +02:00

127 lines
3.8 KiB
TypeScript

import type { ProfileBrief } from '@chat-app/shared/friends';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { usePeerPresence } from '../lib/usePeerPresence';
import { Avatar } from './Avatar';
interface Props {
userId: string;
profile: ProfileBrief | null;
x: number;
y: number;
onClose: () => void;
onStartDm?: (userId: string) => void;
}
const PRESENCE_DOT: Record<string, string> = {
online: 'bg-emerald-500',
idle: 'bg-amber-400',
dnd: 'bg-rose-500',
invisible: 'bg-neutral-500',
offline: 'bg-neutral-400 dark:bg-neutral-600',
};
const CARD_W = 280;
const CARD_H = 180;
// Hover/click card surfaced from avatars around the app. Shows display name,
// @handle, presence state + status message, plus a DM-start button when
// the clicked profile isn't the caller.
export function UserProfilePopover({
userId,
profile,
x,
y,
onClose,
onStartDm,
}: Props) {
// Subscribe to the same presence feed that the conversation header uses,
// so status updates flow in live.
const presence = usePeerPresence(userId);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (t?.closest('[data-user-popover]')) return;
if (t?.closest('[data-user-popover-trigger]')) return;
onClose();
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [onClose]);
const left = Math.min(Math.max(8, x), window.innerWidth - CARD_W - 8);
const top = Math.min(Math.max(8, y), window.innerHeight - CARD_H - 8);
const displayName = profile?.displayName ?? '?';
const username = profile?.username ?? '';
const state = presence?.state ?? 'offline';
const showPresence = state !== 'invisible';
const statusMessage = presence?.statusMessage?.trim() ?? '';
return createPortal(
<div
data-user-popover
role="dialog"
aria-label={displayName}
style={{ left, top, width: CARD_W }}
className="fixed z-[80] flex flex-col gap-3 rounded-xl border border-line bg-surface-2/95 p-4 shadow-xl backdrop-blur-md"
>
<div className="flex items-center gap-3">
<div className="relative">
<Avatar
url={profile?.avatarUrl ?? null}
displayName={displayName}
className="h-14 w-14 text-lg"
/>
{showPresence && (
<span
aria-hidden="true"
className={
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full ring-2 ring-surface-2 ' +
(PRESENCE_DOT[state] ?? PRESENCE_DOT.offline)
}
/>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-fg">
{displayName}
</p>
{username && (
<p className="truncate text-xs text-fg-muted">@{username}</p>
)}
</div>
</div>
{statusMessage && state !== 'offline' && (
<p className="rounded-md bg-surface-3 px-2.5 py-1.5 text-xs italic text-fg-muted">
{statusMessage}
</p>
)}
{onStartDm && (
<button
type="button"
onClick={() => {
onStartDm(userId);
onClose();
}}
className="inline-flex cursor-pointer items-center justify-center rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
>
Nachricht senden
</button>
)}
</div>,
document.body,
);
}