feat(social): add friends, private chats, inbox and music sharing
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 2m29s
ci / ts / ts (packages + web) (push) Successful in 2m55s

This commit is contained in:
mrleo1nid committed 2026-10-23 03:19:29 +03:00
1 parent f1528de948
commit 0a05bfe2cd
43 files changed
+2828 -389

No files matched your search

+6 -2
View File
@@ -32,7 +32,9 @@ for (const width of [320, 1280]) {
await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
const shared = new URL(sharedUrl);
expect(shared.origin).toBe(new URL(page.url()).origin);
@@ -47,7 +49,9 @@ for (const width of [320, 1280]) {
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
});
+48
View File
@@ -0,0 +1,48 @@
import { expect, test } from '@playwright/test';
for (const width of [320, 800, 1280]) {
test(`friends, messages and sharing at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
if (width === 800) await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { sessionStorage.setItem('shared-url', data.url ?? ''); } }));
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();
const openFriends = async () => {
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
};
await openFriends();
if (width === 800) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
await page.getByRole('button', { name: 'Написать', exact: true }).click();
if (width === 800) expect((await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!.width).toBeGreaterThan(180);
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill('Привет! Нашёл музыку для нашей поездки 🎵');
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(page.getByText('Привет! Нашёл музыку для нашей поездки 🎵', { exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeEmpty();
await page.screenshot({ path: info.outputPath('conversation.png'), fullPage: true });
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('listitem').first().click();
await page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('button', { name: 'Поделиться треком', exact: true }).click();
await expect(page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true })).toBeVisible();
if (width === 800) {
await page.getByRole('dialog').getByRole('button', { name: 'Отправить в приложение', exact: true }).click();
await expect.poll(() => page.evaluate(() => sessionStorage.getItem('shared-url'))).toContain('/track/');
}
await page.getByRole('dialog').getByRole('button', { name: 'moderator · Отправить', exact: true }).click();
await expect(page.getByRole('button', { name: 'moderator · Отправлено', exact: true })).toBeDisabled();
await page.screenshot({ path: info.outputPath('share-music.png') });
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
await openFriends();
await page.getByRole('button', { name: 'Написать', exact: true }).click();
await expect(page.getByRole('link', { name: 'Открыть трек ↗', exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
await page.getByRole('link', { name: 'Открыть трек ↗', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Группа крови', exact: true })).toBeVisible();
});
}
+49 -3
View File
@@ -14,6 +14,49 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup);
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: 'Друзья и общение' });
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 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);
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 () => {
const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user', { display_name: 'Борис' });
const { api, router, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
await user.type(await screen.findByLabelText('Найти по имени или логину'), 'bob');
await user.click(await screen.findByRole('button', { name: 'В друзья' }));
await screen.findByRole('button', { name: 'Отменить заявку' });
await user.click(screen.getByRole('button', { name: 'Отменить заявку' }));
await screen.findByRole('button', { name: 'В друзья' });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Принять' }));
await user.click(await screen.findByRole('button', { name: 'Написать' }));
await user.type(await screen.findByLabelText('Сообщение', { exact: true }), 'Привет, послушай это');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
expect(await screen.findByText('Привет, послушай это')).toBeInTheDocument();
expect(api.messages).toHaveLength(1);
await router.navigate({ to: '/track/$id', params: { id: albumTracks[0]!.id } });
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(player).getByRole('button', { name: 'Поделиться треком' }));
await user.click(await screen.findByRole('button', { name: 'Борис · Отправить' }));
await screen.findByRole('button', { name: 'Борис · Отправлено' });
expect(api.messages.at(-1)).toMatchObject({ attachment_kind: 'track', attachment_id: albumTracks[0]!.id, recipient: bob.id });
expect(screen.getByRole('button', { name: 'Борис · Отправлено' })).toBeDisabled();
});
});
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
const api = fakeApi(users);
const i18n = await createI18n('ru');
@@ -27,7 +70,7 @@ async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: Fak
<App session={session} router={router} queryClient={queryClient} />
</I18nextProvider>,
);
return { api, router, session, i18n, user: userEvent.setup() };
return { api, router, session, i18n, queryClient, user: userEvent.setup() };
}
describe('sign-in', () => {
@@ -294,7 +337,8 @@ describe('player', () => {
await user.click(screen.getByRole('button', { name: 'Слушать' }));
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
await screen.findByRole('button', { name: 'Ссылка скопирована' });
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
});
@@ -517,7 +561,9 @@ describe('playlist sharing', () => {
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await screen.findByRole('heading', { name: 'Общий микс' });
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
expect(await screen.findByRole('status')).toHaveTextContent('Ссылка на плейлист скопирована');
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
await screen.findByRole('button', { name: 'Ссылка скопирована' });
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Закрыть' }));
const shareId = api.playlists.get(playlist.id)!.share_id!;
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
+3
View File
@@ -36,6 +36,7 @@ import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError';
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
import { SocialPage } from '../features/social/Social';
export interface RouterContext {
session: AuthSession;
@@ -147,6 +148,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
};
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
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 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 });
@@ -293,6 +295,7 @@ const routeTree = rootRoute.addChildren([
setupPasswordRoute,
appRoute.addChildren([
homeRoute,
socialRoute,
trackRoute,
playlistsRoute,
downloadsRoute,
+165
View File
@@ -0,0 +1,165 @@
import { attachmentPath, socialApi, uuidv7, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useDesktop } from '../../desktop';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import s from './social.module.css';
const scopes = new WeakMap<object, number>();
let nextScope = 0;
function useSocial() {
const session = useSession();
const user = useUser();
const server = useDesktop()?.server ?? '';
const api = useMemo(() => socialApi(session), [session]);
if (!scopes.has(session)) scopes.set(session, ++nextScope);
const key = ['social', server, scopes.get(session), user.id] as const;
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, staleTime: 5000, refetchInterval: 15_000, refetchOnWindowFocus: true });
return { api, key, inbox, user };
}
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
type ShareValue = { attachment: MusicAttachment; url: string };
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>;
}
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
const { t } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [done, setDone] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const ids = useRef(new Map<string, string>());
const action = useMutation({ mutationFn: async (peer: string) => {
if (peer === 'copy') { await navigator.clipboard.writeText(value.url); setCopied(true); return; }
if (peer === 'external') {
try { await navigator.share({ title: value.attachment.title, url: value.url }); }
catch (error) { if (!(error instanceof DOMException && error.name === 'AbortError')) throw error; }
return;
}
let id = ids.current.get(peer); if (!id) { id = uuidv7(); ids.current.set(peer, id); }
await api.send(peer, { id, text: value.attachment.title, attachment_kind: value.attachment.kind, attachment_id: value.attachment.id });
setDone(old => [...old, peer]);
await cache.invalidateQueries({ queryKey: key });
} });
return <Dialog title={t('player.share')} onClose={close}>
<div className={s.stack}><strong>{value.attachment.title}</strong>
<Button busy={action.isPending && action.variables === 'copy'} onClick={() => action.mutate('copy')}>{t(copied ? 'social.copied' : 'social.copy')}</Button>
{!!navigator.share && <Button busy={action.isPending && action.variables === 'external'} onClick={() => action.mutate('external')}>{t('social.external')}</Button>}
<h3>{t('social.send_friend')}</h3>
{inbox.isPending && <p>{t('social.loading')}</p>}
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <p>{t('social.no_friends_share')}</p>}
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} disabled={done.includes(p.id) || action.isPending} onClick={() => action.mutate(p.id)}>{p.display_name} · {t(done.includes(p.id) ? 'social.sent' : 'social.send')}</Button>)}
<FormError>{action.error || inbox.error ? errorText(action.error ?? inbox.error) : null}</FormError>
</div>
</Dialog>;
}
export function SocialPage({ peer = '' }: { peer?: string }) {
const { t } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const navigate = useNavigate();
const errorText = useErrorText();
const [tab, setTab] = useState<'people' | 'notifications'>('people');
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
const open = (id: string) => { setTab('people'); setSearch(''); setQ(''); void navigate({ to: '/friends', search: { peer: id } }); };
return <div className={s.page}>
<header><h1>{t('social.title')}</h1><p className={s.muted}>{t('social.subtitle')}</p></header>
<nav className={s.tabs} aria-label={t('social.title')}>
<Button variant={tab === 'people' ? 'primary' : 'ghost'} onClick={() => setTab('people')}>{t('social.people')}</Button>
<Button variant={tab === 'notifications' ? 'primary' : 'ghost'} onClick={() => setTab('notifications')}>{t('social.notifications')} {inbox.data?.unread || ''}</Button>
</nav>
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
{inbox.isPending && <p role="status">{t('social.loading')}</p>}
{tab === 'notifications' ? <div className={s.stack}>
{inbox.data?.notifications.length === 0 && <p className={s.empty}>{t('social.no_notifications')}</p>}
{inbox.data?.notifications.map(n => <article key={n.id} className={`${s.person} ${!n.read ? s.unread : ''}`}>
<div><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString()}</time></div>
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
</article>)}
</div> : <div className={s.layout}>
<aside className={s.stack}>
<Field label={t('social.search')} value={search} onChange={setSearch} type="search" maxLength={80} />
{q.length >= 2 && results.isFetching && <p role="status">{t('social.loading')}</p>}
{!people.length && inbox.data && !results.isFetching && <p className={s.empty}>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</p>}
{people.map(p => <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={a => action.mutate({ action: a, target_id: p.id })} />)}
</aside>
{selected ? <Chat key={selected.id} person={selected} /> : <div className={s.empty}>{t('social.choose_chat')}</div>}
</div>}
</div>;
}
function Person({ person: p, busy, open, act, selected }: { person: SocialPerson; busy: boolean; selected: boolean; open: () => void; act: (a: SocialAction['action']) => void }) {
const { t } = useTranslation();
return <article className={`${s.person} ${selected ? s.selected : ''}`}>
<div className={s.identity}><span className={s.avatar} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><div><strong>{p.display_name}</strong><div className={s.muted}>@{p.username} · {t(`social.rel_${p.relationship}`)}</div></div></div>
<div className={s.actions}>
{p.relationship === 'friend' && <Button onClick={open}>{t('social.chat')}{p.unread ? ` · ${p.unread}` : ''}</Button>}
{p.relationship === 'none' && <Button disabled={busy} onClick={() => act('request')}>{t('social.add')}</Button>}
{p.relationship === 'incoming' && <Button disabled={busy} variant="primary" onClick={() => act('accept')}>{t('social.accept')}</Button>}
{['incoming', 'outgoing', 'friend'].includes(p.relationship) && <Button disabled={busy} variant="ghost" onClick={() => act('remove')}>{t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')}</Button>}
<Button disabled={busy} variant="ghost" onClick={() => act(p.relationship === 'blocked' ? 'unblock' : 'block')}>{t(p.relationship === 'blocked' ? 'social.unblock' : 'social.block')}</Button>
</div>
</article>;
}
function Chat({ person }: { person: SocialPerson }) {
const { t } = useTranslation();
const { api, key, user } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [text, setText] = useState('');
const pending = useRef<{ id: string; text: string } | null>(null);
const messages = useInfiniteQuery({ queryKey: [...key, 'chat', person.id], initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) => api.messages(person.id, pageParam), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined, refetchInterval: 8_000 });
const list = [...(messages.data?.pages ?? [])].reverse().flat();
const last = list.at(-1)?.seq;
const unread = list.some(m => m.sender_id !== user.id && !m.read);
const [visible, setVisible] = useState(document.visibilityState === 'visible');
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
useEffect(() => {
if (!last || !unread || !visible) return;
let cancelled = false;
void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => { if (!cancelled) void cache.invalidateQueries({ queryKey: key }); }).catch(() => undefined);
return () => { cancelled = true; };
}, [last, unread, visible, api, person.id, cache, user.id, messages.dataUpdatedAt]);
const end = useRef<HTMLDivElement>(null);
useEffect(() => { end.current?.scrollIntoView?.({ block: 'nearest' }); }, [last]);
const send = useMutation({ mutationFn: async () => {
if (!pending.current || pending.current.text !== text) pending.current = { id: uuidv7(), text };
return api.send(person.id, { ...pending.current, attachment_kind: 'none' });
}, onSuccess: async () => { setText(''); pending.current = null; await cache.invalidateQueries({ queryKey: key }); } });
return <section className={s.chat} aria-label={t('social.chat')}>
<header><strong>{person.display_name}</strong><p className={s.muted}>{t('social.chat_hint')}</p></header>
<FormError>{messages.error || send.error ? errorText(messages.error ?? send.error) : null}</FormError>
<div className={s.messages}>
{messages.hasNextPage && <Button busy={messages.isFetchingNextPage} onClick={() => void messages.fetchNextPage()}>{t('social.older')}</Button>}
{messages.isPending && <p>{t('social.loading')}</p>}
{!list.length && messages.isSuccess && <p className={s.empty}>{t('social.first_message')}</p>}
{list.map(m => <article key={m.id} className={`${s.bubble} ${m.sender_id === user.id ? s.mine : ''}`}>
<div>{m.text}</div>
{attachmentPath(m) && <Link className={s.music} to={m.attachment_kind === 'track' ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id: m.attachment_id!, shareId: m.attachment_id! }}>{t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} ↗</Link>}
<small className={s.muted}>{new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{m.sender_id === user.id ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</small>
</article>)}<div ref={end} />
</div>
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
<textarea aria-label={t('social.message')} placeholder={t('social.message')} maxLength={4000} rows={2} value={text} disabled={send.isPending} onChange={e => setText(e.target.value)} />
<Button type="submit" variant="primary" busy={send.isPending} disabled={!text.trim()}>{t('social.send')}</Button>
</form>
</section>;
}
@@ -0,0 +1,22 @@
.page { max-width: 1200px; padding: 32px; margin: auto; display: grid; gap: 24px; }
.page h1 { margin: 0; font-size: 28px; }
.muted { color: var(--text3); font-size: 13px; }
.tabs, .actions { display: flex; flex-wrap: wrap; gap: 8px; }
.layout { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(280px, 1fr); gap: 24px; align-items: start; }
.stack { display: grid; gap: 12px; }
.person { padding: 16px; border: 1px solid var(--line); background: var(--surface); border-radius: 16px; display: grid; gap: 16px; overflow-wrap: anywhere; }
.identity { display: flex; gap: 12px; align-items: center; }
.avatar { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; color: var(--accent2); background: var(--accent-soft); }
.selected, .unread { border-color: var(--accent); }
.empty { color: var(--text3); padding: 36px 16px; text-align: center; line-height: 1.7; }
.chat { border: 1px solid var(--line); border-radius: 20px; background: var(--surface); overflow: hidden; min-width: 0; }
.chat header { padding: 20px; border-bottom: 1px solid var(--line); }
.chat header p { margin-bottom: 0; }
.messages { display: flex; flex-direction: column; gap: 12px; padding: 20px; max-height: 55vh; min-height: 220px; overflow: auto; }
.bubble { display: grid; gap: 8px; padding: 12px 16px; border-radius: 16px 16px 16px 4px; background: var(--surface2); max-width: 85%; align-self: start; white-space: pre-wrap; overflow-wrap: anywhere; }
.mine { align-self: end; background: var(--accent-soft); border-radius: 16px 16px 4px 16px; }
.music { color: var(--accent2); padding: 10px; border: 1px solid var(--line2); border-radius: 10px; text-decoration: none; }
.composer { display: flex; gap: 12px; padding: 16px; border-top: 1px solid var(--line); align-items: end; }
.composer textarea { resize: vertical; min-width: 0; flex: 1; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--text); padding: 12px; font: inherit; }
@media(max-width: 760px) { .layout { grid-template-columns: 1fr; } .page { padding: 20px 16px; } .composer { flex-wrap: wrap; } }
@media(min-width: 761px) and (max-width: 1023px) { .composer { flex-wrap: wrap; justify-content: end; } .composer textarea { flex-basis: 100%; } }
+6 -7
View File
@@ -9,7 +9,9 @@ import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist
import type { components } from '@pipu/api-client';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { sharePlaylist } from '../shared/lib/share';
import { playlistShareUrl } from '@pipu/core';
import { desktopHandle } from '../desktop';
import { useMusicShare } from '../features/social/Social';
import { formatDuration } from '../shared/lib/format';
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } from '../shared/ui/icons';
@@ -56,6 +58,7 @@ export function PlaylistPage({ id, shared = false }: { id: string; shared?: bool
}
function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
const openShare = useMusicShare();
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
@@ -65,20 +68,17 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
const [edit, setEdit] = useState(false);
const [remove, setRemove] = useState(false);
const [manage, setManage] = useState(false);
const [copied, setCopied] = useState(false);
const sharing = useMutation({ mutationFn: async () => {
setCopied(false);
let token = shareId ?? playlist.share_id;
if (!readOnly) {
token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id;
cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current);
}
if (!token) throw new Error('Share link unavailable');
const result = await sharePlaylist(token, playlist.name);
if (result === 'copied') setCopied(true);
openShare({ attachment: { kind: 'playlist', id: token, title: playlist.name }, url: playlistShareUrl(desktopHandle()?.server ?? window.location.origin, token) });
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })),
onSuccess: () => { setCopied(false); cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
onSuccess: () => { cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
@@ -117,7 +117,6 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
</>}
</div>
{!readOnly && <p>{t('playlists.share_hint')}</p>}
{copied && <p role="status">{t('playlists.link_copied')}</p>}
<FormError>{sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null}</FormError>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
{!playlist.tracks.length && (
+4
View File
@@ -10,6 +10,10 @@ export function installE2EFetch() {
makeUser('mobile-admin', 'admin', { reviews_uploads: true }),
makeUser('moderator', 'moderator', { reviews_uploads: true }),
]);
// A ready conversation lets browser tests exercise messages and music sharing.
const alice = [...api.users.values()].find(u => u.username === 'alice')!;
const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
const orig = window.fetch.bind(window);
window.fetch = async (input, init) => {
const req = new Request(input, init);
+47 -1
View File
@@ -78,6 +78,21 @@ export function fakeApi(initial: FakeUser[]) {
const reactions = new Map<string, 'like' | 'dislike'>();
const playlistOwners = new Map<string, string>();
const playlists = new Map<string, components['schemas']['Playlist']>();
const friendships = new Map<string, { sender: string; accepted: boolean }>();
const blocks = new Set<string>();
const messages: (components['schemas']['DirectMessage'] & { recipient: string })[] = [];
const notifications: (components['schemas']['SocialNotification'] & { recipient: string })[] = [];
const pair = (a: string, b: string) => [a, b].sort().join(':');
const isBlocked = (a: string, b: string) => blocks.has(`${a}:${b}`) || blocks.has(`${b}:${a}`);
const socialPerson = (me: string, p: FakeUser): components['schemas']['SocialPerson'] => {
const f = friendships.get(pair(me, p.id));
return { id: p.id, username: p.username, display_name: p.display_name ?? p.username,
relationship: blocks.has(`${me}:${p.id}`) ? 'blocked' : f?.accepted ? 'friend' : f ? f.sender === me ? 'outgoing' : 'incoming' : 'none',
unread: messages.filter(m => m.recipient === me && m.sender_id === p.id && !m.read).length };
};
const notify = (recipient: string, actor: FakeUser, kind: components['schemas']['SocialNotification']['kind']) => notifications.unshift({
id: crypto.randomUUID(), recipient, actor_id: actor.id, actor_name: actor.display_name ?? actor.username, kind, read: false, created_at: new Date().toISOString(),
});
let managementSources = [{ id: 'origin', name: 'Текущий репозиторий', url: 'https://git.example.org/pipu/music.git' }];
let managementJob: components['schemas']['ServerJob'] | null = null;
@@ -201,6 +216,37 @@ export function fakeApi(initial: FakeUser[]) {
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
if (path === '/api/v1/me/social') return json({
people: [...users.values()].filter(p => p.id !== user.id && !p.is_blocked && !blocks.has(`${p.id}:${user.id}`)).map(p => socialPerson(user.id, p)).filter(p => p.relationship !== 'none'),
notifications: notifications.filter(n => n.recipient === user.id && !isBlocked(user.id, n.actor_id)),
unread: notifications.filter(n => n.recipient === user.id && !n.read && !isBlocked(user.id, n.actor_id)).length,
});
if (path === '/api/v1/me/social/people') {
const q = url.searchParams.get('q')?.toLowerCase() ?? '';
return json([...users.values()].filter(p => p.id !== user.id && !p.is_blocked && !isBlocked(user.id,p.id) && `${p.username} ${p.display_name ?? ''}`.toLowerCase().includes(q)).map(p => socialPerson(user.id,p)));
}
if (path === '/api/v1/me/social/actions' && body) {
const peer = String(body.target_id); const action = body.action; const p = users.get(peer); const f = friendships.get(pair(user.id,peer));
if (action === 'request' && p && !isBlocked(user.id,peer) && !f) { friendships.set(pair(user.id,peer),{ sender:user.id,accepted:false }); notify(peer,user,'friend_request'); }
if (action === 'accept' && f && f.sender !== user.id && !f.accepted) { f.accepted=true; notify(peer,user,'friend_accepted'); }
if (action === 'remove' || action === 'block') friendships.delete(pair(user.id,peer));
if (action === 'block') blocks.add(`${user.id}:${peer}`);
if (action === 'unblock') blocks.delete(`${user.id}:${peer}`);
if (action === 'read_notification') notifications.filter(n => n.recipient === user.id && n.id === peer).forEach(n => { n.read=true; });
if (action === 'read_chat') { messages.filter(m => m.recipient === user.id && m.sender_id === peer && m.seq <= Number(body.up_to_seq)).forEach(m => { m.read=true; }); notifications.filter(n => n.recipient === user.id && n.actor_id === peer).forEach(n => { n.read=true; }); }
return new Response(null,{status:204});
}
const chatPath = /^\/api\/v1\/me\/social\/chats\/([^/]+)$/.exec(path);
if (chatPath) {
const peer=chatPath[1]!;if (!friendships.get(pair(user.id,peer))?.accepted || isBlocked(user.id,peer)) return problem(403,'social.unavailable');
if (req.method === 'GET') return json(messages.filter(m => pair(m.sender_id,m.recipient)===pair(user.id,peer) && (!url.searchParams.has('before') || m.seq<Number(url.searchParams.get('before')))).slice(-50));
if (req.method === 'POST' && body) {
const old=messages.find(m => m.id===body.id);if(old)return json(old);
const message={...body,recipient:peer,sender_id:user.id,seq:messages.length+1,created_at:new Date().toISOString(),read:false} as typeof messages[number];
messages.push(message);notify(peer,user,body.attachment_kind==='none'?'message':'music');return json(message);
}
}
const sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path);
if (sharedPath && req.method === 'GET') {
@@ -396,5 +442,5 @@ export function fakeApi(initial: FakeUser[]) {
return problem(404, 'common.not_found');
};
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, signOut: () => (current = null) };
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, friendships, notifications, messages, signOut: () => (current = null) };
}
+6 -2
View File
@@ -38,8 +38,10 @@ 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';
type NavPath =
| '/friends'
| '/'
| '/admin/users'
| '/admin/audit'
@@ -150,16 +152,17 @@ function NavSection({
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
export function AppShell() {
return useAuthState().status === 'signed_in' ? (
<PlayerProvider>
<SocialProvider><PlayerProvider>
<DesktopUpdateDialog />
<Shell />
</PlayerProvider>
</PlayerProvider></SocialProvider>
) : null;
}
function Shell() {
const { t } = useTranslation();
const user = useUser();
const socialUnread = useSocialUnread();
const session = useSession();
const desktop = useDesktop();
const navigate = useNavigate();
@@ -177,6 +180,7 @@ function Shell() {
const navigation = (mobile = false) => (
<>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavLink to="/friends" label={t('social.title')} icon={groupIcon} activeIcon={groupFillIcon} badge={socialUnread} />
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
{desktop && <NavLink to="/collection/downloads" label={t('desktop.offline')} icon={downloadIcon} activeIcon={downloadIcon} />}
+9 -29
View File
@@ -7,7 +7,9 @@ import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { shareTrack } from '../shared/lib/share';
import { trackShareUrl } from '@pipu/core';
import { desktopHandle } from '../desktop';
import { useMusicShare } from '../features/social/Social';
import {
albumIcon,
closeIcon,
@@ -36,20 +38,11 @@ import s from './PlayerBar.module.css';
export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const { t } = useTranslation();
const player = usePlayer();
const openShare = useMusicShare();
const st = usePlayerState();
const narrow = useNarrow();
const [sheetOpen, setSheetOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
const [sharing, setSharing] = useState(false);
useEffect(() => {
setShareStatus(null);
}, [st.track?.id]);
useEffect(() => {
if (!shareStatus) return;
const timer = setTimeout(() => setShareStatus(null), 4000);
return () => clearTimeout(timer);
}, [shareStatus]);
const queueButton = useRef<HTMLButtonElement>(null);
const queueId = useId();
const closeQueue = useCallback(() => {
@@ -72,17 +65,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
}
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = async () => {
setSharing(true);
setShareStatus(null);
try {
const result = await shareTrack(track.id, track.title);
if (result === 'copied') setShareStatus('copied');
} catch {
setShareStatus('error');
} finally {
setSharing(false);
}
const share = () => {
openShare({ attachment: { kind: 'track', id: track.id, title: track.title }, url: trackShareUrl(desktopHandle()?.server ?? window.location.origin, track.id) });
};
const repeatLabel = t(`player.repeat_${st.repeat}`);
const ctx = st.context;
@@ -198,7 +182,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<div className={s.right}>
<AddToPlaylist trackIds={[track.id]} compact className={cx(s.icon, s.playlistButton)} />
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button
@@ -255,11 +239,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
variant={variant}
onClose={() => setSheetOpen(false)}
share={share}
sharing={sharing}
/>
)}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
</footer>
);
}
@@ -281,12 +263,10 @@ function NowPlayingSheet({
variant,
onClose,
share,
sharing,
}: {
variant: 'bar' | 'island';
onClose: () => void;
share: () => Promise<void>;
sharing: boolean;
share: () => void;
}) {
const { t } = useTranslation();
const player = usePlayer();
@@ -370,7 +350,7 @@ function NowPlayingSheet({
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
</button>
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
<button type="button" className={s.icon} aria-label={t('player.share')} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>