Files
pipu-music/apps/web/src/features/social/MiniSocial.tsx
T
mrleo1nid cb22f15912
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m20s
release / desktop (Linux и Windows в релиз) (push) Successful in 19m22s
ci / ts / ts (packages + web) (push) Successful in 3m9s
release / release (версия и заметки) (push) Successful in 10s
release / android (APK в релиз) (push) Successful in 4m7s
release / prune (старые файлы и прогоны) (push) Successful in 11s
fix(web): polish social UI and prevent doubled shared audio
2026-10-23 19:54:10 +03:00

75 lines
5.1 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useRouterState, useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Button, EmptyState, FormError, Icon } from '../../shared/ui/ui';
import { chatIcon, closeIcon, groupIcon, searchIcon } from '../../shared/ui/icons';
import { useErrorText } from '../../shared/lib/errors';
import { ChatConversation } from './ChatConversation';
import { Presence } from './Presence';
import { useSocialDialogOpen } from './Social';
import { useSocial } from './queries';
import s from './social.module.css';
/** Lives in the shell so browsing and minimizing never change the selected conversation. */
export function MiniSocial() {
const { t } = useTranslation();
const { api, inbox, key, user } = useSocial();
const errorText = useErrorText();
const navigate = useNavigate();
const pathname = useRouterState({ select: state => state.location.pathname });
const dialogOpen = useSocialDialogOpen();
const [desktop, setDesktop] = useState(() => window.matchMedia?.('(min-width: 1024px)').matches ?? false);
const [open, setOpen] = useState(false);
const [peer, setPeer] = useState('');
const [search, setSearch] = useState('');
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLElement>(null);
const shown = desktop && pathname !== '/friends';
useEffect(() => {
const media = window.matchMedia?.('(min-width: 1024px)');
if (!media) return;
const change = () => setDesktop(media.matches);
media.addEventListener('change', change);
return () => media.removeEventListener('change', change);
}, []);
useEffect(() => {
if (open && shown) panel.current?.querySelector<HTMLInputElement>('input')?.focus();
}, [open, shown]);
const friends = (inbox.data?.people ?? []).filter(person => person.relationship === 'friend');
const selected = friends.find(person => person.id === peer);
const unread = friends.reduce((sum, person) => sum + person.unread, 0);
const filtered = [...friends].sort((a, b) => Number(b.online) - Number(a.online) || (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name))
.filter(person => `${person.display_name} ${person.username}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
const minimize = () => { setOpen(false); trigger.current?.focus(); };
if (!shown) return null;
return <aside className={`${s.miniDock} ${user.ui_prefs.web_player === 'island' ? s.miniDockIsland : ''}`} aria-label={t('social.mini_chat')}>
<section hidden={!open} ref={panel} id="mini-social-panel" className={s.miniPanel} aria-label={t('social.title')}
onKeyDown={event => { if (event.key === 'Escape' && !event.defaultPrevented && !dialogOpen) { event.stopPropagation(); minimize(); } }}>
<header className={s.miniHeader}>
<strong>{t('social.title')}</strong>
<Button variant="ghost" onClick={() => { setOpen(false); void navigate({ to: '/friends', search: selected ? { peer: selected.id, tab: 'messages' } : {} }); }}>{t('social.open_full')}</Button>
<Button variant="ghost" icon={closeIcon} aria-label={t('social.minimize_chat')} onClick={minimize} />
</header>
<FormError>{inbox.error ? errorText(inbox.error) : null}</FormError>
<div className={s.miniWorkspace}>
<div className={s.miniFriends}>
<label className={s.listSearch}><Icon src={searchIcon} size={16} /><input aria-label={t('social.find_conversation')} placeholder={t('social.find_conversation')} value={search} onChange={event => setSearch(event.target.value)} /></label>
{inbox.isPending && <p className={s.muted}>{t('social.loading')}</p>}
{inbox.isSuccess && !filtered.length && <p className={s.muted}>{t(search ? 'social.no_results' : 'social.no_friends_share')}</p>}
{filtered.map(person => <button key={person.id} type="button" className={`${s.dialogRow} ${peer === person.id ? s.selected : ''}`} aria-pressed={peer === person.id} onClick={() => setPeer(person.id)}>
<span className={`${s.avatar} ${person.online ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
<span className={s.dialogBody}><strong>{person.display_name}</strong><Presence online={person.online} /></span>
{person.unread > 0 && <span className={s.badge}>{person.unread}</span>}
</button>)}
</div>
{selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={open && !dialogOpen} />
: <div className={s.welcome}><EmptyState icon={groupIcon}>{t('social.choose_chat')}</EmptyState></div>}
</div>
</section>
<button ref={trigger} type="button" className={s.miniTrigger} aria-label={t('social.mini_chat')} title={t('social.mini_chat')} aria-expanded={open} aria-controls="mini-social-panel" onClick={() => setOpen(value => !value)}>
<Icon src={open ? closeIcon : chatIcon} size={22} />
{unread > 0 && <span className={s.badge}>{unread > 99 ? '99+' : unread}</span>}
</button>
</aside>;
}