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
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:
1 parent
f1528de948
commit
0a05bfe2cd
43 files changed
+2828
-389
No files matched your search
@@ -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);
|
||||
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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: 'Поделиться плейлистом' }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%; } }
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
Reference in new issue
Block a user