feat(web): add desktop mini chat and move social controls to profile
This commit is contained in:
1 parent
f087ec9f88
commit
6450027b88
10 files changed
+259
-44
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const width of [1280, 1920]) {
|
||||
test(`mini chat keeps the page and draft at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
if (width === 1920) {
|
||||
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Остров', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Остров', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('link', { name: 'Главная', exact: true }).first().click();
|
||||
}
|
||||
const dock = page.getByRole('complementary', { name: 'Друзья и чат', exact: true });
|
||||
const toggle = dock.getByRole('button', { name: 'Друзья и чат', exact: true });
|
||||
await toggle.click();
|
||||
const panel = dock.getByRole('region', { name: 'Друзья и общение', exact: true });
|
||||
await panel.getByRole('textbox', { name: 'Найти диалог', exact: true }).fill('moderator');
|
||||
await panel.getByRole('button', { name: /moderator/ }).click();
|
||||
const composer = panel.getByRole('textbox', { name: 'Сообщение', exact: true });
|
||||
await composer.fill('Черновик из мини-чата');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await panel.getByRole('button', { name: 'Свернуть чат', exact: true }).click();
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(toggle).toBeFocused();
|
||||
await page.getByRole('link', { name: 'История', exact: true }).first().click();
|
||||
await toggle.click();
|
||||
await expect(composer).toHaveValue('Черновик из мини-чата');
|
||||
await panel.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||
await expect(panel.getByText('Черновик из мини-чата', { exact: true })).toBeVisible();
|
||||
await expect(composer).toBeEmpty();
|
||||
await expect(page).toHaveURL(/\/collection\/history$/);
|
||||
const panelBox = (await panel.boundingBox())!;
|
||||
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер', exact: true }).boundingBox())!;
|
||||
expect(panelBox.width).toBeLessThanOrEqual(600);
|
||||
expect(panelBox.x).toBeGreaterThanOrEqual(width - 640);
|
||||
expect(panelBox.y).toBeGreaterThanOrEqual(60);
|
||||
expect(panelBox.y + panelBox.height).toBeLessThan(playerBox.y);
|
||||
await page.screenshot({ path: info.outputPath('mini-chat.png'), fullPage: true });
|
||||
await composer.fill('Продолжить в полном чате');
|
||||
await composer.press('Escape');
|
||||
await expect(panel).toBeHidden();
|
||||
await toggle.click();
|
||||
await expect(composer).toHaveValue('Продолжить в полном чате');
|
||||
await page.setViewportSize({ width: 800, height: 900 });
|
||||
await expect(dock).toHaveCount(0);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect(composer).toHaveValue('Продолжить в полном чате');
|
||||
await panel.getByRole('button', { name: 'Открыть раздел', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/friends\?/);
|
||||
await expect(dock).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveValue('Продолжить в полном чате');
|
||||
});
|
||||
}
|
||||
@@ -134,8 +134,9 @@ describe('social', () => {
|
||||
|
||||
it('saves privacy and independent request admission preferences', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { api, user } = await renderApp([alice], '/friends', { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Моя музыка скрыта' }));
|
||||
const { api, user } = await renderApp([alice], '/', { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Моя музыка скрыта' }));
|
||||
const privacy = await screen.findByRole('group', { name: 'Кто видит мою музыку' });
|
||||
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
|
||||
@@ -152,15 +153,19 @@ describe('social', () => {
|
||||
it('shows persistent notifications and acknowledges only the chosen event', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
await screen.findByRole('heading', { name: 'Друзья и общение' });
|
||||
const { api, user, queryClient, router } = await renderApp([alice, bob], '/', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
|
||||
api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Уведомления 1' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Уведомления 1' }));
|
||||
await screen.findByText('Хочет добавить вас в друзья');
|
||||
await user.click(screen.getByRole('button', { name: 'Прочитано' }));
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument());
|
||||
expect(api.notifications[0]?.read).toBe(true);
|
||||
await user.click(screen.getByRole('button', { name: 'Открыть' }));
|
||||
await screen.findByRole('button', { name: 'Принять' });
|
||||
expect(router.state.location.pathname).toBe('/friends');
|
||||
expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id });
|
||||
});
|
||||
it('searches, requests, cancels, accepts and exchanges messages and music', async () => {
|
||||
|
||||
@@ -149,7 +149,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||
|
||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||
const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> });
|
||||
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string } => typeof s.peer === 'string' ? { peer: s.peer } : {}, component: () => <SocialPage peer={socialRoute.useSearch().peer} /> });
|
||||
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string; tab?: 'messages' | 'people' | 'requests' } => ({ ...(typeof s.peer === 'string' ? { peer: s.peer } : {}), ...(s.tab === 'messages' || s.tab === 'people' || s.tab === 'requests' ? { tab: s.tab } : {}) }), component: () => { const search = socialRoute.useSearch(); return <SocialPage peer={search.peer} initialTab={search.tab} />; } });
|
||||
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
|
||||
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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 { useSocial, useSocialDialogOpen } from './Social';
|
||||
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>;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { Presence } from './Presence';
|
||||
const scopes = new WeakMap<object, number>();
|
||||
let nextScope = 0;
|
||||
|
||||
function useSocial() {
|
||||
export function useSocial() {
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const server = useDesktop()?.server ?? '';
|
||||
@@ -36,11 +36,15 @@ export function FriendRecommendations() {
|
||||
}
|
||||
|
||||
type ShareValue = { attachment: MusicAttachment; url: string };
|
||||
type AccountDialog = 'privacy' | 'notifications' | null;
|
||||
const AccountDialogContext = createContext<{ dialog: AccountDialog; setDialog: (dialog: AccountDialog) => void }>({ dialog: null, setDialog: () => {} });
|
||||
export const useSocialDialogOpen = () => useContext(AccountDialogContext).dialog !== null;
|
||||
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
|
||||
export const useMusicShare = () => useContext(ShareContext);
|
||||
export function SocialProvider({ children }: { children: ReactNode }) {
|
||||
const [share, setShare] = useState<ShareValue | null>(null);
|
||||
return <ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider>;
|
||||
const [dialog, setDialog] = useState<AccountDialog>(null);
|
||||
return <AccountDialogContext.Provider value={{ dialog, setDialog }}><ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider></AccountDialogContext.Provider>;
|
||||
}
|
||||
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -75,10 +79,55 @@ function ShareDialog({ value, close }: { value: ShareValue; close: () => void })
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
type SocialTab = 'messages' | 'people' | 'requests' | 'notifications';
|
||||
export function useSocialProfileMenu() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { api, inbox, key } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const errorText = useErrorText();
|
||||
const { dialog, setDialog } = useContext(AccountDialogContext);
|
||||
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const people = inbox.data?.people ?? [];
|
||||
const currentVisibility = inbox.data?.activity_visibility ?? 'nobody';
|
||||
const open = (id: string) => {
|
||||
const relationship = people.find(p => p.id === id)?.relationship;
|
||||
const tab = relationship === 'friend' ? 'messages' : relationship === 'incoming' || relationship === 'outgoing' ? 'requests' : 'people';
|
||||
setDialog(null);
|
||||
void navigate({ to: '/friends', search: { peer: id, tab } });
|
||||
};
|
||||
return {
|
||||
items: [
|
||||
{ label: t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings'), icon: currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon, onSelect: () => setDialog('privacy') },
|
||||
{ label: `${t('social.notifications')}${inbox.data?.unread ? ` ${inbox.data.unread}` : ''}`, icon: inboxIcon, onSelect: () => setDialog('notifications') },
|
||||
],
|
||||
dialogs: <>
|
||||
{dialog === 'privacy' && <Dialog title={t('social.privacy_settings')} onClose={() => setDialog(null)}>
|
||||
<fieldset className={s.privacy} disabled={!inbox.data || visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => visibility.mutate(v)}>{t(`social.visibility_${v}`)}</Button>)}</div><p className={s.muted}>{t('social.activity_privacy_hint')}</p></fieldset>
|
||||
<FormError>{visibility.error || action.error || inbox.error ? errorText(visibility.error ?? action.error ?? inbox.error) : null}</FormError>
|
||||
<ListeningPanel showRequests={false} />
|
||||
{people.some(p => p.relationship === 'blocked') && <section><h3>{t('social.blocked_people')}</h3>{people.filter(p => p.relationship === 'blocked').map(p => <Person key={p.id} person={p} busy={action.isPending} open={() => {}} act={op => action.mutate({ action: op, target_id: p.id })} />)}</section>}
|
||||
</Dialog>}
|
||||
{dialog === 'notifications' && <Dialog title={t('social.notifications')} onClose={() => setDialog(null)}>
|
||||
<FormError>{inbox.error || action.error ? errorText(inbox.error ?? action.error) : null}</FormError>
|
||||
<div className={s.events}>
|
||||
{inbox.data?.notifications.length === 0 && <EmptyState icon={inboxIcon}>{t('social.no_notifications')}</EmptyState>}
|
||||
{inbox.data?.notifications.map(n => <article key={n.id} className={`${s.event} ${!n.read ? s.unread : ''}`}>
|
||||
<span className={s.avatar} aria-hidden>{n.actor_name.slice(0, 1).toUpperCase()}</span>
|
||||
<div className={s.eventBody}><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString(i18n.language)}</time></div>
|
||||
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
|
||||
</article>)}
|
||||
</div>
|
||||
</Dialog>}
|
||||
</>,
|
||||
};
|
||||
}
|
||||
|
||||
type SocialTab = 'messages' | 'people' | 'requests';
|
||||
type ConfirmAction = { person: SocialPerson; action: 'remove' | 'block' };
|
||||
|
||||
export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
export function SocialPage({ peer = '', initialTab }: { peer?: string; initialTab?: SocialTab }) {
|
||||
const { dialog } = useContext(AccountDialogContext);
|
||||
const { t, i18n } = useTranslation();
|
||||
const { api, inbox, key, user } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
@@ -87,7 +136,6 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
const [tab, setTab] = useState<SocialTab>('messages');
|
||||
const [search, setSearch] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [settings, setSettings] = useState(false);
|
||||
const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
|
||||
const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
@@ -102,10 +150,10 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
|
||||
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: tab === 'people' && q.length >= 2 });
|
||||
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { setConfirm(null); await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const people = inbox.data?.people ?? [];
|
||||
const friends = people.filter(p => p.relationship === 'friend');
|
||||
const conversations = [...friends].sort((a, b) => (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name));
|
||||
useEffect(() => { if (initialTab) setTab(initialTab); }, [peer, initialTab]);
|
||||
const initialPeer = conversations[0]?.id;
|
||||
useEffect(() => {
|
||||
// Pin the initial desktop selection: a newer message in another dialog must
|
||||
@@ -130,15 +178,10 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
if (op === 'block' || op === 'remove' && person.relationship === 'friend') setConfirm({ person, action: op });
|
||||
else action.mutate({ action: op, target_id: person.id });
|
||||
};
|
||||
const currentVisibility = inbox.data?.activity_visibility ?? 'nobody';
|
||||
const listLabel = t(tab === 'messages' ? 'social.find_conversation' : tab === 'requests' ? 'social.find_request' : 'social.search');
|
||||
return <div className={`${s.page} ${tab === 'messages' && peer && selected ? s.conversationOpen : ''}`}>
|
||||
<header className={s.pageHeader}>
|
||||
<div><span className={s.eyebrow}>{t('social.space')}</span><h1>{t('social.title')}</h1><p className={s.muted}>{t('social.subtitle')}</p></div>
|
||||
<div className={s.headerActions}>
|
||||
<Button variant="ghost" icon={currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon} onClick={() => setSettings(true)}>{t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings')}</Button>
|
||||
<Button variant="ghost" icon={inboxIcon} aria-pressed={tab === 'notifications'} onClick={() => setTab('notifications')}>{t('social.notifications')}{inbox.data?.unread ? <span className={s.badge}>{inbox.data.unread}</span> : null}</Button>
|
||||
</div>
|
||||
<h1>{t('social.title')}</h1>
|
||||
</header>
|
||||
<ListeningPanel showPreferences={false} />
|
||||
<section className={s.hub}>
|
||||
@@ -147,15 +190,7 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
<Button className={s.addFriend} variant="ghost" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button>
|
||||
</nav>
|
||||
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
|
||||
{tab === 'notifications' ? <div className={s.events}>
|
||||
<h2>{t('social.notifications')}</h2>
|
||||
{inbox.data?.notifications.length === 0 && <EmptyState icon={inboxIcon}>{t('social.no_notifications')}</EmptyState>}
|
||||
{inbox.data?.notifications.map(n => <article key={n.id} className={`${s.event} ${!n.read ? s.unread : ''}`}>
|
||||
<span className={s.avatar} aria-hidden>{n.actor_name.slice(0, 1).toUpperCase()}</span>
|
||||
<div className={s.eventBody}><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString(i18n.language)}</time></div>
|
||||
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
|
||||
</article>)}
|
||||
</div> : <div className={tab === 'messages' ? s.workspace : s.directory}>
|
||||
<div className={tab === 'messages' ? s.workspace : s.directory}>
|
||||
<aside ref={listRef} className={s.peopleList} aria-label={t(tab === 'messages' ? 'social.messages' : 'social.people')}>
|
||||
<div className={s.listSearch}><Icon src={searchIcon} size={18} /><input ref={searchRef} type="search" aria-label={listLabel} placeholder={listLabel} value={search} maxLength={80} onChange={e => setSearch(e.target.value)} /></div>
|
||||
{inbox.isPending && <div className={s.stack} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(i => <Skeleton key={i} style={{ height: 64 }} />)}</div>}
|
||||
@@ -167,15 +202,9 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
{p.unread > 0 && <span className={s.badge}>{p.unread}</span>}
|
||||
</button> : <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={op => act(p, op)} />)}
|
||||
</aside>
|
||||
{tab === 'messages' && (selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={(!narrow || !!peer) && !settings && !confirm} onBack={back} actions={<FriendMenu person={selected} act={op => act(selected, op)} />} /> : <div className={s.welcome}><span className={s.welcomeIcon}><Icon src={musicIcon} size={36} /></span><h2>{t('social.conversation_title')}</h2><p>{t('social.choose_chat')}</p><Button variant="primary" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button></div>)}
|
||||
</div>}
|
||||
{tab === 'messages' && (selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={(!narrow || !!peer) && !dialog && !confirm} onBack={back} actions={<FriendMenu person={selected} act={op => act(selected, op)} />} /> : <div className={s.welcome}><span className={s.welcomeIcon}><Icon src={musicIcon} size={36} /></span><h2>{t('social.conversation_title')}</h2><p>{t('social.choose_chat')}</p><Button variant="primary" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button></div>)}
|
||||
</div>
|
||||
</section>
|
||||
{settings && <Dialog title={t('social.privacy_settings')} onClose={() => setSettings(false)}>
|
||||
<fieldset className={s.privacy} disabled={!inbox.data || visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => visibility.mutate(v)}>{t(`social.visibility_${v}`)}</Button>)}</div><p className={s.muted}>{t('social.activity_privacy_hint')}</p></fieldset>
|
||||
<FormError>{visibility.error ? errorText(visibility.error) : null}</FormError>
|
||||
<ListeningPanel showRequests={false} />
|
||||
{people.some(p => p.relationship === 'blocked') && <section><h3>{t('social.blocked_people')}</h3>{people.filter(p => p.relationship === 'blocked').map(p => <Person key={p.id} person={p} busy={action.isPending} open={() => {}} act={op => act(p, op)} />)}</section>}
|
||||
</Dialog>}
|
||||
{confirm && <Dialog title={t(confirm.action === 'block' ? 'social.confirm_block' : 'social.confirm_remove', { name: confirm.person.display_name })} onClose={() => { if (!action.isPending) setConfirm(null); }} footer={<><Button disabled={action.isPending} onClick={() => setConfirm(null)}>{t('form.cancel')}</Button><Button variant="danger" busy={action.isPending} onClick={() => action.mutate({ action: confirm.action, target_id: confirm.person.id })}>{t(`social.${confirm.action}`)}</Button></>}><p className={s.muted}>{t(confirm.action === 'block' ? 'social.block_hint' : 'social.remove_hint')}</p><FormError>{action.error ? errorText(action.error) : null}</FormError></Dialog>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
.page { width: 100%; max-width: 1480px; margin: 0 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 20px; }
|
||||
.page { width: 100%; max-width: 1480px; margin: 0 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||
.pageHeader { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-shrink: 0; }
|
||||
.page h1 { font-size: clamp(22px, 2.2vw, 30px); letter-spacing: -.035em; margin: 4px 0 0; }
|
||||
.pageHeader p { margin: 8px 0 0; }
|
||||
.eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: 10px; font-weight: 600; color: var(--text3); }
|
||||
.page h1 { font-size: 24px; letter-spacing: -.035em; margin: 0; }
|
||||
.muted { color: var(--text2); font-size: 13px; line-height: 1.6; }
|
||||
.headerActions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: end; }
|
||||
.hub { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--surface); border: 1px solid var(--line2); border-radius: 20px; overflow: hidden; box-shadow: 0 12px 48px var(--hover); }
|
||||
.tabs { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); padding: 10px 16px; flex-shrink: 0; }
|
||||
.tabs > button:not(.addFriend) { position: relative; display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 10px; color: var(--text2); font-weight: 600; transition: color .18s, background .18s; }
|
||||
@@ -133,6 +130,42 @@
|
||||
.musicUnavailable { opacity: .7; }
|
||||
.musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; }
|
||||
.musicMessages .musicCard { width: 100%; }
|
||||
.miniDock { grid-area: content; align-self: end; justify-self: end; z-index: 25 !important; display: flex; flex-direction: column; align-items: end; gap: 12px; width: min(600px, calc(100% - 32px)); min-height: 0; margin: 16px; pointer-events: none; }
|
||||
.miniPanel { pointer-events: auto; width: 100%; height: min(640px, calc(100dvh - 250px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line2); border-radius: 18px; background: var(--surface); box-shadow: 0 16px 64px #0008; overflow: hidden; animation: panelIn .2s ease-out; }
|
||||
.miniDockIsland { margin-bottom: 132px; }
|
||||
.miniDockIsland .miniPanel { height: min(640px, calc(100dvh - 290px)); }
|
||||
.miniPanel[hidden] { display: none; }
|
||||
.miniHeader { display: flex; align-items: center; gap: 6px; padding: 8px 10px 8px 16px; border-bottom: 1px solid var(--line); }
|
||||
.miniHeader > strong { flex: 1; font-size: 14px; }
|
||||
.miniHeader > button { min-height: 36px; padding: 0 8px; font-size: 12px; }
|
||||
.miniWorkspace { flex: 1; min-height: 0; display: grid; grid-template-columns: 170px minmax(0, 1fr); }
|
||||
.miniFriends { overflow-y: auto; min-width: 0; padding: 8px; border-right: 1px solid var(--line); }
|
||||
.miniFriends .listSearch { padding: 0 8px; gap: 5px; margin: 0 0 8px; min-height: 36px; }
|
||||
.miniFriends .listSearch input { height: 36px; font-size: 11px; }
|
||||
.miniFriends .dialogRow { gap: 8px; padding: 10px 6px; }
|
||||
.miniFriends .avatar { width: 28px; height: 28px; font-size: 12px; }
|
||||
.miniFriends .dialogBody strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; }
|
||||
.miniFriends .badge { min-width: 17px; height: 17px; padding: 0 4px; font-size: 10px; }
|
||||
.miniWorkspace .chat { min-width: 0; }
|
||||
.miniWorkspace .chatHeader { gap: 8px; padding: 10px; }
|
||||
.miniWorkspace .chatHeader .avatar { width: 30px; height: 30px; font-size: 12px; }
|
||||
.miniWorkspace .chatIdentity h2 { font-size: 14px; }
|
||||
.miniWorkspace .chatTools .togetherLabel { display: none; }
|
||||
.miniWorkspace .chatTools > button, .miniWorkspace .chatTools > div > button { width: 34px; min-width: 34px; padding: 0; }
|
||||
.miniWorkspace .chatTabs { gap: 14px; padding: 0 12px; }
|
||||
.miniWorkspace .chatTabs button { font-size: 11px; }
|
||||
.miniWorkspace .messages { padding: 12px; gap: 10px; }
|
||||
.miniWorkspace .bubble { max-width: 94%; font-size: 13px; padding: 8px 10px 4px; }
|
||||
.miniWorkspace .composer { padding: 8px; gap: 4px; }
|
||||
.miniWorkspace .composer .iconButton { width: 34px; min-width: 34px; }
|
||||
.miniWorkspace .composer textarea { padding: 8px; font-size: 13px; }
|
||||
.miniWorkspace .welcome { padding: 20px; }
|
||||
.miniTrigger { position: relative; pointer-events: auto; display: grid; place-items: center; width: 48px; height: 48px; flex-shrink: 0; border-radius: 50%; color: var(--text); border: 1px solid var(--line2); background: var(--surface); box-shadow: 0 6px 24px #0005; cursor: pointer; }
|
||||
.miniTrigger:hover, .miniTrigger[aria-expanded='true'] { background: var(--accent-fill); color: var(--on-accent, #fff); }
|
||||
.miniTrigger:focus-visible { outline: 2px solid var(--accent2); outline-offset: 3px; }
|
||||
.miniTrigger > .badge { position: absolute; top: -5px; right: -5px; }
|
||||
@media(max-width: 1023px) { .miniDock { display: none; } }
|
||||
@media(prefers-reduced-motion: reduce) { .miniPanel { animation: none; } }
|
||||
.attachmentCaption { font-size: 12px; color: var(--text2); white-space: pre-wrap; }
|
||||
.expandText { display: block; color: var(--accent2); font-size: 12px; padding: 6px 0; }
|
||||
@keyframes conversationIn { from { opacity: .4; transform: translateX(8px); } to { opacity: 1; transform: none; } }
|
||||
@@ -140,10 +173,10 @@
|
||||
@keyframes messageIn { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
@keyframes tabIn { from { opacity: 0; transform: scaleX(.5); } to { opacity: 1; transform: none; } }
|
||||
@keyframes typingPulse { from { opacity: .35; transform: translateY(0); } to { opacity: 1; transform: translateY(-3px); } }
|
||||
@media(max-width: 1150px) { .headerActions { gap: 0; } .pageHeader { align-items: start; } .headerActions > button { font-size: 12px; padding: 0 10px; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } }
|
||||
@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .headerActions { justify-content: start; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } }
|
||||
@media(max-width: 1150px) { .pageHeader { align-items: start; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } }
|
||||
@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } }
|
||||
@media(max-width: 767px) {
|
||||
.page { gap: 12px; } .pageHeader { padding: 0 2px; } .pageHeader p { display: none; } .eyebrow { display: none; } .page h1 { font-size: 23px; } .headerActions { width: 100%; justify-content: space-between; }
|
||||
.page { gap: 12px; } .pageHeader { padding: 0 2px; } .page h1 { font-size: 23px; }
|
||||
.hub { border-radius: 14px; } .tabs { flex-wrap: wrap; } .tabs > button:not(.addFriend) { flex: 1; font-size: 12px; } .tabs .addFriend { width: 100%; height: 36px; font-size: 12px; }
|
||||
.workspace { display: flex; flex-direction: column; } .workspace > .peopleList { flex: 1; border: 0; } .workspace > .chat, .workspace > .welcome { display: none; }
|
||||
.conversationOpen > .pageHeader, .conversationOpen > .stack, .conversationOpen .hub > .tabs, .conversationOpen .workspace > .peopleList { display: none; }
|
||||
|
||||
@@ -38,7 +38,8 @@ import { PlayerBar } from './PlayerBar';
|
||||
import { SearchBox } from './SearchBox';
|
||||
import s from './AppShell.module.css';
|
||||
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
|
||||
import { useSocialUnread, SocialProvider } from '../features/social/Social';
|
||||
import { useSocialUnread, useSocialProfileMenu, SocialProvider } from '../features/social/Social';
|
||||
import { MiniSocial } from '../features/social/MiniSocial';
|
||||
import { ListeningBanner } from '../features/social/Listening';
|
||||
|
||||
type NavPath =
|
||||
@@ -164,6 +165,7 @@ function Shell() {
|
||||
const { t } = useTranslation();
|
||||
const user = useUser();
|
||||
const socialUnread = useSocialUnread();
|
||||
const socialProfile = useSocialProfileMenu();
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const navigate = useNavigate();
|
||||
@@ -270,6 +272,7 @@ function Shell() {
|
||||
</div>
|
||||
}
|
||||
items={[
|
||||
...socialProfile.items,
|
||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||
...(!desktop
|
||||
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
|
||||
@@ -301,7 +304,9 @@ function Shell() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{socialProfile.dialogs}
|
||||
<ShellMain />
|
||||
<MiniSocial />
|
||||
|
||||
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
Социальные функции работают между пользователями одного сервера. В web и desktop
|
||||
открывается раздел «Друзья и общение», на Android — вкладка «Друзья».
|
||||
|
||||
|
||||
На ПК справа над плеером доступна мини-панель друзей и чата шириной до 600 px.
|
||||
Она позволяет переписываться без ухода с текущей страницы, сворачивается с сохранением
|
||||
черновика и открывает выбранную переписку в полном разделе. На экранах уже 1024 px
|
||||
используется полный раздел общения.
|
||||
|
||||
## Поведение
|
||||
|
||||
- Поиск по логину и отображаемому имени начинается с двух символов, возвращает до 30 человек.
|
||||
|
||||
@@ -387,6 +387,9 @@
|
||||
"no_results": "No playlists found"
|
||||
},
|
||||
"social": {
|
||||
"mini_chat": "Friends and chat",
|
||||
"open_full": "Open full view",
|
||||
"minimize_chat": "Minimize chat",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"together_offline": "Listen together when your friend is online",
|
||||
|
||||
@@ -387,6 +387,9 @@
|
||||
"no_results": "Плейлисты не найдены"
|
||||
},
|
||||
"social": {
|
||||
"mini_chat": "Друзья и чат",
|
||||
"open_full": "Открыть раздел",
|
||||
"minimize_chat": "Свернуть чат",
|
||||
"online": "В сети",
|
||||
"offline": "Не в сети",
|
||||
"together_offline": "Слушать вместе можно, когда друг в сети",
|
||||
|
||||
Reference in new issue
Block a user