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
75 lines
5.1 KiB
TypeScript
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>;
|
|
}
|