Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb22f15912 | ||
|
|
6450027b88 |
No files matched your search
@@ -0,0 +1,72 @@
|
|||||||
|
import axe from 'axe-core';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
for (const { width, height, light } of [{ width: 1280, height: 900, light: false }, { width: 1920, height: 900, light: false }, { width: 1024, height: 600, light: false }, { width: 1280, height: 720, light: true }]) {
|
||||||
|
test(`mini chat keeps the page and draft at ${width}x${height} ${light ? 'light' : 'dark'}`, async ({ page }, info) => {
|
||||||
|
await page.setViewportSize({ width, height });
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
if (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
|
||||||
|
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 expect(panel.getByRole('textbox', { name: 'Найти диалог', exact: true })).toHaveCSS('outline-style', 'none');
|
||||||
|
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 panel.evaluate(async element => {
|
||||||
|
await Promise.all(element.getAnimations({ subtree: true })
|
||||||
|
.filter(animation => animation.effect?.getTiming().iterations !== Infinity)
|
||||||
|
.map(animation => animation.finished.catch(() => undefined)));
|
||||||
|
});
|
||||||
|
await page.addScriptTag({ content: axe.source });
|
||||||
|
const violations = await page.evaluate(async (selector) => {
|
||||||
|
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
|
||||||
|
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => ({ target: node.target, summary: node.failureSummary })) }));
|
||||||
|
}, '[id="mini-social-panel"]');
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
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 });
|
||||||
|
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('Продолжить в полном чате');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import axe from 'axe-core';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
for (const width of [390, 1280]) {
|
||||||
|
test(`notification history scrolls inside the dialog at ${width}px`, async ({ page }, info) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('pipu.locale', 'ru');
|
||||||
|
localStorage.setItem('pipu.e2e.notifications', '1');
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Уведомления 4', exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Уведомления', exact: true });
|
||||||
|
const list = dialog.getByRole('region', { name: 'Уведомления', exact: true });
|
||||||
|
await expect(list.getByRole('article')).toHaveCount(28);
|
||||||
|
const box = (await dialog.boundingBox())!;
|
||||||
|
expect(box.height).toBeLessThanOrEqual(680);
|
||||||
|
expect(box.width).toBeLessThanOrEqual(Math.min(600, width - (width < 768 ? 24 : 32)));
|
||||||
|
const close = dialog.getByRole('button', { name: 'Закрыть', exact: true });
|
||||||
|
const closeBefore = (await close.boundingBox())!;
|
||||||
|
await list.evaluate(element => { element.scrollTop = element.scrollHeight; });
|
||||||
|
await expect(close).toBeVisible();
|
||||||
|
expect((await close.boundingBox())!.y).toBe(closeBefore.y);
|
||||||
|
await list.evaluate(element => { element.scrollTop = 0; });
|
||||||
|
await page.addScriptTag({ content: axe.source });
|
||||||
|
const violations = await page.evaluate(async (selector) => {
|
||||||
|
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
|
||||||
|
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
|
||||||
|
}, '[role="dialog"]');
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
await page.screenshot({ path: info.outputPath('notifications.png'), fullPage: true });
|
||||||
|
await dialog.getByRole('button', { name: 'Непрочитанные 4', exact: true }).click();
|
||||||
|
await expect(list.getByRole('article')).toHaveCount(4);
|
||||||
|
await list.getByRole('button', { name: 'Прочитано', exact: true }).first().click();
|
||||||
|
await expect(list.getByRole('article')).toHaveCount(3);
|
||||||
|
await dialog.getByRole('button', { name: 'Все', exact: true }).click();
|
||||||
|
await expect(list.getByRole('article')).toHaveCount(28);
|
||||||
|
await close.click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import axe from 'axe-core';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
for (const { width, light } of [{ width: 390, light: false }, { width: 1280, light: false }, { width: 1280, light: true }]) {
|
||||||
|
test(`social profile settings at ${width}px ${light ? 'light' : 'dark'}`, 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 (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
|
||||||
|
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Уведомления', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Моя музыка скрыта', exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Приватность и общение', exact: true });
|
||||||
|
const visibility = dialog.getByRole('group', { name: 'Кто видит мою музыку', exact: true });
|
||||||
|
const friends = visibility.getByRole('button', { name: 'Друзьям', exact: true });
|
||||||
|
await friends.click();
|
||||||
|
await expect(friends).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(friends).toBeEnabled();
|
||||||
|
await expect(friends).toBeFocused();
|
||||||
|
await dialog.getByText('Слушать вместе', { exact: true }).click();
|
||||||
|
await expect(dialog.getByRole('group', { name: 'Принимать запросы и приглашения от', exact: true })).toBeVisible();
|
||||||
|
expect((await dialog.boundingBox())!.width).toBeLessThanOrEqual(width - 24);
|
||||||
|
await expect.poll(() => dialog.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||||
|
await page.addScriptTag({ content: axe.source });
|
||||||
|
const violations = await page.evaluate(async () => {
|
||||||
|
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[role="dialog"]')!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
|
||||||
|
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
|
||||||
|
});
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
await page.screenshot({ path: info.outputPath('privacy.png'), fullPage: true });
|
||||||
|
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||||
|
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
|
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
|
||||||
|
const heading = page.getByRole('heading', { name: 'Друзья и общение', exact: true });
|
||||||
|
await expect(heading).toBeVisible();
|
||||||
|
expect((await heading.locator('..').boundingBox())!.height).toBeLessThanOrEqual(40);
|
||||||
|
await expect(page.getByRole('button', { name: 'Моя музыка скрыта', exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Уведомления', exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -65,9 +65,11 @@ for (const width of [320, 800, 1280]) {
|
|||||||
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
|
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
|
||||||
await page.setViewportSize({ width, height: 560 });
|
await page.setViewportSize({ width, height: 560 });
|
||||||
}
|
}
|
||||||
const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!;
|
await expect.poll(async () => {
|
||||||
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!;
|
const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!;
|
||||||
expect(composerBox.y + composerBox.height).toBeLessThanOrEqual(playerBox.y);
|
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!;
|
||||||
|
return composerBox.y + composerBox.height - playerBox.y;
|
||||||
|
}).toBeLessThanOrEqual(0);
|
||||||
await expect.poll(() => page.evaluate(() => {
|
await expect.poll(() => page.evaluate(() => {
|
||||||
const main = document.querySelector('main')!;
|
const main = document.querySelector('main')!;
|
||||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||||
|
|||||||
@@ -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: 'Открыть: bob' }));
|
||||||
|
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 () => {
|
||||||
|
|||||||
@@ -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,74 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useRouterState, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, EmptyState, FormError, Icon } from '../../shared/ui/ui';
|
||||||
|
import { chatIcon, closeIcon, groupIcon, searchIcon } from '../../shared/ui/icons';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { ChatConversation } from './ChatConversation';
|
||||||
|
import { Presence } from './Presence';
|
||||||
|
import { useSocialDialogOpen } from './Social';
|
||||||
|
import { useSocial } from './queries';
|
||||||
|
import s from './social.module.css';
|
||||||
|
|
||||||
|
/** Lives in the shell so browsing and minimizing never change the selected conversation. */
|
||||||
|
export function MiniSocial() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { api, inbox, key, user } = useSocial();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const pathname = useRouterState({ select: state => state.location.pathname });
|
||||||
|
const dialogOpen = useSocialDialogOpen();
|
||||||
|
const [desktop, setDesktop] = useState(() => window.matchMedia?.('(min-width: 1024px)').matches ?? false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [peer, setPeer] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const trigger = useRef<HTMLButtonElement>(null);
|
||||||
|
const panel = useRef<HTMLElement>(null);
|
||||||
|
const shown = desktop && pathname !== '/friends';
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia?.('(min-width: 1024px)');
|
||||||
|
if (!media) return;
|
||||||
|
const change = () => setDesktop(media.matches);
|
||||||
|
media.addEventListener('change', change);
|
||||||
|
return () => media.removeEventListener('change', change);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && shown) panel.current?.querySelector<HTMLInputElement>('input')?.focus();
|
||||||
|
}, [open, shown]);
|
||||||
|
const friends = (inbox.data?.people ?? []).filter(person => person.relationship === 'friend');
|
||||||
|
const selected = friends.find(person => person.id === peer);
|
||||||
|
const unread = friends.reduce((sum, person) => sum + person.unread, 0);
|
||||||
|
const filtered = [...friends].sort((a, b) => Number(b.online) - Number(a.online) || (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name))
|
||||||
|
.filter(person => `${person.display_name} ${person.username}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
|
||||||
|
const minimize = () => { setOpen(false); trigger.current?.focus(); };
|
||||||
|
if (!shown) return null;
|
||||||
|
return <aside className={`${s.miniDock} ${user.ui_prefs.web_player === 'island' ? s.miniDockIsland : ''}`} aria-label={t('social.mini_chat')}>
|
||||||
|
<section hidden={!open} ref={panel} id="mini-social-panel" className={s.miniPanel} aria-label={t('social.title')}
|
||||||
|
onKeyDown={event => { if (event.key === 'Escape' && !event.defaultPrevented && !dialogOpen) { event.stopPropagation(); minimize(); } }}>
|
||||||
|
<header className={s.miniHeader}>
|
||||||
|
<strong>{t('social.title')}</strong>
|
||||||
|
<Button variant="ghost" onClick={() => { setOpen(false); void navigate({ to: '/friends', search: selected ? { peer: selected.id, tab: 'messages' } : {} }); }}>{t('social.open_full')}</Button>
|
||||||
|
<Button variant="ghost" icon={closeIcon} aria-label={t('social.minimize_chat')} onClick={minimize} />
|
||||||
|
</header>
|
||||||
|
<FormError>{inbox.error ? errorText(inbox.error) : null}</FormError>
|
||||||
|
<div className={s.miniWorkspace}>
|
||||||
|
<div className={s.miniFriends}>
|
||||||
|
<label className={s.listSearch}><Icon src={searchIcon} size={16} /><input aria-label={t('social.find_conversation')} placeholder={t('social.find_conversation')} value={search} onChange={event => setSearch(event.target.value)} /></label>
|
||||||
|
{inbox.isPending && <p className={s.muted}>{t('social.loading')}</p>}
|
||||||
|
{inbox.isSuccess && !filtered.length && <p className={s.muted}>{t(search ? 'social.no_results' : 'social.no_friends_share')}</p>}
|
||||||
|
{filtered.map(person => <button key={person.id} type="button" className={`${s.dialogRow} ${peer === person.id ? s.selected : ''}`} aria-pressed={peer === person.id} onClick={() => setPeer(person.id)}>
|
||||||
|
<span className={`${s.avatar} ${person.online ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
|
||||||
|
<span className={s.dialogBody}><strong>{person.display_name}</strong><Presence online={person.online} /></span>
|
||||||
|
{person.unread > 0 && <span className={s.badge}>{person.unread}</span>}
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
{selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={open && !dialogOpen} />
|
||||||
|
: <div className={s.welcome}><EmptyState icon={groupIcon}>{t('social.choose_chat')}</EmptyState></div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<button ref={trigger} type="button" className={s.miniTrigger} aria-label={t('social.mini_chat')} title={t('social.mini_chat')} aria-expanded={open} aria-controls="mini-social-panel" onClick={() => setOpen(value => !value)}>
|
||||||
|
<Icon src={open ? closeIcon : chatIcon} size={22} />
|
||||||
|
{unread > 0 && <span className={s.badge}>{unread > 99 ? '99+' : unread}</span>}
|
||||||
|
</button>
|
||||||
|
</aside>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { checkIcon, chevronRightIcon, inboxIcon } from '../../shared/ui/icons';
|
||||||
|
import { Dialog, EmptyState, FormError, Icon, Skeleton } from '../../shared/ui/ui';
|
||||||
|
import { useSocial } from './queries';
|
||||||
|
import s from './notifications.module.css';
|
||||||
|
|
||||||
|
export function NotificationsDialog({ onClose, onOpen }: { onClose: () => void; onOpen: (id: string) => void }) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { api, inbox, key } = useSocial();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||||
|
const read = useMutation({ mutationFn: (id: string) => api.action({ action: 'read_notification', target_id: id }), onSuccess: () => cache.invalidateQueries({ queryKey: key }) });
|
||||||
|
const events = inbox.data?.notifications ?? [];
|
||||||
|
const unread = events.filter(event => !event.read).length;
|
||||||
|
const shown = unreadOnly ? events.filter(event => !event.read) : events;
|
||||||
|
return <Dialog title={t('social.notifications')} onClose={onClose} className={s.dialog}>
|
||||||
|
<nav className={s.filters} aria-label={t('social.notifications')}>
|
||||||
|
<button type="button" aria-pressed={!unreadOnly} onClick={() => setUnreadOnly(false)}>{t('social.notifications_all')}</button>
|
||||||
|
<button type="button" aria-pressed={unreadOnly} onClick={() => setUnreadOnly(true)}>{t('social.notifications_unread')}{unread > 0 && <span className={s.count}>{unread}</span>}</button>
|
||||||
|
</nav>
|
||||||
|
<FormError>{read.error || inbox.error ? errorText(read.error ?? inbox.error) : null}</FormError>
|
||||||
|
<div className={s.list} tabIndex={0} role="region" aria-label={t('social.notifications')}>
|
||||||
|
{inbox.isPending && <div className={s.loading} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(value => <Skeleton key={value} style={{ height: 68 }} />)}</div>}
|
||||||
|
{inbox.isSuccess && !shown.length && <div className={s.empty}><EmptyState icon={unreadOnly ? checkIcon : inboxIcon}>{t(unreadOnly ? 'social.notifications_caught_up' : 'social.no_notifications')}</EmptyState></div>}
|
||||||
|
{shown.map(event => {
|
||||||
|
const date = new Date(event.created_at);
|
||||||
|
return <article key={event.id} className={`${s.row} ${!event.read ? s.unread : ''}`}>
|
||||||
|
<button type="button" className={s.open} aria-label={t('social.open_notification', { name: event.actor_name })} onClick={() => onOpen(event.actor_id)}>
|
||||||
|
<span className={s.avatar} aria-hidden>{event.actor_name.slice(0, 1).toUpperCase()}</span>
|
||||||
|
<span className={s.content}><strong>{event.actor_name}</strong><span>{t(`social.kind_${event.kind}`)}</span>
|
||||||
|
<time dateTime={event.created_at} title={date.toLocaleString(i18n.language)}>{date.toLocaleString(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</time>
|
||||||
|
</span>
|
||||||
|
<Icon src={chevronRightIcon} size={18} className={s.arrow} />
|
||||||
|
</button>
|
||||||
|
{!event.read && <button type="button" className={s.read} disabled={read.isPending} aria-label={t('social.mark_read')} title={t('social.mark_read')} onClick={() => read.mutate(event.id)}><span className={s.dot} /><Icon src={checkIcon} size={18} /></button>}
|
||||||
|
</article>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { socialApi, uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
import { uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link, useNavigate } from '@tanstack/react-router';
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSession, useUser } from '../../app/session';
|
|
||||||
import { useDesktop } from '../../desktop';
|
|
||||||
import { useErrorText } from '../../shared/lib/errors';
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
import { Button, Dialog, EmptyState, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
|
import { Button, Dialog, EmptyState, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
|
||||||
import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldIcon, visibilityOffIcon } from '../../shared/ui/icons';
|
import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldIcon, visibilityOffIcon } from '../../shared/ui/icons';
|
||||||
@@ -12,20 +10,8 @@ import s from './social.module.css';
|
|||||||
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||||
import { ChatConversation } from './ChatConversation';
|
import { ChatConversation } from './ChatConversation';
|
||||||
import { Presence } from './Presence';
|
import { Presence } from './Presence';
|
||||||
|
import { useSocial } from './queries';
|
||||||
const scopes = new WeakMap<object, number>();
|
import { NotificationsDialog } from './NotificationsDialog';
|
||||||
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; }
|
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
|
||||||
|
|
||||||
@@ -36,11 +22,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 +65,46 @@ function ShareDialog({ value, close }: { value: ShareValue; close: () => void })
|
|||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SocialTab = 'messages' | 'people' | 'requests' | 'notifications';
|
export function useSocialProfileMenu() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { api, inbox, key } = useSocial();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const { dialog, setDialog } = useContext(AccountDialogContext);
|
||||||
|
const closeDialog = useCallback(() => setDialog(null), [setDialog]);
|
||||||
|
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={closeDialog} className={s.preferencesDialog}>
|
||||||
|
<fieldset className={s.privacy} disabled={!inbox.data} aria-busy={visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-disabled={visibility.isPending} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => { if (!visibility.isPending) 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' && <NotificationsDialog onClose={closeDialog} onOpen={open} />}
|
||||||
|
</>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +113,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 +127,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 +155,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 +167,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 +179,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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.dialog { width: 600px; max-height: min(680px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
|
||||||
|
.dialog > :first-child { flex-shrink: 0; padding: 20px 24px 16px; }
|
||||||
|
.dialog > :nth-child(2) { display: flex; flex-direction: column; gap: 0; min-height: 0; padding: 0; overflow: hidden; }
|
||||||
|
.filters { display: flex; gap: 6px; flex-shrink: 0; padding: 0 24px 16px; border-bottom: 1px solid var(--line); }
|
||||||
|
.filters button { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 12px; border-radius: 9px; color: var(--text2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||||
|
.filters button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent2); }
|
||||||
|
.filters button:hover { background: var(--hover); color: var(--text); }
|
||||||
|
.count { font-size: 11px; font-variant-numeric: tabular-nums; color: var(--accent2); }
|
||||||
|
.list { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line2) transparent; padding: 8px 12px 12px; }
|
||||||
|
.row { position: relative; display: flex; align-items: center; border-radius: 12px; }
|
||||||
|
.row + .row { margin-top: 4px; }
|
||||||
|
.row.unread { background: var(--accent-soft); }
|
||||||
|
.row:hover { background: var(--hover); }
|
||||||
|
.open { display: flex; align-items: center; gap: 14px; flex: 1; min-width: 0; padding: 14px 12px; border-radius: 12px; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
|
||||||
|
.avatar { display: grid; place-items: center; flex-shrink: 0; width: 40px; height: 40px; border: 1px solid var(--line2); border-radius: 50%; background: var(--surface2); color: var(--accent2); font-size: 14px; font-weight: 600; }
|
||||||
|
.content { display: flex; flex-direction: column; flex: 1; min-width: 0; gap: 4px; overflow-wrap: anywhere; }
|
||||||
|
.content strong { font-size: 13px; font-weight: 600; }
|
||||||
|
.content > span { font-size: 13px; color: var(--text2); line-height: 1.45; }
|
||||||
|
.content time { color: var(--text3); font-size: 11px; line-height: 1.4; }
|
||||||
|
.arrow { flex-shrink: 0; color: var(--text3); }
|
||||||
|
.read { position: relative; display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; margin-right: 10px; border-radius: 9px; color: var(--accent2); cursor: pointer; }
|
||||||
|
.read > svg, .read > span:last-child { opacity: 0; }
|
||||||
|
.dot { position: absolute; width: 6px; height: 6px; border-radius: 50%; background: var(--accent2); }
|
||||||
|
.read:hover, .read:focus-visible { background: var(--accent-soft); }
|
||||||
|
.read:hover .dot, .read:focus-visible .dot { opacity: 0; }
|
||||||
|
.read:hover > :last-child, .read:focus-visible > :last-child { opacity: 1; }
|
||||||
|
.read:disabled { opacity: .5; cursor: wait; }
|
||||||
|
.open:focus-visible, .read:focus-visible, .filters button:focus-visible, .list:focus-visible { outline: 2px solid var(--accent2); outline-offset: -2px; }
|
||||||
|
.loading { display: grid; gap: 8px; padding: 12px; }
|
||||||
|
.empty { padding: 36px 20px; }
|
||||||
|
@media(max-width: 767px) {
|
||||||
|
.dialog { max-height: min(680px, calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))); }
|
||||||
|
.dialog > :first-child { padding: 16px; }
|
||||||
|
.filters { padding: 0 16px 12px; }
|
||||||
|
.list { padding: 6px; }
|
||||||
|
.open { gap: 10px; padding: 12px 10px; }
|
||||||
|
.avatar { width: 34px; height: 34px; }
|
||||||
|
.read { width: 36px; min-height: 44px; margin-right: 4px; }
|
||||||
|
}
|
||||||
|
@media(hover: none) { .read > :last-child { opacity: 1; } .read .dot { display: none; } }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { socialApi } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useSession, useUser } from '../../app/session';
|
||||||
|
import { useDesktop } from '../../desktop';
|
||||||
|
|
||||||
|
const scopes = new WeakMap<object, number>();
|
||||||
|
let nextScope = 0;
|
||||||
|
|
||||||
|
export 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 };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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; }
|
||||||
@@ -20,7 +17,7 @@
|
|||||||
.listSearch:focus-within { border-color: var(--accent); }
|
.listSearch:focus-within { border-color: var(--accent); }
|
||||||
.listSearch input { color: var(--text); font: inherit; font-size: 13px; width: 100%; min-width: 0; background: transparent; border: 0; height: 44px; }
|
.listSearch input { color: var(--text); font: inherit; font-size: 13px; width: 100%; min-width: 0; background: transparent; border: 0; height: 44px; }
|
||||||
.listSearch input::placeholder { color: var(--text3); }
|
.listSearch input::placeholder { color: var(--text3); }
|
||||||
.listSearch input:focus-visible { outline-offset: 4px; }
|
.listSearch input:focus-visible { outline: none; }
|
||||||
.dialogRow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 12px; text-align: left; border-radius: 12px; margin-bottom: 4px; transition: background .18s, transform .18s; }
|
.dialogRow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 12px; text-align: left; border-radius: 12px; margin-bottom: 4px; transition: background .18s, transform .18s; }
|
||||||
.dialogRow:hover { background: var(--hover); }
|
.dialogRow:hover { background: var(--hover); }
|
||||||
.dialogRow:active { transform: scale(.985); }
|
.dialogRow:active { transform: scale(.985); }
|
||||||
@@ -36,7 +33,7 @@
|
|||||||
.presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; }
|
.presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; }
|
||||||
.presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; }
|
.presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; }
|
||||||
.presenceOnline .presenceDot { background: var(--ok); }
|
.presenceOnline .presenceDot { background: var(--ok); }
|
||||||
.directory, .events { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; }
|
.directory { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; }
|
||||||
.directory .peopleList { padding: 0; overflow: visible; }
|
.directory .peopleList { padding: 0; overflow: visible; }
|
||||||
.directory .listSearch { max-width: 440px; margin-left: 0; }
|
.directory .listSearch { max-width: 440px; margin-left: 0; }
|
||||||
.person { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; padding: 20px 4px; border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; }
|
.person { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; padding: 20px 4px; border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; }
|
||||||
@@ -56,17 +53,29 @@
|
|||||||
.welcomeIcon { color: var(--accent2); background: var(--accent-soft); display: grid; place-items: center; width: 80px; height: 80px; border-radius: 24px; margin-bottom: 8px; }
|
.welcomeIcon { color: var(--accent2); background: var(--accent-soft); display: grid; place-items: center; width: 80px; height: 80px; border-radius: 24px; margin-bottom: 8px; }
|
||||||
.welcome h2 { font-size: 22px; letter-spacing: -.025em; margin: 0; }
|
.welcome h2 { font-size: 22px; letter-spacing: -.025em; margin: 0; }
|
||||||
.welcome p { color: var(--text2); max-width: 280px; margin: 0 0 8px; line-height: 1.7; }
|
.welcome p { color: var(--text2); max-width: 280px; margin: 0 0 8px; line-height: 1.7; }
|
||||||
.events h2 { font-size: 18px; margin: 8px 0 20px; }
|
|
||||||
.event { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 18px 12px; border-bottom: 1px solid var(--line); border-radius: 10px; }
|
|
||||||
.eventBody { flex: 1; min-width: 100px; overflow-wrap: anywhere; }
|
|
||||||
.eventBody p { color: var(--text2); font-size: 13px; margin: 4px 0; }
|
|
||||||
.event.unread { background: var(--accent-soft); }
|
|
||||||
.privacy { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 0 0 16px; min-width: 0; }
|
.privacy { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 0 0 16px; min-width: 0; }
|
||||||
.privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
|
.privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
|
||||||
.privacy summary { cursor: pointer; font-weight: 600; }
|
.privacy summary { cursor: pointer; font-weight: 600; }
|
||||||
.privacy p { margin-bottom: 0; }
|
.privacy p { margin-bottom: 0; }
|
||||||
.privacy .privacy { margin: 12px 0; }
|
.privacy .privacy { margin: 12px 0; }
|
||||||
.choice { display: flex; gap: 10px; padding: 12px 0; align-items: center; }
|
.privacy input { accent-color: var(--accent); }
|
||||||
|
.preferencesDialog { width: 560px; max-height: min(720px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
|
||||||
|
.preferencesDialog > :first-child { padding: 20px 24px 16px; flex-shrink: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.preferencesDialog > :nth-child(2) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
|
||||||
|
.preferencesDialog fieldset.privacy { border: 0; padding: 0; margin: 0; }
|
||||||
|
.preferencesDialog .privacy legend { padding: 0; margin-bottom: 12px; color: var(--text); font-weight: 600; }
|
||||||
|
.preferencesDialog .privacy .actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); }
|
||||||
|
.preferencesDialog .privacy .actions button { min-width: 0; padding: 0 8px; border-radius: 8px; min-height: 36px; }
|
||||||
|
.preferencesDialog details.privacy { padding: 16px; margin: 8px 0 0; border: 1px solid var(--line); background: var(--surface2); }
|
||||||
|
.preferencesDialog details.privacy > summary { line-height: 1.5; }
|
||||||
|
.preferencesDialog details.privacy fieldset { margin: 20px 0; }
|
||||||
|
.preferencesDialog details.privacy > label { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.5; }
|
||||||
|
.preferencesDialog details.privacy > label input { margin-top: 3px; }
|
||||||
|
.choice { display: flex; gap: 12px; padding: 14px; align-items: center; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; background: var(--surface2); font-size: 13px; line-height: 1.5; }
|
||||||
|
.choice + .choice { margin-top: 8px; }
|
||||||
|
.choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.choice:has(input:disabled) { opacity: .5; cursor: not-allowed; }
|
||||||
|
.choice input { flex-shrink: 0; accent-color: var(--accent); }
|
||||||
.stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; }
|
.stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; }
|
||||||
.chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); animation: conversationIn .24s cubic-bezier(.2,.8,.2,1); }
|
.chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); animation: conversationIn .24s cubic-bezier(.2,.8,.2,1); }
|
||||||
.chatHeader { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
|
.chatHeader { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
|
||||||
@@ -97,7 +106,7 @@
|
|||||||
.messageText { white-space: pre-wrap; user-select: text; line-height: 1.65; font-size: 14px; min-width: 0; }
|
.messageText { white-space: pre-wrap; user-select: text; line-height: 1.65; font-size: 14px; min-width: 0; }
|
||||||
.messageText a, .attachmentCaption a { color: var(--accent2); }
|
.messageText a, .attachmentCaption a { color: var(--accent2); }
|
||||||
.messageFoot { display: flex; align-items: center; justify-content: end; gap: 10px; min-height: 30px; }
|
.messageFoot { display: flex; align-items: center; justify-content: end; gap: 10px; min-height: 30px; }
|
||||||
.messageFoot small { font-size: 10px; color: var(--text3); }
|
.messageFoot small { font-size: 11px; color: var(--text2); }
|
||||||
.messageFoot button { width: 28px; height: 28px; min-width: 28px; padding: 0; border-radius: 8px; color: var(--text3); }
|
.messageFoot button { width: 28px; height: 28px; min-width: 28px; padding: 0; border-radius: 8px; color: var(--text3); }
|
||||||
.messageFoot button:hover { color: var(--text); background: var(--hover); }
|
.messageFoot button:hover { color: var(--text); background: var(--hover); }
|
||||||
.quote { margin: 0 0 2px; padding: 6px 10px; border-left: 2px solid var(--accent2); border-radius: 0 6px 6px 0; background: var(--hover); color: var(--text2); font-size: 12px; max-height: 72px; overflow: hidden; }
|
.quote { margin: 0 0 2px; padding: 6px 10px; border-left: 2px solid var(--accent2); border-radius: 0 6px 6px 0; background: var(--hover); color: var(--text2); font-size: 12px; max-height: 72px; overflow: hidden; }
|
||||||
@@ -133,6 +142,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: var(--shadow); 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 +185,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; }
|
||||||
@@ -153,9 +198,9 @@
|
|||||||
.chatTabs { padding: 0 14px; gap: 18px; } .chatTabs button { min-height: 44px; font-size: 11px; }
|
.chatTabs { padding: 0 14px; gap: 18px; } .chatTabs button { min-height: 44px; font-size: 11px; }
|
||||||
.nowListening { padding: 8px 14px; } .messages { padding: 12px; gap: 10px; } .bubble { max-width: 92%; font-size: 14px; padding: 9px 12px 4px; }
|
.nowListening { padding: 8px 14px; } .messages { padding: 12px; gap: 10px; } .bubble { max-width: 92%; font-size: 14px; padding: 9px 12px 4px; }
|
||||||
.composer { gap: 4px; padding: 10px 8px; } .composer textarea { font-size: 16px; padding: 10px; } .composer .iconButton { min-width: 40px; width: 40px; height: 44px; }
|
.composer { gap: 4px; padding: 10px 8px; } .composer textarea { font-size: 16px; padding: 10px; } .composer .iconButton { min-width: 40px; width: 40px; height: 44px; }
|
||||||
.directory, .events { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; }
|
.directory { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; }
|
||||||
.musicCard { gap: 8px; } .musicCard > :first-child { width: 40px !important; height: 40px !important; } .musicCard .iconButton { min-width: 32px; width: 32px; } .musicInfo a { font-size: 13px; }
|
.musicCard { gap: 8px; } .musicCard > :first-child { width: 40px !important; height: 40px !important; } .musicCard .iconButton { min-width: 32px; width: 32px; } .musicInfo a { font-size: 13px; }
|
||||||
.chatSearch { padding: 10px 12px; gap: 6px; } .replyBar { padding: 8px 12px; } .privacy { padding: 12px; } .privacy .actions { gap: 4px; } .privacy .actions button { padding: 0 12px; }
|
.chatSearch { padding: 10px 12px; gap: 6px; } .replyBar { padding: 8px 12px; } .privacy { padding: 12px; } .privacy .actions { gap: 4px; } .privacy .actions button { padding: 0 12px; }
|
||||||
}
|
}
|
||||||
@media(pointer: coarse) { .chatTools button, .messageFoot button, .reactions button { min-height: 44px; } }
|
@media(pointer: coarse) { .chatTools button, .messageFoot button, .reactions button { min-height: 44px; } }
|
||||||
@media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .events, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } }
|
@media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } }
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { AuthSession, ListeningState, Track } from '@pipu/core';
|
||||||
|
import { refreshListening } from '@pipu/core';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Player } from './player';
|
||||||
|
|
||||||
|
const track: Track = {
|
||||||
|
id: 'local', title: 'Local', duration_ms: 200_000, artists: [],
|
||||||
|
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
|
||||||
|
genres: [], disc_no: null, track_no: null, available: true, stream_url: '/s/local', reaction: null, features: null,
|
||||||
|
};
|
||||||
|
const remote = { ...track, id: 'remote', stream_url: '/s/remote' };
|
||||||
|
const ok = (data: unknown) => ({ data, response: new Response() });
|
||||||
|
const players: Player[] = [];
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
let snapshot: ListeningState = { room: null, requests: [], preferences: { requests: 'friends', auto_accept: 'friends', autoplay: true }, server_time: new Date().toISOString() };
|
||||||
|
const session = {
|
||||||
|
state: { status: 'signed_in', user: { id: 'guest', ui_prefs: { crossfade_s: 6, normalize: false } } },
|
||||||
|
client: {
|
||||||
|
GET: vi.fn(async (path: string) => ok(path === '/api/v1/me/social/listening' ? snapshot : remote)),
|
||||||
|
PUT: vi.fn(async () => ok({})), POST: vi.fn(async () => ok({})),
|
||||||
|
},
|
||||||
|
} as unknown as AuthSession;
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
const overlap = document.createElement('audio');
|
||||||
|
vi.spyOn(globalThis, 'Audio').mockImplementation(function () { return overlap; });
|
||||||
|
const frames: FrameRequestCallback[] = [];
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frames.push(callback); return frames.length; });
|
||||||
|
const clock = vi.spyOn(performance, 'now').mockReturnValue(0);
|
||||||
|
const player = new Player(session, audio);
|
||||||
|
players.push(player);
|
||||||
|
const startFade = () => {
|
||||||
|
player.playList([track, { ...track, id: 'next', stream_url: '/s/next' }], 0, { type: 'queue' });
|
||||||
|
Object.defineProperty(audio, 'duration', { configurable: true, value: 200 });
|
||||||
|
audio.currentTime = 196;
|
||||||
|
audio.dispatchEvent(new Event('timeupdate'));
|
||||||
|
clock.mockReturnValue(3000);
|
||||||
|
frames.shift()!(3000);
|
||||||
|
expect(overlap.paused).toBe(false);
|
||||||
|
expect(overlap.volume).toBeGreaterThan(0);
|
||||||
|
};
|
||||||
|
const follow = async () => {
|
||||||
|
snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [] } };
|
||||||
|
player.start();
|
||||||
|
await vi.waitFor(() => expect(player.getState()).toMatchObject({ track: { id: 'remote' }, playing: true, listening: { locked: true } }));
|
||||||
|
};
|
||||||
|
return { player, audio, overlap, frames, clock, startFade, follow, session, setRoom: (room: ListeningState['room']) => { snapshot = { ...snapshot, room }; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => { players.splice(0).forEach(player => player.dispose()); vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); });
|
||||||
|
|
||||||
|
describe('shared listening audio ownership', () => {
|
||||||
|
it('stops a local crossfade before following and ignores its queued animation', async () => {
|
||||||
|
const e = fixture();
|
||||||
|
e.startFade();
|
||||||
|
const staleFrame = e.frames.shift()!;
|
||||||
|
await e.follow();
|
||||||
|
expect(e.audio.paused).toBe(false);
|
||||||
|
expect(e.overlap.paused).toBe(true);
|
||||||
|
expect(e.overlap.getAttribute('src')).toBeNull();
|
||||||
|
staleFrame(6000);
|
||||||
|
expect(e.player.getState().track?.id).toBe('remote');
|
||||||
|
expect(e.overlap.paused).toBe(true);
|
||||||
|
e.setRoom(null);
|
||||||
|
refreshListening(e.session);
|
||||||
|
await vi.waitFor(() => expect(e.player.getState().listening?.room).toBeNull());
|
||||||
|
expect(e.audio.paused).toBe(true);
|
||||||
|
expect(e.overlap.paused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('silences the overlapping element on pause', () => {
|
||||||
|
const e = fixture();
|
||||||
|
e.startFade();
|
||||||
|
e.player.pause();
|
||||||
|
expect(e.audio.paused).toBe(true);
|
||||||
|
expect(e.overlap.paused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels an old transition when another track is selected', () => {
|
||||||
|
const e = fixture();
|
||||||
|
e.startFade();
|
||||||
|
const staleFrame = e.frames.shift()!;
|
||||||
|
e.player.playList([remote], 0, { type: 'queue' });
|
||||||
|
e.clock.mockReturnValue(6000);
|
||||||
|
staleFrame(6000);
|
||||||
|
expect(e.audio.getAttribute('src')).toBe('/s/remote');
|
||||||
|
expect(e.player.getState().track?.id).toBe('remote');
|
||||||
|
expect(e.overlap.paused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still completes an uninterrupted local crossfade with one active source', async () => {
|
||||||
|
const e = fixture();
|
||||||
|
e.startFade();
|
||||||
|
e.clock.mockReturnValue(6000);
|
||||||
|
e.frames.shift()!(6000);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(e.player.getState().track?.id).toBe('next');
|
||||||
|
expect(e.audio.getAttribute('src')).toBe('/s/next');
|
||||||
|
expect(e.audio.paused).toBe(false);
|
||||||
|
expect(e.overlap.paused).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,6 +49,7 @@ export class Player {
|
|||||||
private get audio() { return this.engine.audio; }
|
private get audio() { return this.engine.audio; }
|
||||||
private get overlap() { return this.engine.overlap; }
|
private get overlap() { return this.engine.overlap; }
|
||||||
private fading = false;
|
private fading = false;
|
||||||
|
private fadeVersion = 0;
|
||||||
private ignoreEnded = false;
|
private ignoreEnded = false;
|
||||||
private readonly queue = new PlayQueue();
|
private readonly queue = new PlayQueue();
|
||||||
private readonly events: EventBuffer;
|
private readonly events: EventBuffer;
|
||||||
@@ -106,7 +107,7 @@ export class Player {
|
|||||||
if (!this.listening) {
|
if (!this.listening) {
|
||||||
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
|
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
|
||||||
read: this.getState,
|
read: this.getState,
|
||||||
pause: () => { this.restoreVersion++; this.leaveWave(); this.audio.pause(); },
|
pause: () => { this.restoreVersion++; this.leaveWave(); this.cancelCrossfade(); this.audio.pause(); },
|
||||||
apply: (room, position, current) => this.syncListening(room, position, current),
|
apply: (room, position, current) => this.syncListening(room, position, current),
|
||||||
changed: listening => this.update({ listening }),
|
changed: listening => this.update({ listening }),
|
||||||
});
|
});
|
||||||
@@ -291,6 +292,7 @@ export class Player {
|
|||||||
|
|
||||||
pause() {
|
pause() {
|
||||||
if (this.listening?.locked && !this.applyingSync) return;
|
if (this.listening?.locked && !this.applyingSync) return;
|
||||||
|
this.cancelCrossfade();
|
||||||
this.audio.pause();
|
this.audio.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +334,7 @@ export class Player {
|
|||||||
const track = this.queue.current;
|
const track = this.queue.current;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
const to = Math.min(Math.max(ms, 0), track.duration_ms);
|
const to = Math.min(Math.max(ms, 0), track.duration_ms);
|
||||||
|
this.cancelCrossfade();
|
||||||
const from = Math.round(this.audio.currentTime * 1000);
|
const from = Math.round(this.audio.currentTime * 1000);
|
||||||
this.attempt?.seek(to);
|
this.attempt?.seek(to);
|
||||||
this.audio.currentTime = to / 1000;
|
this.audio.currentTime = to / 1000;
|
||||||
@@ -398,6 +401,7 @@ export class Player {
|
|||||||
// --- загрузка трека ---
|
// --- загрузка трека ---
|
||||||
|
|
||||||
private async load(autoplay: boolean, startMs = 0, current = () => true) {
|
private async load(autoplay: boolean, startMs = 0, current = () => true) {
|
||||||
|
this.cancelCrossfade();
|
||||||
const track = this.queue.current;
|
const track = this.queue.current;
|
||||||
if (!track?.stream_url) return;
|
if (!track?.stream_url) return;
|
||||||
const version = this.restoreVersion;
|
const version = this.restoreVersion;
|
||||||
@@ -484,10 +488,25 @@ export class Player {
|
|||||||
this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix);
|
this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A cancelled transition must not keep a second source audible or change the new queue. */
|
||||||
|
private cancelCrossfade() {
|
||||||
|
this.fadeVersion++;
|
||||||
|
const wasFading = this.fading;
|
||||||
|
this.fading = false;
|
||||||
|
this.ignoreEnded = false;
|
||||||
|
this.overlap.pause();
|
||||||
|
this.overlap.volume = 0;
|
||||||
|
if (this.overlap.hasAttribute('src')) {
|
||||||
|
this.overlap.removeAttribute('src');
|
||||||
|
this.overlap.load();
|
||||||
|
}
|
||||||
|
if (wasFading) this.applyLoudness();
|
||||||
|
}
|
||||||
|
|
||||||
/** В конце трека накладывает следующий на crossfade_s секунд. */
|
/** В конце трека накладывает следующий на crossfade_s секунд. */
|
||||||
private maybeCrossfade(positionMs: number) {
|
private maybeCrossfade(positionMs: number) {
|
||||||
const cf = this.crossfadeSeconds();
|
const cf = this.crossfadeSeconds();
|
||||||
if (this.fading || cf <= 0 || this.state.repeat === 'one') return;
|
if (this.listening?.locked || this.fading || cf <= 0 || this.state.repeat === 'one') return;
|
||||||
const dur = this.audio.duration;
|
const dur = this.audio.duration;
|
||||||
if (!Number.isFinite(dur)) return;
|
if (!Number.isFinite(dur)) return;
|
||||||
const left = dur * 1000 - positionMs;
|
const left = dur * 1000 - positionMs;
|
||||||
@@ -495,15 +514,16 @@ export class Player {
|
|||||||
const next = this.state.queue[this.state.index + 1];
|
const next = this.state.queue[this.state.index + 1];
|
||||||
if (!next?.stream_url || !next.available) return;
|
if (!next?.stream_url || !next.available) return;
|
||||||
this.fading = true;
|
this.fading = true;
|
||||||
|
const version = ++this.fadeVersion;
|
||||||
this.offline?.keep([this.queue.current!.id, next.id]);
|
this.offline?.keep([this.queue.current!.id, next.id]);
|
||||||
this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url;
|
this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url;
|
||||||
this.overlap.volume = 0;
|
this.overlap.volume = 0;
|
||||||
void this.overlap.play().catch(() => {
|
void this.overlap.play().catch(() => {
|
||||||
this.fading = false;
|
if (version === this.fadeVersion) this.cancelCrossfade();
|
||||||
});
|
});
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
const step = () => {
|
const step = () => {
|
||||||
if (!this.fading) return;
|
if (!this.fading || version !== this.fadeVersion) return;
|
||||||
const p = Math.min(1, (performance.now() - started) / (cf * 1000));
|
const p = Math.min(1, (performance.now() - started) / (cf * 1000));
|
||||||
this.applyLoudness(1 - p);
|
this.applyLoudness(1 - p);
|
||||||
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
|
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
|
||||||
@@ -511,12 +531,12 @@ export class Player {
|
|||||||
requestAnimationFrame(step);
|
requestAnimationFrame(step);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.finishCrossfade(next);
|
this.finishCrossfade(next, version);
|
||||||
};
|
};
|
||||||
requestAnimationFrame(step);
|
requestAnimationFrame(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
private finishCrossfade(next: Track) {
|
private finishCrossfade(next: Track, version: number) {
|
||||||
const at = this.overlap.currentTime;
|
const at = this.overlap.currentTime;
|
||||||
this.ignoreEnded = true;
|
this.ignoreEnded = true;
|
||||||
this.audio.pause();
|
this.audio.pause();
|
||||||
@@ -545,6 +565,7 @@ export class Player {
|
|||||||
this.media.updateMediaMetadata(next);
|
this.media.updateMediaMetadata(next);
|
||||||
void this.offline?.save(next).catch(() => undefined);
|
void this.offline?.save(next).catch(() => undefined);
|
||||||
void this.audio.play().finally(() => {
|
void this.audio.play().finally(() => {
|
||||||
|
if (version !== this.fadeVersion) return;
|
||||||
this.fading = false;
|
this.fading = false;
|
||||||
this.ignoreEnded = false;
|
this.ignoreEnded = false;
|
||||||
});
|
});
|
||||||
@@ -690,6 +711,7 @@ export class Player {
|
|||||||
this.activity = null;
|
this.activity = null;
|
||||||
this.restoreVersion++;
|
this.restoreVersion++;
|
||||||
this.wavePolicy.activate(null);
|
this.wavePolicy.activate(null);
|
||||||
|
this.fadeVersion++;
|
||||||
this.fading = false;
|
this.fading = false;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.engine.dispose();
|
this.engine.dispose();
|
||||||
@@ -708,7 +730,7 @@ export class Player {
|
|||||||
this.listening?.beginAudio();
|
this.listening?.beginAudio();
|
||||||
}
|
}
|
||||||
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
|
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
|
||||||
if (!room.track_id) { this.audio.pause(); return; }
|
if (!room.track_id) { this.cancelCrossfade(); this.audio.pause(); return; }
|
||||||
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
|
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
|
||||||
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
||||||
if (!current()) return;
|
if (!current()) return;
|
||||||
|
|||||||
@@ -149,21 +149,25 @@ export function Dialog({
|
|||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
|
const closeRef = useRef(onClose);
|
||||||
|
closeRef.current = onClose;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prev = document.activeElement as HTMLElement | null;
|
const prev = document.activeElement as HTMLElement | null;
|
||||||
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
|
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
|
||||||
(first ?? ref.current)?.focus();
|
(first ?? ref.current)?.focus();
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') closeRef.current();
|
||||||
if (e.key === 'Tab') {
|
if (e.key === 'Tab') {
|
||||||
const controls = [...(ref.current?.querySelectorAll<HTMLElement>(
|
const controls = [...(ref.current?.querySelectorAll<HTMLElement>(
|
||||||
'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
|
'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
|
||||||
@@ -182,10 +186,10 @@ export function Dialog({
|
|||||||
document.removeEventListener('keydown', onKey);
|
document.removeEventListener('keydown', onKey);
|
||||||
prev?.focus();
|
prev?.focus();
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, []);
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
|
<div ref={ref} className={cx(s.dialog, className)} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
|
||||||
<div className={s.dialogHead}>
|
<div className={s.dialogHead}>
|
||||||
<h2 id={titleId}>{title}</h2>
|
<h2 id={titleId}>{title}</h2>
|
||||||
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
|
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ export function installE2EFetch() {
|
|||||||
const alice = [...api.users.values()].find(u => u.username === 'alice')!;
|
const alice = [...api.users.values()].find(u => u.username === 'alice')!;
|
||||||
const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
|
const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
|
||||||
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
|
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
|
||||||
|
if (localStorage.getItem('pipu.e2e.notifications') === '1') {
|
||||||
|
const kinds = ['message', 'friend_request', 'friend_accepted', 'music'] as const;
|
||||||
|
for (let index = 0; index < 28; index++) api.notifications.push({
|
||||||
|
id: `event-${index}`, recipient: alice.id, actor_id: friend.id,
|
||||||
|
actor_name: index % 3 === 0 ? 'JoyRacoon' : index % 3 === 1 ? 'Александра' : 'Константин',
|
||||||
|
kind: kinds[index % kinds.length]!, created_at: new Date(Date.now() - index * 36e5).toISOString(), read: index >= 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
const orig = window.fetch.bind(window);
|
const orig = window.fetch.bind(window);
|
||||||
window.fetch = async (input, init) => {
|
window.fetch = async (input, init) => {
|
||||||
const req = new Request(input, init);
|
const req = new Request(input, init);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
Социальные функции работают между пользователями одного сервера. В web и desktop
|
Социальные функции работают между пользователями одного сервера. В web и desktop
|
||||||
открывается раздел «Друзья и общение», на Android — вкладка «Друзья».
|
открывается раздел «Друзья и общение», на Android — вкладка «Друзья».
|
||||||
|
|
||||||
|
|
||||||
|
На ПК справа над плеером доступна мини-панель друзей и чата шириной до 600 px.
|
||||||
|
Она позволяет переписываться без ухода с текущей страницы, сворачивается с сохранением
|
||||||
|
черновика и открывает выбранную переписку в полном разделе. На экранах уже 1024 px
|
||||||
|
используется полный раздел общения.
|
||||||
|
|
||||||
## Поведение
|
## Поведение
|
||||||
|
|
||||||
- Поиск по логину и отображаемому имени начинается с двух символов, возвращает до 30 человек.
|
- Поиск по логину и отображаемому имени начинается с двух символов, возвращает до 30 человек.
|
||||||
|
|||||||
@@ -387,6 +387,13 @@
|
|||||||
"no_results": "No playlists found"
|
"no_results": "No playlists found"
|
||||||
},
|
},
|
||||||
"social": {
|
"social": {
|
||||||
|
"notifications_all": "All",
|
||||||
|
"notifications_unread": "Unread",
|
||||||
|
"notifications_caught_up": "You’re all caught up",
|
||||||
|
"open_notification": "Open: {{name}}",
|
||||||
|
"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",
|
||||||
|
|||||||
@@ -387,6 +387,13 @@
|
|||||||
"no_results": "Плейлисты не найдены"
|
"no_results": "Плейлисты не найдены"
|
||||||
},
|
},
|
||||||
"social": {
|
"social": {
|
||||||
|
"notifications_all": "Все",
|
||||||
|
"notifications_unread": "Непрочитанные",
|
||||||
|
"notifications_caught_up": "Всё прочитано",
|
||||||
|
"open_notification": "Открыть: {{name}}",
|
||||||
|
"mini_chat": "Друзья и чат",
|
||||||
|
"open_full": "Открыть раздел",
|
||||||
|
"minimize_chat": "Свернуть чат",
|
||||||
"online": "В сети",
|
"online": "В сети",
|
||||||
"offline": "Не в сети",
|
"offline": "Не в сети",
|
||||||
"together_offline": "Слушать вместе можно, когда друг в сети",
|
"together_offline": "Слушать вместе можно, когда друг в сети",
|
||||||
|
|||||||
Reference in new issue
Block a user