Files
ChatApp/apps/desktop/src/components/UserBar.tsx
T
byGalax 825160ee46 feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.

Highlights:
  - All 17 Tauri release commits ported (audio fixes, custom notification
    sound, Discord-style chat UX, profile banner, changelog page,
    Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
    fix).
  - Native napi-rs audio-loopback addon with WASAPI process-loopback:
      * EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
        never hear themselves echoed back through the capture.
      * INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
        picked window's audio is captured, not the whole OS mixer
        (Discord parity).
      * HWND -> PID resolution via Win32 GetWindowThreadProcessId.
  - Discord-style screen-source picker (thumbnail grid, screens vs
    apps tabs, live-refreshing thumbnails).
  - Hash routing fix for packaged builds (file:// can't resolve
    BrowserRouter paths).
  - Tauri sources removed (apps/desktop/src-tauri).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:35:01 +02:00

159 lines
5.6 KiB
TypeScript

import { updateOwnProfile } from '@chat-app/shared/auth';
import type { PresenceState } from '@chat-app/shared/supabase';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
import { Avatar } from './Avatar';
import { ChevronDownIcon } from './icons';
const STATUS_MAX = 128;
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, 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',
};
export function UserBar() {
const { t } = useTranslation(['app']);
const { profile, refreshProfile } = useAuth();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const presence = profile?.presenceState ?? 'offline';
const persistedStatus = profile?.statusMessage ?? '';
const [statusDraft, setStatusDraft] = useState(persistedStatus);
// Re-sync local draft with the server when the profile refreshes (e.g. after
// a successful save) — without this the input would forget any incoming
// updates from another device.
useEffect(() => {
setStatusDraft(persistedStatus);
}, [persistedStatus]);
// Subtitle priority: custom status when online and set, else label, else "Offline".
const subtitle =
presence === 'offline'
? t('app:presence.offline')
: persistedStatus.trim().length > 0
? persistedStatus.trim()
: t('app:presence.' + presence);
async function changePresence(next: PresenceState) {
if (busy || next === presence) {
setOpen(false);
return;
}
setBusy(true);
try {
await updateOwnProfile(supabase, { presenceState: next });
await refreshProfile();
} catch (err: unknown) {
console.error('updatePresence failed', err);
} finally {
setBusy(false);
setOpen(false);
}
}
async function saveStatus() {
const next = statusDraft.trim().slice(0, STATUS_MAX);
if (next === persistedStatus) return;
setBusy(true);
try {
await updateOwnProfile(supabase, { statusMessage: next.length === 0 ? null : next });
await refreshProfile();
} catch (err: unknown) {
console.error('updateStatusMessage failed', err);
} finally {
setBusy(false);
}
}
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
>
<div className="relative">
<Avatar
url={profile?.avatarUrl ?? null}
displayName={profile?.displayName ?? profile?.username}
className="h-10 w-10 text-sm"
/>
<span
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-2 ' +
PRESENCE_DOT[presence]
}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-fg">{profile?.displayName ?? '—'}</p>
<p className="truncate text-xs text-fg-muted">{subtitle}</p>
</div>
<ChevronDownIcon
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
/>
</button>
{open && (
<div
role="menu"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl dark:bg-[#313338]"
>
<div className="border-b border-line p-2">
<input
type="text"
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value.slice(0, STATUS_MAX))}
onBlur={() => void saveStatus()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void saveStatus();
setOpen(false);
}
if (e.key === 'Escape') {
setStatusDraft(persistedStatus);
setOpen(false);
}
}}
placeholder={t('app:presence.status_placeholder', {
defaultValue: 'Status setzen…',
})}
maxLength={STATUS_MAX}
className="w-full rounded-md border border-line bg-surface-2 px-2 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent dark:bg-[#383a40]"
/>
</div>
{PRESENCE_OPTIONS.map((opt) => (
<button
key={opt}
type="button"
role="menuitemradio"
aria-checked={opt === presence}
onClick={() => void changePresence(opt)}
disabled={busy}
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-fg transition hover:bg-surface-2 disabled:opacity-50 dark:hover:bg-[#383a40]"
>
<span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
<span className="flex-1">{t('app:presence.' + opt)}</span>
{opt === presence && <span className="text-xs text-accent"></span>}
</button>
))}
</div>
)}
</div>
);
}