feat(web): add desktop mini chat and move social controls to profile
ci / ts / ts (packages + web) (push) Successful in 4m55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m37s

This commit is contained in:
mrleo1nid committed 2026-10-23 18:37:32 +03:00
1 parent f087ec9f88
commit 6450027b88
10 files changed
+259 -44

No files matched your search

+58
View File
@@ -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('Продолжить в полном чате');
});
}
+10 -5
View File
@@ -134,8 +134,9 @@ describe('social', () => {
it('saves privacy and independent request admission preferences', async () => { it('saves privacy and independent request admission preferences', async () => {
const alice = makeUser('alice', 'user'); const alice = makeUser('alice', 'user');
const { api, user } = await renderApp([alice], '/friends', { signedInAs: alice }); const { api, user } = await renderApp([alice], '/', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Моя музыка скрыта' })); await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
await user.click(await screen.findByRole('menuitem', { name: 'Моя музыка скрыта' }));
const privacy = await screen.findByRole('group', { name: 'Кто видит мою музыку' }); const privacy = await screen.findByRole('group', { name: 'Кто видит мою музыку' });
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true'); expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' })); 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 () => { it('shows persistent notifications and acknowledges only the chosen event', async () => {
const alice = makeUser('alice', 'user'); const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice }); const { api, user, queryClient, router } = await renderApp([alice, bob], '/', { signedInAs: alice });
await screen.findByRole('heading', { name: 'Друзья и общение' }); 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 }); 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 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 screen.findByText('Хочет добавить вас в друзья');
await user.click(screen.getByRole('button', { name: 'Прочитано' })); await user.click(screen.getByRole('button', { name: 'Прочитано' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument());
expect(api.notifications[0]?.read).toBe(true); 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 }); 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 () => { it('searches, requests, cancels, accepts and exchanges messages and music', async () => {
+1 -1
View File
@@ -149,7 +149,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage }); 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 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 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 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 }); 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>;
}
+58 -29
View File
@@ -16,7 +16,7 @@ import { Presence } from './Presence';
const scopes = new WeakMap<object, number>(); const scopes = new WeakMap<object, number>();
let nextScope = 0; let nextScope = 0;
function useSocial() { export function useSocial() {
const session = useSession(); const session = useSession();
const user = useUser(); const user = useUser();
const server = useDesktop()?.server ?? ''; const server = useDesktop()?.server ?? '';
@@ -36,11 +36,15 @@ export function FriendRecommendations() {
} }
type ShareValue = { attachment: MusicAttachment; url: string }; 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'); }); const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
export const useMusicShare = () => useContext(ShareContext); export const useMusicShare = () => useContext(ShareContext);
export function SocialProvider({ children }: { children: ReactNode }) { export function SocialProvider({ children }: { children: ReactNode }) {
const [share, setShare] = useState<ShareValue | null>(null); 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 }) { function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -75,10 +79,55 @@ function ShareDialog({ value, close }: { value: ShareValue; close: () => void })
</Dialog>; </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' }; 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 { t, i18n } = useTranslation();
const { api, inbox, key, user } = useSocial(); const { api, inbox, key, user } = useSocial();
const cache = useQueryClient(); const cache = useQueryClient();
@@ -87,7 +136,6 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
const [tab, setTab] = useState<SocialTab>('messages'); const [tab, setTab] = useState<SocialTab>('messages');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [settings, setSettings] = useState(false);
const [confirm, setConfirm] = useState<ConfirmAction | null>(null); const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false); const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false);
const searchRef = useRef<HTMLInputElement>(null); 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]); 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 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 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 people = inbox.data?.people ?? [];
const friends = people.filter(p => p.relationship === 'friend'); 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)); 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; const initialPeer = conversations[0]?.id;
useEffect(() => { useEffect(() => {
// Pin the initial desktop selection: a newer message in another dialog must // 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 }); if (op === 'block' || op === 'remove' && person.relationship === 'friend') setConfirm({ person, action: op });
else action.mutate({ action: op, target_id: person.id }); 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'); 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 : ''}`}> return <div className={`${s.page} ${tab === 'messages' && peer && selected ? s.conversationOpen : ''}`}>
<header className={s.pageHeader}> <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> <h1>{t('social.title')}</h1>
<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>
</header> </header>
<ListeningPanel showPreferences={false} /> <ListeningPanel showPreferences={false} />
<section className={s.hub}> <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> <Button className={s.addFriend} variant="ghost" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button>
</nav> </nav>
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError> <FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
{tab === 'notifications' ? <div className={s.events}> <div className={tab === 'messages' ? s.workspace : s.directory}>
<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}>
<aside ref={listRef} className={s.peopleList} aria-label={t(tab === 'messages' ? 'social.messages' : 'social.people')}> <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> <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>} {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>} {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)} />)} </button> : <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={op => act(p, op)} />)}
</aside> </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>)} {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>} </div>
</section> </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>} {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>; </div>;
} }
+41 -8
View File
@@ -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; } .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; } .page h1 { font-size: 24px; letter-spacing: -.035em; margin: 0; }
.pageHeader p { margin: 8px 0 0; }
.eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: 10px; font-weight: 600; color: var(--text3); }
.muted { color: var(--text2); font-size: 13px; line-height: 1.6; } .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); } .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 { 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; } .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; } .musicUnavailable { opacity: .7; }
.musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; } .musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; }
.musicMessages .musicCard { width: 100%; } .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; } .attachmentCaption { font-size: 12px; color: var(--text2); white-space: pre-wrap; }
.expandText { display: block; color: var(--accent2); font-size: 12px; padding: 6px 0; } .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; } } @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 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 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); } } @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: 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; } .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: 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) { @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; } .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; } .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; } .conversationOpen > .pageHeader, .conversationOpen > .stack, .conversationOpen .hub > .tabs, .conversationOpen .workspace > .peopleList { display: none; }
+6 -1
View File
@@ -38,7 +38,8 @@ import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox'; import { SearchBox } from './SearchBox';
import s from './AppShell.module.css'; import s from './AppShell.module.css';
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures'; 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'; import { ListeningBanner } from '../features/social/Listening';
type NavPath = type NavPath =
@@ -164,6 +165,7 @@ function Shell() {
const { t } = useTranslation(); const { t } = useTranslation();
const user = useUser(); const user = useUser();
const socialUnread = useSocialUnread(); const socialUnread = useSocialUnread();
const socialProfile = useSocialProfileMenu();
const session = useSession(); const session = useSession();
const desktop = useDesktop(); const desktop = useDesktop();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -270,6 +272,7 @@ function Shell() {
</div> </div>
} }
items={[ items={[
...socialProfile.items,
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) }, { label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
...(!desktop ...(!desktop
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }] ? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
@@ -301,7 +304,9 @@ function Shell() {
</Dialog> </Dialog>
)} )}
{socialProfile.dialogs}
<ShellMain /> <ShellMain />
<MiniSocial />
<PlayerBar variant={island ? 'island' : 'bar'} /> <PlayerBar variant={island ? 'island' : 'bar'} />
</div> </div>
+6
View File
@@ -3,6 +3,12 @@
Социальные функции работают между пользователями одного сервера. В web и desktop Социальные функции работают между пользователями одного сервера. В web и desktop
открывается раздел «Друзья и общение», на Android — вкладка «Друзья». открывается раздел «Друзья и общение», на Android — вкладка «Друзья».
На ПК справа над плеером доступна мини-панель друзей и чата шириной до 600 px.
Она позволяет переписываться без ухода с текущей страницы, сворачивается с сохранением
черновика и открывает выбранную переписку в полном разделе. На экранах уже 1024 px
используется полный раздел общения.
## Поведение ## Поведение
- Поиск по логину и отображаемому имени начинается с двух символов, возвращает до 30 человек. - Поиск по логину и отображаемому имени начинается с двух символов, возвращает до 30 человек.
+3
View File
@@ -387,6 +387,9 @@
"no_results": "No playlists found" "no_results": "No playlists found"
}, },
"social": { "social": {
"mini_chat": "Friends and chat",
"open_full": "Open full view",
"minimize_chat": "Minimize chat",
"online": "Online", "online": "Online",
"offline": "Offline", "offline": "Offline",
"together_offline": "Listen together when your friend is online", "together_offline": "Listen together when your friend is online",
+3
View File
@@ -387,6 +387,9 @@
"no_results": "Плейлисты не найдены" "no_results": "Плейлисты не найдены"
}, },
"social": { "social": {
"mini_chat": "Друзья и чат",
"open_full": "Открыть раздел",
"minimize_chat": "Свернуть чат",
"online": "В сети", "online": "В сети",
"offline": "Не в сети", "offline": "Не в сети",
"together_offline": "Слушать вместе можно, когда друг в сети", "together_offline": "Слушать вместе можно, когда друг в сети",