Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66d3897f66 | ||
|
|
17bb0e1846 | ||
|
|
17c85493b7 | ||
|
|
75f9c57eca | ||
|
|
8a007be036 | ||
|
|
f77148df2c | ||
|
|
139dd226fa | ||
|
|
795bb7de46 | ||
|
|
11b890c2e7 | ||
|
|
59983ed1b3 | ||
|
|
cb22f15912 | ||
|
|
6450027b88 |
No files matched your search
@@ -13,7 +13,7 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ListenTogetherButton({ person }: { person: SocialPerson }) {
|
||||
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
|
||||
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
|
||||
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
|
||||
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
|
||||
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
async function login(page: Page, username = 'mobile-admin', target = '/') {
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
|
||||
await page.getByLabel('Логин', { exact: true }).fill(username);
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
|
||||
}
|
||||
|
||||
test('admin groups related pages and remembers collapsed sections', async ({ page }, info) => {
|
||||
await login(page);
|
||||
const nav = page.getByRole('navigation', { name: 'Навигация' });
|
||||
await nav.getByRole('button', { name: 'Админка', exact: true }).click();
|
||||
await expect(nav.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
|
||||
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
|
||||
await expect(nav.getByRole('button', { name: title, exact: true })).toHaveAttribute('aria-expanded', 'false');
|
||||
}
|
||||
await nav.getByRole('button', { name: 'Система', exact: true }).click();
|
||||
const system = nav.getByRole('group', { name: 'Система', exact: true });
|
||||
await expect(system.getByRole('link')).toHaveCount(4);
|
||||
for (const title of ['Обновления', 'Конфигурация', 'Настройки', 'Журнал']) {
|
||||
await expect(system.getByRole('link', { name: title, exact: true })).toBeVisible();
|
||||
}
|
||||
await nav.screenshot({ path: info.outputPath('admin-groups.png') });
|
||||
// The fake API resets authentication on a full document load; sign in again.
|
||||
await login(page);
|
||||
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'true');
|
||||
await nav.getByRole('button', { name: 'Система', exact: true }).click();
|
||||
await login(page);
|
||||
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'false');
|
||||
await login(page, 'mobile-admin', '/admin/audit');
|
||||
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toBeVisible();
|
||||
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toHaveAttribute('data-status', 'active');
|
||||
await nav.getByRole('button', { name: 'Медиатека', exact: true }).click();
|
||||
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link')).toHaveCount(2);
|
||||
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
|
||||
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link')).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('moderator sees only permitted groups and pages', async ({ page }) => {
|
||||
await login(page, 'moderator', '/admin/library');
|
||||
const nav = page.getByRole('navigation', { name: 'Навигация' });
|
||||
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link', { name: 'Библиотека' })).toBeVisible();
|
||||
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveCount(0);
|
||||
await expect(nav.getByRole('link', { name: 'Хранилища' })).toHaveCount(0);
|
||||
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
|
||||
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('mobile menu exposes grouped pages and closes after navigation', async ({ page }, info) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await login(page);
|
||||
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Навигация' });
|
||||
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
|
||||
await expect(dialog.getByRole('heading', { name: title, exact: true })).toBeVisible();
|
||||
}
|
||||
await dialog.screenshot({ path: info.outputPath('admin-groups-mobile.png') });
|
||||
await dialog.getByRole('group', { name: 'Система', exact: true }).getByRole('link', { name: 'Обновления', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/admin\/updates$/);
|
||||
});
|
||||
|
||||
test('tablet icon sidebar keeps all admin destinations accessible', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 900, height: 900 });
|
||||
await login(page);
|
||||
const nav = page.getByRole('navigation', { name: 'Навигация' });
|
||||
for (const title of ['Библиотека', 'Хранилища', 'Предложить треки', 'Очередь', 'Журнал']) {
|
||||
await expect(nav.getByRole('link', { name: title, exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const scenario of ['pending', 'automatic', 'restricted', 'incoming']) {
|
||||
test(`listening request lifecycle: ${scenario}`, async ({ page }, info) => {
|
||||
await page.addInitScript(value => {
|
||||
localStorage.setItem('pipu.locale', 'ru');
|
||||
localStorage.setItem('pipu.e2e.listening', value);
|
||||
}, scenario);
|
||||
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('link', { name: 'Друзья и общение', exact: true }).click();
|
||||
const banner = page.getByRole('region', { name: 'Слушать вместе', exact: true });
|
||||
await expect(banner).toHaveCount(0);
|
||||
if (scenario === 'incoming') {
|
||||
await expect(page.getByRole('button', { name: 'Принять', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('pending-invite.png'), fullPage: true });
|
||||
await page.getByRole('button', { name: 'Принять', exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole('button', { name: 'Слушать вместе', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Слушать вместе', exact: true });
|
||||
await expect(dialog.getByRole('radio', { name: 'Подключиться к другу', exact: true })).toBeChecked();
|
||||
await dialog.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||
if (scenario === 'restricted') {
|
||||
await expect(dialog.getByRole('alert')).toContainText('Совместное прослушивание недоступно');
|
||||
await expect(dialog.getByRole('alert')).not.toContainText('Чат доступен только друзьям');
|
||||
await page.screenshot({ path: info.outputPath('request-restricted.png'), fullPage: true });
|
||||
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(banner).toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
await expect(dialog).toHaveCount(0);
|
||||
if (scenario === 'pending') {
|
||||
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toBeVisible();
|
||||
await expect(banner).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Отмена', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toHaveCount(0);
|
||||
await expect(banner).toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await expect(banner).toContainText('Слушаете вместе с moderator');
|
||||
await expect(page.getByRole('contentinfo', { name: 'Плеер', exact: true }).getByRole('button', { name: 'Следующий', exact: true })).toBeDisabled();
|
||||
await banner.getByRole('button', { name: 'Выйти из прослушивания', exact: true }).click();
|
||||
await expect(banner).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
@@ -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.setViewportSize({ width, height: 560 });
|
||||
}
|
||||
const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!;
|
||||
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!;
|
||||
expect(composerBox.y + composerBox.height).toBeLessThanOrEqual(playerBox.y);
|
||||
await expect.poll(async () => {
|
||||
const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!;
|
||||
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!;
|
||||
return composerBox.y + composerBox.height - playerBox.y;
|
||||
}).toBeLessThanOrEqual(0);
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const main = document.querySelector('main')!;
|
||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const nginxHeaders = readFileSync(new URL('../../../deploy/nginx/security-headers.conf', import.meta.url), 'utf8');
|
||||
const desktopConfig = JSON.parse(readFileSync(new URL('../../desktop/src-tauri/tauri.conf.json', import.meta.url), 'utf8'));
|
||||
const policies: Record<string, string> = {
|
||||
web: nginxHeaders.match(/add_header Content-Security-Policy "([^"]+)" always;/)![1],
|
||||
desktop: desktopConfig.app.security.csp,
|
||||
};
|
||||
|
||||
for (const [platform, policy] of Object.entries(policies)) {
|
||||
test(`upload hash worker runs under ${platform} CSP`, async ({ page }) => {
|
||||
// Apply the deployed policy to both the document and worker responses.
|
||||
await page.route('**/*', async (route) => {
|
||||
if (new URL(route.request().url()).pathname === '/csp-upload-test') {
|
||||
await route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>Upload CSP</title>', headers: { 'Content-Security-Policy': policy } });
|
||||
return;
|
||||
}
|
||||
if (new URL(route.request().url()).pathname === '/csp-eval-test.js') {
|
||||
await route.fulfill({ contentType: 'application/javascript', body: `
|
||||
try { new Function('return 1')(); document.documentElement.dataset.evalBlocked = 'false'; }
|
||||
catch { document.documentElement.dataset.evalBlocked = 'true'; }
|
||||
` });
|
||||
return;
|
||||
}
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, headers: { ...response.headers(), 'Content-Security-Policy': policy } });
|
||||
});
|
||||
await page.goto('/csp-upload-test');
|
||||
const result = await page.evaluate(() => new Promise((resolve, reject) => {
|
||||
const worker = new Worker('/src/features/uploads/hash.worker.ts', { type: 'module' });
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.onmessage = ({ data }) => {
|
||||
if (data.type === 'progress') return;
|
||||
worker.terminate();
|
||||
resolve(data);
|
||||
};
|
||||
worker.postMessage({ id: 'csp-test', file: new File(['abc'], 'track.flac') });
|
||||
}));
|
||||
expect(result).toEqual({
|
||||
id: 'csp-test', type: 'done',
|
||||
sha256: 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
|
||||
});
|
||||
// A page script observes CSP; Playwright's evaluate bypasses the eval restriction.
|
||||
await page.addScriptTag({ url: '/csp-eval-test.js' });
|
||||
await expect(page.locator('html')).toHaveAttribute('data-eval-blocked', 'true');
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,73 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('social', () => {
|
||||
it.each([false, true])('closes a join dialog and follows only after admission (automatic=%s)', async (automatic) => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient, session } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
api.presence.set(bob.id, Date.now());
|
||||
api.activityVisibility.set(bob.id, 'friends');
|
||||
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 1000 });
|
||||
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: automatic ? 'friends' : 'nobody', autoplay: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
|
||||
await waitFor(() => expect(dialog).not.toBeInTheDocument());
|
||||
if (!automatic) {
|
||||
expect(api.listeningMembers.has(alice.id)).toBe(false);
|
||||
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
|
||||
expect(api.listeningRequests.size).toBe(1);
|
||||
const request = [...api.listeningRequests.values()][0]!;
|
||||
const room = [...api.listeningRooms.values()][0]!;
|
||||
api.listeningMembers.set(alice.id, room.id);
|
||||
api.listeningRequests.delete(request.id);
|
||||
refreshListening(session);
|
||||
}
|
||||
await screen.findByText('Слушаете вместе с bob');
|
||||
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('closes a pending invitation without announcing a stream and allows cancellation', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient, router } = await renderApp([alice, bob], `/track/${albumTracks[0]!.id}`, { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
api.presence.set(bob.id, Date.now());
|
||||
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: 'nobody', autoplay: true });
|
||||
await router.navigate({ to: '/friends' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
|
||||
await waitFor(() => expect(dialog).not.toBeInTheDocument());
|
||||
expect(api.listeningMembers.has(bob.id)).toBe(false);
|
||||
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole('button', { name: 'Отмена' }));
|
||||
await waitFor(() => expect(api.listeningRequests.size).toBe(0));
|
||||
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains listening restrictions instead of reporting a chat error and can close after rejection', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
api.presence.set(bob.id, Date.now());
|
||||
api.activityVisibility.set(bob.id, 'friends');
|
||||
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
|
||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Совместное прослушивание недоступно');
|
||||
expect(within(dialog).getByRole('alert')).not.toHaveTextContent('Чат доступен только друзьям');
|
||||
expect(api.listeningRequests.size).toBe(0);
|
||||
expect(api.listeningRooms.size).toBe(0);
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Закрыть' }));
|
||||
expect(dialog).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables listening together only while a friend is online, independently of music privacy', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
@@ -134,8 +201,9 @@ describe('social', () => {
|
||||
|
||||
it('saves privacy and independent request admission preferences', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { api, user } = await renderApp([alice], '/friends', { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Моя музыка скрыта' }));
|
||||
const { api, user } = await renderApp([alice], '/', { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Моя музыка скрыта' }));
|
||||
const privacy = await screen.findByRole('group', { name: 'Кто видит мою музыку' });
|
||||
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
|
||||
@@ -152,15 +220,19 @@ describe('social', () => {
|
||||
it('shows persistent notifications and acknowledges only the chosen event', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
await screen.findByRole('heading', { name: 'Друзья и общение' });
|
||||
const { api, user, queryClient, router } = await renderApp([alice, bob], '/', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
|
||||
api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Уведомления 1' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Уведомления 1' }));
|
||||
await screen.findByText('Хочет добавить вас в друзья');
|
||||
await user.click(screen.getByRole('button', { name: 'Прочитано' }));
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument());
|
||||
expect(api.notifications[0]?.read).toBe(true);
|
||||
await user.click(screen.getByRole('button', { name: 'Открыть: 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 });
|
||||
});
|
||||
it('searches, requests, cancels, accepts and exchanges messages and music', async () => {
|
||||
|
||||
@@ -149,7 +149,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||
|
||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||
const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> });
|
||||
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string } => typeof s.peer === 'string' ? { peer: s.peer } : {}, component: () => <SocialPage peer={socialRoute.useSearch().peer} /> });
|
||||
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string; tab?: 'messages' | 'people' | 'requests' } => ({ ...(typeof s.peer === 'string' ? { peer: s.peer } : {}), ...(s.tab === 'messages' || s.tab === 'people' || s.tab === 'requests' ? { tab: s.tab } : {}) }), component: () => { const search = socialRoute.useSearch(); return <SocialPage peer={search.peer} initialTab={search.tab} />; } });
|
||||
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
|
||||
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||
|
||||
@@ -42,7 +42,7 @@ function ListenTogetherDialog({ person, close }: { person: SocialPerson; close:
|
||||
const { api, key } = useListening(); const state = usePlayerState();
|
||||
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
|
||||
const [id] = useState(uuidv7);
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend>
|
||||
<label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label>
|
||||
<label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label>
|
||||
|
||||
@@ -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={() => { if (!event.read) read.mutate(event.id); 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 { 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 { useSession, useUser } from '../../app/session';
|
||||
import { useDesktop } from '../../desktop';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
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';
|
||||
@@ -12,20 +10,8 @@ import s from './social.module.css';
|
||||
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||
import { ChatConversation } from './ChatConversation';
|
||||
import { Presence } from './Presence';
|
||||
|
||||
const scopes = new WeakMap<object, number>();
|
||||
let nextScope = 0;
|
||||
|
||||
function useSocial() {
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const server = useDesktop()?.server ?? '';
|
||||
const api = useMemo(() => socialApi(session), [session]);
|
||||
if (!scopes.has(session)) scopes.set(session, ++nextScope);
|
||||
const key = ['social', server, scopes.get(session), user.id] as const;
|
||||
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, staleTime: 5000, refetchInterval: 15_000, refetchOnWindowFocus: true });
|
||||
return { api, key, inbox, user };
|
||||
}
|
||||
import { useSocial } from './queries';
|
||||
import { NotificationsDialog } from './NotificationsDialog';
|
||||
|
||||
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
|
||||
|
||||
@@ -36,11 +22,15 @@ export function FriendRecommendations() {
|
||||
}
|
||||
|
||||
type ShareValue = { attachment: MusicAttachment; url: string };
|
||||
type AccountDialog = 'privacy' | 'notifications' | null;
|
||||
const AccountDialogContext = createContext<{ dialog: AccountDialog; setDialog: (dialog: AccountDialog) => void }>({ dialog: null, setDialog: () => {} });
|
||||
export const useSocialDialogOpen = () => useContext(AccountDialogContext).dialog !== null;
|
||||
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
|
||||
export const useMusicShare = () => useContext(ShareContext);
|
||||
export function SocialProvider({ children }: { children: ReactNode }) {
|
||||
const [share, setShare] = useState<ShareValue | null>(null);
|
||||
return <ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider>;
|
||||
const [dialog, setDialog] = useState<AccountDialog>(null);
|
||||
return <AccountDialogContext.Provider value={{ dialog, setDialog }}><ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider></AccountDialogContext.Provider>;
|
||||
}
|
||||
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -75,10 +65,46 @@ function ShareDialog({ value, close }: { value: ShareValue; close: () => void })
|
||||
</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'), badge: 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' };
|
||||
|
||||
export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
export function SocialPage({ peer = '', initialTab }: { peer?: string; initialTab?: SocialTab }) {
|
||||
const { dialog } = useContext(AccountDialogContext);
|
||||
const { t, i18n } = useTranslation();
|
||||
const { api, inbox, key, user } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
@@ -87,7 +113,6 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
const [tab, setTab] = useState<SocialTab>('messages');
|
||||
const [search, setSearch] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [settings, setSettings] = useState(false);
|
||||
const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
|
||||
const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
@@ -102,10 +127,10 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
|
||||
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: tab === 'people' && q.length >= 2 });
|
||||
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { setConfirm(null); await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const people = inbox.data?.people ?? [];
|
||||
const friends = people.filter(p => p.relationship === 'friend');
|
||||
const conversations = [...friends].sort((a, b) => (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name));
|
||||
useEffect(() => { if (initialTab) setTab(initialTab); }, [peer, initialTab]);
|
||||
const initialPeer = conversations[0]?.id;
|
||||
useEffect(() => {
|
||||
// Pin the initial desktop selection: a newer message in another dialog must
|
||||
@@ -130,15 +155,10 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
if (op === 'block' || op === 'remove' && person.relationship === 'friend') setConfirm({ person, action: op });
|
||||
else action.mutate({ action: op, target_id: person.id });
|
||||
};
|
||||
const currentVisibility = inbox.data?.activity_visibility ?? 'nobody';
|
||||
const listLabel = t(tab === 'messages' ? 'social.find_conversation' : tab === 'requests' ? 'social.find_request' : 'social.search');
|
||||
return <div className={`${s.page} ${tab === 'messages' && peer && selected ? s.conversationOpen : ''}`}>
|
||||
<header className={s.pageHeader}>
|
||||
<div><span className={s.eyebrow}>{t('social.space')}</span><h1>{t('social.title')}</h1><p className={s.muted}>{t('social.subtitle')}</p></div>
|
||||
<div className={s.headerActions}>
|
||||
<Button variant="ghost" icon={currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon} onClick={() => setSettings(true)}>{t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings')}</Button>
|
||||
<Button variant="ghost" icon={inboxIcon} aria-pressed={tab === 'notifications'} onClick={() => setTab('notifications')}>{t('social.notifications')}{inbox.data?.unread ? <span className={s.badge}>{inbox.data.unread}</span> : null}</Button>
|
||||
</div>
|
||||
<h1>{t('social.title')}</h1>
|
||||
</header>
|
||||
<ListeningPanel showPreferences={false} />
|
||||
<section className={s.hub}>
|
||||
@@ -147,15 +167,7 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
<Button className={s.addFriend} variant="ghost" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button>
|
||||
</nav>
|
||||
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
|
||||
{tab === 'notifications' ? <div className={s.events}>
|
||||
<h2>{t('social.notifications')}</h2>
|
||||
{inbox.data?.notifications.length === 0 && <EmptyState icon={inboxIcon}>{t('social.no_notifications')}</EmptyState>}
|
||||
{inbox.data?.notifications.map(n => <article key={n.id} className={`${s.event} ${!n.read ? s.unread : ''}`}>
|
||||
<span className={s.avatar} aria-hidden>{n.actor_name.slice(0, 1).toUpperCase()}</span>
|
||||
<div className={s.eventBody}><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString(i18n.language)}</time></div>
|
||||
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
|
||||
</article>)}
|
||||
</div> : <div className={tab === 'messages' ? s.workspace : s.directory}>
|
||||
<div className={tab === 'messages' ? s.workspace : s.directory}>
|
||||
<aside ref={listRef} className={s.peopleList} aria-label={t(tab === 'messages' ? 'social.messages' : 'social.people')}>
|
||||
<div className={s.listSearch}><Icon src={searchIcon} size={18} /><input ref={searchRef} type="search" aria-label={listLabel} placeholder={listLabel} value={search} maxLength={80} onChange={e => setSearch(e.target.value)} /></div>
|
||||
{inbox.isPending && <div className={s.stack} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(i => <Skeleton key={i} style={{ height: 64 }} />)}</div>}
|
||||
@@ -167,15 +179,9 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
{p.unread > 0 && <span className={s.badge}>{p.unread}</span>}
|
||||
</button> : <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={op => act(p, op)} />)}
|
||||
</aside>
|
||||
{tab === 'messages' && (selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={(!narrow || !!peer) && !settings && !confirm} onBack={back} actions={<FriendMenu person={selected} act={op => act(selected, op)} />} /> : <div className={s.welcome}><span className={s.welcomeIcon}><Icon src={musicIcon} size={36} /></span><h2>{t('social.conversation_title')}</h2><p>{t('social.choose_chat')}</p><Button variant="primary" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button></div>)}
|
||||
</div>}
|
||||
{tab === 'messages' && (selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={(!narrow || !!peer) && !dialog && !confirm} onBack={back} actions={<FriendMenu person={selected} act={op => act(selected, op)} />} /> : <div className={s.welcome}><span className={s.welcomeIcon}><Icon src={musicIcon} size={36} /></span><h2>{t('social.conversation_title')}</h2><p>{t('social.choose_chat')}</p><Button variant="primary" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button></div>)}
|
||||
</div>
|
||||
</section>
|
||||
{settings && <Dialog title={t('social.privacy_settings')} onClose={() => setSettings(false)}>
|
||||
<fieldset className={s.privacy} disabled={!inbox.data || visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => visibility.mutate(v)}>{t(`social.visibility_${v}`)}</Button>)}</div><p className={s.muted}>{t('social.activity_privacy_hint')}</p></fieldset>
|
||||
<FormError>{visibility.error ? errorText(visibility.error) : null}</FormError>
|
||||
<ListeningPanel showRequests={false} />
|
||||
{people.some(p => p.relationship === 'blocked') && <section><h3>{t('social.blocked_people')}</h3>{people.filter(p => p.relationship === 'blocked').map(p => <Person key={p.id} person={p} busy={action.isPending} open={() => {}} act={op => act(p, op)} />)}</section>}
|
||||
</Dialog>}
|
||||
{confirm && <Dialog title={t(confirm.action === 'block' ? 'social.confirm_block' : 'social.confirm_remove', { name: confirm.person.display_name })} onClose={() => { if (!action.isPending) setConfirm(null); }} footer={<><Button disabled={action.isPending} onClick={() => setConfirm(null)}>{t('form.cancel')}</Button><Button variant="danger" busy={action.isPending} onClick={() => action.mutate({ action: confirm.action, target_id: confirm.person.id })}>{t(`social.${confirm.action}`)}</Button></>}><p className={s.muted}>{t(confirm.action === 'block' ? 'social.block_hint' : 'social.remove_hint')}</p><FormError>{action.error ? errorText(action.error) : null}</FormError></Dialog>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
.page h1 { font-size: clamp(22px, 2.2vw, 30px); letter-spacing: -.035em; margin: 4px 0 0; }
|
||||
.pageHeader p { margin: 8px 0 0; }
|
||||
.eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: 10px; font-weight: 600; color: var(--text3); }
|
||||
.page h1 { font-size: 24px; letter-spacing: -.035em; margin: 0; }
|
||||
.muted { color: var(--text2); font-size: 13px; line-height: 1.6; }
|
||||
.headerActions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: end; }
|
||||
.hub { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--surface); border: 1px solid var(--line2); border-radius: 20px; overflow: hidden; box-shadow: 0 12px 48px var(--hover); }
|
||||
.tabs { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); padding: 10px 16px; flex-shrink: 0; }
|
||||
.tabs > button:not(.addFriend) { position: relative; display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 10px; color: var(--text2); font-weight: 600; transition: color .18s, background .18s; }
|
||||
@@ -20,7 +17,7 @@
|
||||
.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::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:hover { background: var(--hover); }
|
||||
.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; }
|
||||
.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); }
|
||||
.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 .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; }
|
||||
@@ -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; }
|
||||
.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; }
|
||||
.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 legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
|
||||
.privacy summary { cursor: pointer; font-weight: 600; }
|
||||
.privacy p { margin-bottom: 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; }
|
||||
.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; }
|
||||
@@ -97,7 +106,7 @@
|
||||
.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); }
|
||||
.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: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; }
|
||||
@@ -133,6 +142,42 @@
|
||||
.musicUnavailable { opacity: .7; }
|
||||
.musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; }
|
||||
.musicMessages .musicCard { width: 100%; }
|
||||
.miniDock { grid-area: content; align-self: end; justify-self: end; z-index: 25 !important; display: flex; flex-direction: column; align-items: end; gap: 12px; width: min(600px, calc(100% - 32px)); min-height: 0; margin: 16px; pointer-events: none; }
|
||||
.miniPanel { pointer-events: auto; width: 100%; height: min(640px, calc(100dvh - 250px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line2); border-radius: 18px; background: var(--surface); box-shadow: 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; }
|
||||
.expandText { display: block; color: var(--accent2); font-size: 12px; padding: 6px 0; }
|
||||
@keyframes conversationIn { from { opacity: .4; transform: translateX(8px); } to { opacity: 1; transform: none; } }
|
||||
@@ -140,10 +185,10 @@
|
||||
@keyframes messageIn { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
@keyframes tabIn { from { opacity: 0; transform: scaleX(.5); } to { opacity: 1; transform: none; } }
|
||||
@keyframes typingPulse { from { opacity: .35; transform: translateY(0); } to { opacity: 1; transform: translateY(-3px); } }
|
||||
@media(max-width: 1150px) { .headerActions { gap: 0; } .pageHeader { align-items: start; } .headerActions > button { font-size: 12px; padding: 0 10px; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } }
|
||||
@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .headerActions { justify-content: start; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } }
|
||||
@media(max-width: 1150px) { .pageHeader { align-items: start; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } }
|
||||
@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } }
|
||||
@media(max-width: 767px) {
|
||||
.page { gap: 12px; } .pageHeader { padding: 0 2px; } .pageHeader p { display: none; } .eyebrow { display: none; } .page h1 { font-size: 23px; } .headerActions { width: 100%; justify-content: space-between; }
|
||||
.page { gap: 12px; } .pageHeader { padding: 0 2px; } .page h1 { font-size: 23px; }
|
||||
.hub { border-radius: 14px; } .tabs { flex-wrap: wrap; } .tabs > button:not(.addFriend) { flex: 1; font-size: 12px; } .tabs .addFriend { width: 100%; height: 36px; font-size: 12px; }
|
||||
.workspace { display: flex; flex-direction: column; } .workspace > .peopleList { flex: 1; border: 0; } .workspace > .chat, .workspace > .welcome { display: none; }
|
||||
.conversationOpen > .pageHeader, .conversationOpen > .stack, .conversationOpen .hub > .tabs, .conversationOpen .workspace > .peopleList { display: none; }
|
||||
@@ -153,9 +198,9 @@
|
||||
.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; }
|
||||
.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; }
|
||||
.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(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: [{ id: 'host', name: 'Host' }, { id: 'guest', name: 'Guest' }] } };
|
||||
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 overlap() { return this.engine.overlap; }
|
||||
private fading = false;
|
||||
private fadeVersion = 0;
|
||||
private ignoreEnded = false;
|
||||
private readonly queue = new PlayQueue();
|
||||
private readonly events: EventBuffer;
|
||||
@@ -106,7 +107,7 @@ export class Player {
|
||||
if (!this.listening) {
|
||||
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
|
||||
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),
|
||||
changed: listening => this.update({ listening }),
|
||||
});
|
||||
@@ -291,6 +292,7 @@ export class Player {
|
||||
|
||||
pause() {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
this.cancelCrossfade();
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
@@ -332,6 +334,7 @@ export class Player {
|
||||
const track = this.queue.current;
|
||||
if (!track) return;
|
||||
const to = Math.min(Math.max(ms, 0), track.duration_ms);
|
||||
this.cancelCrossfade();
|
||||
const from = Math.round(this.audio.currentTime * 1000);
|
||||
this.attempt?.seek(to);
|
||||
this.audio.currentTime = to / 1000;
|
||||
@@ -398,6 +401,7 @@ export class Player {
|
||||
// --- загрузка трека ---
|
||||
|
||||
private async load(autoplay: boolean, startMs = 0, current = () => true) {
|
||||
this.cancelCrossfade();
|
||||
const track = this.queue.current;
|
||||
if (!track?.stream_url) return;
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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 секунд. */
|
||||
private maybeCrossfade(positionMs: number) {
|
||||
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;
|
||||
if (!Number.isFinite(dur)) return;
|
||||
const left = dur * 1000 - positionMs;
|
||||
@@ -495,15 +514,16 @@ export class Player {
|
||||
const next = this.state.queue[this.state.index + 1];
|
||||
if (!next?.stream_url || !next.available) return;
|
||||
this.fading = true;
|
||||
const version = ++this.fadeVersion;
|
||||
this.offline?.keep([this.queue.current!.id, next.id]);
|
||||
this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url;
|
||||
this.overlap.volume = 0;
|
||||
void this.overlap.play().catch(() => {
|
||||
this.fading = false;
|
||||
if (version === this.fadeVersion) this.cancelCrossfade();
|
||||
});
|
||||
const started = performance.now();
|
||||
const step = () => {
|
||||
if (!this.fading) return;
|
||||
if (!this.fading || version !== this.fadeVersion) return;
|
||||
const p = Math.min(1, (performance.now() - started) / (cf * 1000));
|
||||
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);
|
||||
@@ -511,12 +531,12 @@ export class Player {
|
||||
requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
this.finishCrossfade(next);
|
||||
this.finishCrossfade(next, version);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
private finishCrossfade(next: Track) {
|
||||
private finishCrossfade(next: Track, version: number) {
|
||||
const at = this.overlap.currentTime;
|
||||
this.ignoreEnded = true;
|
||||
this.audio.pause();
|
||||
@@ -545,6 +565,7 @@ export class Player {
|
||||
this.media.updateMediaMetadata(next);
|
||||
void this.offline?.save(next).catch(() => undefined);
|
||||
void this.audio.play().finally(() => {
|
||||
if (version !== this.fadeVersion) return;
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
});
|
||||
@@ -690,6 +711,7 @@ export class Player {
|
||||
this.activity = null;
|
||||
this.restoreVersion++;
|
||||
this.wavePolicy.activate(null);
|
||||
this.fadeVersion++;
|
||||
this.fading = false;
|
||||
this.finishAttempt('skip');
|
||||
this.engine.dispose();
|
||||
@@ -708,7 +730,7 @@ export class Player {
|
||||
this.listening?.beginAudio();
|
||||
}
|
||||
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) {
|
||||
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
||||
if (!current()) return;
|
||||
|
||||
@@ -420,6 +420,21 @@
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.menuBadge {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-fill);
|
||||
color: var(--on-accent, #fff);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.menuDanger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -149,21 +149,25 @@ export function Dialog({
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
useEffect(() => {
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
|
||||
(first ?? ref.current)?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') closeRef.current();
|
||||
if (e.key === 'Tab') {
|
||||
const controls = [...(ref.current?.querySelectorAll<HTMLElement>(
|
||||
'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);
|
||||
prev?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
}, []);
|
||||
return createPortal(
|
||||
<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}>
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
|
||||
@@ -200,7 +204,7 @@ export function Dialog({
|
||||
);
|
||||
}
|
||||
|
||||
export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void };
|
||||
export type MenuItem = { label: string; icon?: string; danger?: boolean; badge?: number; onSelect: () => void };
|
||||
|
||||
/** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
|
||||
export function Menu({
|
||||
@@ -264,6 +268,7 @@ export function Menu({
|
||||
>
|
||||
{it.icon && <Icon src={it.icon} size={18} />}
|
||||
{it.label}
|
||||
{it.badge ? <span className={s.menuBadge}>{it.badge > 99 ? '99+' : it.badge}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fakeApi, makeUser } from './fakeApi';
|
||||
import { albumTracks, fakeApi, makeUser } from './fakeApi';
|
||||
import { mobileFixtures } from './mobileFixtures';
|
||||
|
||||
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
|
||||
@@ -14,6 +14,29 @@ export function installE2EFetch() {
|
||||
const alice = [...api.users.values()].find(u => u.username === 'alice')!;
|
||||
const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
|
||||
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
|
||||
const listeningScenario = localStorage.getItem('pipu.e2e.listening');
|
||||
if (listeningScenario) {
|
||||
const device = crypto.randomUUID();
|
||||
api.presence.set(friend.id, Date.now());
|
||||
api.activityVisibility.set(friend.id, 'friends');
|
||||
api.activity.set(friend.id, { device_id: device, track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
|
||||
api.listeningPreferences.set(friend.id, { requests: listeningScenario === 'restricted' ? 'nobody' : 'friends', auto_accept: listeningScenario === 'automatic' ? 'friends' : 'nobody', autoplay: true });
|
||||
if (listeningScenario === 'incoming') {
|
||||
const roomId = crypto.randomUUID();
|
||||
const requestId = crypto.randomUUID();
|
||||
api.listeningRooms.set(roomId, { id: roomId, owner_id: friend.id, owner_name: friend.username, device_id: device, track_id: albumTracks[0]!.id, position_ms: 0, playing: false, updated_at: new Date().toISOString(), participants: [] });
|
||||
api.listeningMembers.set(friend.id, roomId);
|
||||
api.listeningRequests.set(requestId, { id: requestId, owner_id: friend.id, guest_id: alice.id, sender_id: friend.id, sender_name: friend.username, recipient_id: alice.id, kind: 'invite', expires_at: new Date(Date.now() + 120_000).toISOString() });
|
||||
}
|
||||
}
|
||||
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);
|
||||
window.fetch = async (input, init) => {
|
||||
const req = new Request(input, init);
|
||||
|
||||
@@ -260,8 +260,13 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
if(op==='join'||op==='invite'){
|
||||
const host=op==='join'?target:user.id; const guest=op==='join'?user.id:target; const prefs=listeningPreferences.get(target)??defaultListening;
|
||||
const friend=!!friendships.get(pair(user.id,target))?.accepted;
|
||||
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.unavailable');
|
||||
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.listening_unavailable');
|
||||
if ((presence.get(target) ?? 0) <= Date.now() - 45_000) return problem(409, 'social.person_offline');
|
||||
const guestRoom = listeningRooms.get(listeningMembers.get(guest) ?? '');
|
||||
const hostRoom = listeningRooms.get(listeningMembers.get(host) ?? '');
|
||||
if (guestRoom && guestRoom.owner_id !== host || hostRoom && hostRoom.owner_id !== host) return problem(409, 'social.listening_busy');
|
||||
if (guestRoom?.owner_id === host || listeningRequests.has(String(body.id))) return new Response(null, { status: 204 });
|
||||
if ([...listeningRequests.values()].some(r => r.owner_id === host && r.guest_id === guest)) return problem(409, 'social.listening_pending');
|
||||
let room=[...listeningRooms.values()].find(r=>r.owner_id===host);
|
||||
const source=activity.get(host);if(!room){room={id:crypto.randomUUID(),owner_id:host,owner_name:users.get(host)?.username??host,device_id:String(op==='join'?source?.device_id:body.device_id),track_id:String(op==='join'?source?.track_id:body.track_id),position_ms:Number(op==='join'?source?.position_ms:body.position_ms??0),playing:true,updated_at:new Date().toISOString(),participants:[]};listeningRooms.set(room.id,room);listeningMembers.set(host,room.id);}
|
||||
if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id);
|
||||
|
||||
@@ -170,6 +170,22 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navSubsection {
|
||||
min-height: 40px;
|
||||
margin: 2px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.navSubgroup > .navItem {
|
||||
margin-left: 12px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -295,6 +311,9 @@
|
||||
.navGroupFolded {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.navSubsection { min-height: 0; }
|
||||
.navSubgroup > .navItem { margin-left: 0; padding-left: 0; border-left: 0; }
|
||||
}
|
||||
|
||||
.mobileBottom { display: none; }
|
||||
@@ -358,6 +377,8 @@
|
||||
border: 0; pointer-events: none;
|
||||
}
|
||||
.mobileMenu .navSectionTitle { display: block; }
|
||||
.mobileMenu .navSubsection { color: var(--text2); font-size: 13px; }
|
||||
.mobileMenu .navSubgroup > .navItem { margin-left: 12px; border-left: 1px solid var(--line); }
|
||||
.mobileMenu .navChevron { display: none; }
|
||||
.content, .island .content { padding: 20px 16px 24px; }
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { queueIcon } from '../shared/ui/icons';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
||||
import { Suspense, useCallback, useEffect, useId, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthState, useSession, useUser } from '../app/session';
|
||||
@@ -38,7 +38,8 @@ import { PlayerBar } from './PlayerBar';
|
||||
import { SearchBox } from './SearchBox';
|
||||
import s from './AppShell.module.css';
|
||||
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
|
||||
import { useSocialUnread, SocialProvider } from '../features/social/Social';
|
||||
import { useSocialUnread, useSocialProfileMenu, SocialProvider } from '../features/social/Social';
|
||||
import { MiniSocial } from '../features/social/MiniSocial';
|
||||
import { ListeningBanner } from '../features/social/Listening';
|
||||
|
||||
type NavPath =
|
||||
@@ -109,6 +110,8 @@ function NavSection({
|
||||
children,
|
||||
defaultFolded = false,
|
||||
mobile = false,
|
||||
nested = false,
|
||||
paths,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -116,12 +119,21 @@ function NavSection({
|
||||
children: ReactNode;
|
||||
defaultFolded?: boolean;
|
||||
mobile?: boolean;
|
||||
nested?: boolean;
|
||||
paths?: readonly string[];
|
||||
}) {
|
||||
const groupId = useId();
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
const active = paths?.some((path) => pathname === path || pathname.startsWith(`${path}/`)) ?? false;
|
||||
const [folded, setFolded] = useState(() => {
|
||||
if (active) return false;
|
||||
if (readIds(FOLDED_KEY).includes(id)) return true;
|
||||
if (readIds(TOUCHED_KEY).includes(id)) return false;
|
||||
return defaultFolded;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (active) setFolded(false);
|
||||
}, [active, pathname]);
|
||||
const toggle = () => {
|
||||
const next = !folded;
|
||||
setFolded(next);
|
||||
@@ -137,15 +149,15 @@ function NavSection({
|
||||
return (
|
||||
<>
|
||||
{mobile ? (
|
||||
<h3 className={s.navSection}><span className={s.navSectionTitle}>{title}</span></h3>
|
||||
<h3 className={cx(s.navSection, nested && s.navSubsection)}><span className={s.navSectionTitle}>{title}</span></h3>
|
||||
) : (
|
||||
<button type="button" className={s.navSection} aria-expanded={!folded} onClick={toggle}>
|
||||
<button type="button" className={cx(s.navSection, nested && s.navSubsection)} aria-expanded={!folded} aria-controls={groupId} onClick={toggle}>
|
||||
<span className={s.navSectionTitle}>{title}</span>
|
||||
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
|
||||
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
|
||||
</button>
|
||||
)}
|
||||
<div className={cx(s.navGroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
|
||||
<div id={groupId} role="group" aria-label={title} className={cx(s.navGroup, nested && s.navSubgroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -164,6 +176,7 @@ function Shell() {
|
||||
const { t } = useTranslation();
|
||||
const user = useUser();
|
||||
const socialUnread = useSocialUnread();
|
||||
const socialProfile = useSocialProfileMenu();
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const navigate = useNavigate();
|
||||
@@ -176,6 +189,7 @@ function Shell() {
|
||||
const reviews = user.reviews_uploads;
|
||||
const pending = usePendingUploads(reviews);
|
||||
const unseen = useUnseenUploads(staff);
|
||||
const uploadBadge = (reviews ? pending ?? 0 : 0) + (unseen ?? 0);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
const navigation = (mobile = false) => (
|
||||
@@ -206,30 +220,38 @@ function Shell() {
|
||||
)}
|
||||
{!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
|
||||
{staff && (
|
||||
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
|
||||
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={uploadBadge} paths={['/admin', '/uploads']} defaultFolded>
|
||||
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
||||
)}
|
||||
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
||||
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
|
||||
{reviews && (
|
||||
<NavLink
|
||||
to="/admin/uploads"
|
||||
label={t('nav.upload_queue')}
|
||||
icon={inboxIcon}
|
||||
activeIcon={inboxFillIcon}
|
||||
badge={pending}
|
||||
/>
|
||||
)}
|
||||
{(user.role === 'admin' || user.manages_updates) && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
</>
|
||||
<NavSection mobile={mobile} nested id="admin-media" title={t('nav.admin_media')} paths={['/admin/library', '/admin/sources']} defaultFolded>
|
||||
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
||||
)}
|
||||
</NavSection>
|
||||
<NavSection mobile={mobile} nested id="admin-uploads" title={t('nav.admin_uploads')} badge={uploadBadge} paths={['/uploads', '/admin/uploads']} defaultFolded>
|
||||
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
||||
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
|
||||
{reviews && (
|
||||
<NavLink
|
||||
to="/admin/uploads"
|
||||
label={t('nav.upload_queue')}
|
||||
icon={inboxIcon}
|
||||
activeIcon={inboxFillIcon}
|
||||
badge={pending}
|
||||
/>
|
||||
)}
|
||||
</NavSection>
|
||||
{(user.role === 'admin' || user.manages_updates) && (
|
||||
<NavSection mobile={mobile} nested id="admin-system" title={t('nav.admin_system')} paths={['/admin/updates', '/admin/config', '/admin/settings', '/admin/audit']} defaultFolded>
|
||||
<NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
</>
|
||||
)}
|
||||
</NavSection>
|
||||
)}
|
||||
</NavSection>
|
||||
)}
|
||||
@@ -270,6 +292,7 @@ function Shell() {
|
||||
</div>
|
||||
}
|
||||
items={[
|
||||
...socialProfile.items,
|
||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||
...(!desktop
|
||||
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
|
||||
@@ -301,7 +324,9 @@ function Shell() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{socialProfile.dialogs}
|
||||
<ShellMain />
|
||||
<MiniSocial />
|
||||
|
||||
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||
</div>
|
||||
|
||||
@@ -259,7 +259,28 @@ a.source:hover {
|
||||
}
|
||||
|
||||
.play[aria-busy='true'] {
|
||||
opacity: 0.7;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.playSpinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 3px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: playSpin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes playSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.playSpinner {
|
||||
animation-duration: 2s;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
|
||||
@@ -143,7 +143,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
aria-busy={st.loading || undefined}
|
||||
disabled={locked} onClick={() => player.toggle()}
|
||||
>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
|
||||
{st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={28} />}
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={26} />
|
||||
@@ -335,8 +335,8 @@ function NowPlayingSheet({
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} disabled={locked} onClick={() => player.toggle()}>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
|
||||
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} aria-busy={st.loading || undefined} disabled={locked} onClick={() => player.toggle()}>
|
||||
{st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={32} />}
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={28} />
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
# hash-wasm считает SHA-256 загрузок в Web Worker; разрешаем WASM без JavaScript eval.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
@@ -14,6 +14,15 @@
|
||||
|
||||
## 2. Пользователи и роли
|
||||
|
||||
В меню «Админка» пользователи доступны отдельным пунктом, остальные страницы собраны в подразделы:
|
||||
|
||||
- **Медиатека:** библиотека и хранилища.
|
||||
- **Загрузки:** предложить треки, мои предложения и очередь на рассмотрение.
|
||||
- **Система:** обновления, конфигурация, настройки и журнал.
|
||||
|
||||
На компьютере подразделы можно сворачивать; браузер запоминает выбор. Подраздел текущей страницы
|
||||
раскрывается при переходе. На телефоне группы показаны заголовками в меню «Ещё». Видимость страниц зависит от прав.
|
||||
|
||||
«Администрирование → Пользователи».
|
||||
|
||||
| Роль | Что может |
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Project Review — 2026-10-24 (1)
|
||||
|
||||
## Вывод и область проверки
|
||||
|
||||
Модульный монолит, contract-first API и общий core соответствуют масштабу проекта. После предыдущего рефакторинга у library/uploads/player появились полезные границы. Перестраивать проект целиком ради SOLID оснований нет. Приоритет — изоляция асинхронных операций при смене аккаунта, авторизация записей и согласованность состояния рекомендаций.
|
||||
|
||||
Проверены карта документации и архитектура, локальные AGENTS, выбранные потоки auth, playback/events, wave, social/listening, uploads/placement, library/schedule, web persistence/dialogs, Android offline/events/downloads/onboarding, desktop proxy, Python wave/API и текущий diff. Это статическое ревью перечисленных потоков с целевыми тестами, а не построчная проверка каждого файла или полный security-аудит. Физический Android, реальные аудиопотоки, NFS и производительность под нагрузкой не проверялись. Замечания ниже подтверждены чтением кода; отдельные новые воспроизводящие тесты для них в этой работе не добавлялись.
|
||||
|
||||
## Prior Reviews Summary
|
||||
|
||||
В каталоге были два отчёта: `2026-10-22-review-1.md` и `2026-10-21-review-1.md`. Все 17 задач отмечены `[x]`; закрытые задачи повторно не открывались.
|
||||
|
||||
### Still Open (carried forward)
|
||||
|
||||
Нет. Перенесено: 0.
|
||||
|
||||
### Resolved Since Last Review
|
||||
|
||||
Нет ранее открытых задач для перепроверки. Подтверждено новых закрытий: 0.
|
||||
|
||||
## Phase 1: Code Quality
|
||||
|
||||
### SOLID
|
||||
|
||||
**F7, P3 — WaveService собирает инфраструктуру и выполняет сценарий.** Конструктор сам создаёт IndexHolder, SessionStore и RankerHolder, `batch` открывает PostgreSQL-соединения, загружает профиль, меняет Redis-сессию, вызывает алгоритм и пишет обучающие признаки. Изолированная проверка сценария требует подмены конкретных инфраструктурных деталей. Передавать узкие порты каталога/профиля, сессии и impression sink из `_serve`, оставив SQL/Redis в адаптерах. Не нужно оборачивать каждую математическую функцию интерфейсом. Источник: `services/recommender/pipu_rec/wave/service.py:58`, `:79`, `:112`; composition root: `pipu_rec/cli.py:_serve`.
|
||||
|
||||
Доказанных нарушений LSP в просмотренных реализациях не найдено. Остаточная длина Player и наличие конкретных адаптеров в composition root сами по себе не считаются дефектами. Закрытые замечания предыдущего SOLID-ревью не дублируются.
|
||||
|
||||
### Performance
|
||||
|
||||
**F5, P2 — размер исключений растёт без ограничения и нарушает внутренний контракт.** Go `servedIDs` загружает все показы сессии и передаёт весь массив как `exclude_ids`, а Python BatchRequest ограничивает его 1000 элементами. После превышения 1000 показов запросы этой сессии получают 422, и Go каждый раз переходит на fallback. Это подтверждённый конфликт границ, а не результат нагрузочного замера. Согласовать bounded history/хранение исключений на стороне рекомендателя, сохранив политику неповторов. Источники: `services/api/internal/wave/wave.go:291`, `:349`; `services/recommender/pipu_rec/wave/service.py:40`.
|
||||
|
||||
Иных измеренных проблем производительности не установлено.
|
||||
|
||||
### Correctness & Bugs
|
||||
|
||||
**F1, P1 — незавершённая загрузка Android продолжает работу в новом аккаунте.** `attach` меняет `this.owner/server` и очищает active; `run` захватывает старую session, но после сетевого await строит путь через изменяемый `this.file` и URL через `this.abs`, затем без проверки поколения снова вставляет DownloadResumable в active. Сценарий: медленный `/tracks/batch` → logout/смена сервера → старый ответ. Старая задача может писать в каталог нового владельца и занимать его очередь; её finally может удалить active новой задачи с тем же track ID. Проверка owner внутри `status` не защищает создание загрузки, progress callback и файловые операции. Захватывать контекст задачи (owner/server/пути), проверять поколение после await и удалять active только по идентичности задачи. Источник: `apps/mobile/src/offline/downloads.ts:84`, `:228`, `:235`, `:261`.
|
||||
|
||||
**F3, P2 — поздний refresh восстанавливает устаревшую авторизацию.** `doRefresh` без проверки версии вызывает `apply`; logout/signOut и новый login не инвалидируют уже отправленный refresh. Если успешный refresh был обработан сервером до logout, но его ответ задержался, он может после signOut вернуть локальный signed_in и записать старые токены в RefreshStore. Аналогично ответ старого refresh может перезаписать новый login. Нужны поколение auth-операций и отбрасывание устаревшего результата до изменения access/store/state. Источник: `packages/core/src/auth.ts:143`, `:159`, `:167`, `:197`.
|
||||
|
||||
### Code Quality
|
||||
|
||||
Отдельных задач по косметике, размеру файлов и magic numbers не создано: они уступают проверенным нарушениям поведения.
|
||||
|
||||
## Phase 2: Logical Consistency
|
||||
|
||||
### Domain & Application Layer
|
||||
|
||||
**F2, P1 — событие пользователя может изменить исход чужой wave-сессии.** Клиент передаёт произвольный UUID в context_id; Accept передаёт его в MarkImpression, где UPDATE ограничен только sessionID/trackID. Проверки `wave_sessions.user_id = p.UserID` нет. Зная UUID чужой сессии и трека, авторизованный клиент может записать like/dislike/skip в чужой показ, влияя на метрики и обучающие данные. UUID усложняет угадывание, но не заменяет авторизацию. Передавать userID и ограничивать запись владельцем сессии в той же SQL-операции; добавить тест изоляции двух пользователей. Источники: `services/api/internal/playback/events.go:75`, `services/api/internal/playback/store.go:354`; контракт `api/openapi.yaml:PlayEvent` разрешает context_id как строку.
|
||||
|
||||
### Data Flow
|
||||
|
||||
Сохранены положительные границы: core не импортирует платформенные реализации, uploads use cases отделены от pgx, read-проекции могут делать межмодульные JOIN. Целевая проверка `pnpm check:boundaries` проходит; это не доказательство отсутствия всех циклов импортов.
|
||||
|
||||
### State Management
|
||||
|
||||
**F4, P2 — drain реакций может перезаписать более свежий выбор.** `drain` читает snapshot pending_reactions и отправляет его без координации с `react`. Возможен порядок: drain прочитал старый like → react успешно записал новый dislike и удалил pending → drain позже отправил старый like. Условие `reaction IS ?` защищает удаление SQLite-строки, но не порядок запросов к серверу. Сериализовать операции по track ID либо добавить версию и согласовать запись на сервере. Источник: `apps/mobile/src/offline/events.ts:50`, `:65`, `:77`, `:82`.
|
||||
|
||||
**F6, P2 — параллельные запросы теряют обновления Redis-сессии волны.** API запускает batch через `asyncio.to_thread`; каждый batch отдельно делает get → меняет session → put. Два запроса одной сессии могут прочитать одинаковый snapshot, выбрать пересекающиеся треки и последней записью затереть feedback/served/recent_artists первого. PostgreSQL-обновление tracks_served не защищает Redis и выбор кандидатов. Нужна сериализация/версионирование всей операции по session_id с учётом нескольких процессов, а не только локальный thread lock. Источники: `services/recommender/pipu_rec/api/app.py:wave_batch`, `pipu_rec/wave/service.py:84`, `:106`, `pipu_rec/wave/store.py:SessionStore`.
|
||||
|
||||
### Consistency
|
||||
|
||||
Конфликт внутреннего контракта Go/Python описан как F5; отдельная дублирующая задача не создаётся.
|
||||
|
||||
## Phase 3: UI/UX
|
||||
|
||||
### Usability
|
||||
|
||||
**F8, P2 — Android молча пропускает onboarding при ошибке его загрузки.** После завершения запроса с ошибкой `query.isPending` становится false, data отсутствует, и `!query.data?.required` возвращает основной интерфейс без ошибки и retry. Для нового пользователя временный сбой выглядит как отсутствие настройки рекомендаций. Разделить unknown/error и подтверждённое required=false; предусмотреть retry и осознанный вход в offline-режим без блокирования скачанной музыки. Источник: `apps/mobile/src/ui/Onboarding.tsx:29`.
|
||||
|
||||
### Visual & Consistency
|
||||
|
||||
Новых подтверждённых визуальных замечаний нет. Старые закрытые mobile-web задачи не переоткрывались. В этой работе браузер использовался для функциональных E2E; полного визуального аудита всех экранов не было.
|
||||
|
||||
### Interaction & Feedback
|
||||
|
||||
В текущих висящих изменениях завершён lifecycle listening: закрытие диалога после отправки, отсутствие стрима до admission, отдельная ошибка listening, явный запуск на втором устройстве ведущего, защита от позднего GET после выхода и сохранение refresh во время запроса. Дополнительно исправлено освобождение пустой скрытой комнаты после отмены/истечения последнего запроса; музыка источника продолжает играть. Эти исправленные в рамках сдачи дефекты не входят в открытые Tasks.
|
||||
|
||||
### Accessibility
|
||||
|
||||
У просмотренного web Dialog есть именование, focus trap, Escape и возврат фокуса; у Android Field/Button есть доступные имена/роли. Новых доказанных accessibility-дефектов не заявлено. Проверка screen reader и всех контрастов не проводилась.
|
||||
|
||||
## Tasks
|
||||
|
||||
Новых открытых задач: 8. Перенесено: 0. Ранее открытых, исправленных после прошлых ревью: 0. Исправление всех найденных замечаний не входило в просьбу завершить уже висящие изменения listening.
|
||||
|
||||
- [ ] 1. [Bug] [P1, F1] Изолировать Download jobs по поколению owner/server, включая URL, пути, progress и active cleanup — `apps/mobile/src/offline/downloads.ts` line 261.
|
||||
- [ ] 2. [Logic] [P1, F2] Проверять владельца wave-сессии при MarkImpression и покрыть изоляцию пользователей — `services/api/internal/playback/store.go` line 354.
|
||||
- [ ] 3. [Bug] [P2, F3] Инвалидировать поздние refresh/apply после logout и нового login — `packages/core/src/auth.ts` line 197.
|
||||
- [ ] 4. [Bug] [P2, F4] Согласовать порядок drain и новых реакций по track ID — `apps/mobile/src/offline/events.ts` line 82.
|
||||
- [ ] 5. [Performance] [P2, F5] Согласовать bounded exclude history Go/Python, исключить постоянный fallback после 1000 показов — `services/api/internal/wave/wave.go` line 349.
|
||||
- [ ] 6. [Logic] [P2, F6] Защитить read-modify-write wave-сессии от параллельных batch — `services/recommender/pipu_rec/wave/service.py` line 84.
|
||||
- [ ] 7. [UX] [P2, F8] Отделить ошибку onboarding от required=false и дать retry/offline-путь — `apps/mobile/src/ui/Onboarding.tsx` line 29.
|
||||
- [ ] 8. [SOLID] [P3, F7] Передавать узкие IO-порты в WaveService из composition root — `services/recommender/pipu_rec/wave/service.py` line 58.
|
||||
|
||||
## Проверки и Git
|
||||
|
||||
- До изменений этого ревью: HEAD `cb22f15`; после успешного `git fetch origin` — HEAD и origin/main совпадали, ahead=0, behind=0.
|
||||
- В рабочем дереве изначально: 13 изменённых tracked-файлов listening/тестов/переводов/docs, новый `apps/web/e2e/listening.spec.ts`, а также отдельные `scripts/share-stats.py`, `artifacts/wave-palette/`, `services/api/api.exe`. Последние три не менялись и не включаются в коммит listening.
|
||||
- Core: 69 тестов прошли, в том числе новый тест освобождения пустой комнаты.
|
||||
- Web: App + player/listening — 51 тест прошёл в итоговом прогоне. В первом запуске при параллельных проверках упал lossless-hint test; изолированно и в повторном прогоне проходит. Стабильность этого теста не считается доказанной одним повтором.
|
||||
- Mobile: 6 тестов прошли. Web/mobile/core typecheck и i18n check прошли (1111 ключей × 2 языка).
|
||||
- `pnpm check:boundaries` прошёл. `git diff --check` прошёл.
|
||||
- Go: unit-тесты social/playback/wave и vet social прошли. Integration `TestActivityRoomsAndChatPrivacy` собирается, но **SKIP**: TEST_DATABASE_URL не задан. Docker daemon недоступен. Транзакционные сценарии на реальной БД этой проверкой не подтверждены.
|
||||
- Playwright listening: 4 сценария прошли. Используется fake API; ошибки аудиопрокси ECONNREFUSED в сценариях не являются проверкой воспроизведения настоящего аудио.
|
||||
- Публичный OpenAPI не менялся, генерация не требуется. Реальный Android/background и PostgreSQL остаются ограничениями проверки.
|
||||
@@ -3,6 +3,12 @@
|
||||
Социальные функции работают между пользователями одного сервера. В web и desktop
|
||||
открывается раздел «Друзья и общение», на Android — вкладка «Друзья».
|
||||
|
||||
|
||||
На ПК справа над плеером доступна мини-панель друзей и чата шириной до 600 px.
|
||||
Она позволяет переписываться без ухода с текущей страницы, сворачивается с сохранением
|
||||
черновика и открывает выбранную переписку в полном разделе. На экранах уже 1024 px
|
||||
используется полный раздел общения.
|
||||
|
||||
## Поведение
|
||||
|
||||
- Поиск по логину и отображаемому имени начинается с двух символов, возвращает до 30 человек.
|
||||
@@ -83,6 +89,14 @@ React Query; Android останавливает polling при уходе при
|
||||
«Подключиться» отправляет запрос хозяину и оставляет управление у него.
|
||||
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
|
||||
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
|
||||
Комната резервируется на время ожидания подтверждения. До принятия запроса
|
||||
слушатель не становится участником, а баннер совместного прослушивания не показывается.
|
||||
Исходное устройство ведущего поддерживает резервирование, не прерывая свою музыку.
|
||||
После успешной отправки окно закрывается; ожидающий запрос можно отменить в разделе общения.
|
||||
После отмены или истечения последнего запроса исходное устройство освобождает
|
||||
пустую комнату при следующем опросе, не прерывая свою музыку.
|
||||
Повторный запрос уже подключённого слушателя не создаёт нового ожидания.
|
||||
Второе устройство ведущего не включает звук автоматически и при закрытии не завершает стрим.
|
||||
Настройки приёма запросов/приглашений и автоматического принятия независимы,
|
||||
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
|
||||
Дополнительная настройка управляет запуском звука при подключении.
|
||||
|
||||
@@ -31,4 +31,48 @@ describe('shared listening',()=>{
|
||||
it('pauses on connection loss and on room termination',async()=>{
|
||||
const e=setup();await e.coordinator.poll(true);e.get.mockRejectedValueOnce(new TypeError('offline'));e.advance(11000);await e.coordinator.poll(true);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({disconnected:true}));e.setState({...e.state,room:null});await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(false);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
|
||||
});
|
||||
it('keeps a reserved source room alive without announcing a stream before approval',async()=>{
|
||||
const e=setup(host,device);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]},requests:[{id:'request',owner_id:host,sender_id:guest,sender_name:'Guest',recipient_id:host,kind:'join',expires_at:'2026-10-23T00:02:00Z'}]});
|
||||
await e.coordinator.poll(true);
|
||||
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
|
||||
expect(e.put).toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();expect(e.adapter.apply).not.toHaveBeenCalled();e.coordinator.dispose();
|
||||
});
|
||||
it('releases an empty source reservation after its final request is cancelled or expires',async()=>{
|
||||
const e=setup(host,device);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]}});
|
||||
await e.coordinator.poll(true);
|
||||
expect(e.post).toHaveBeenCalledWith('/api/v1/me/social/listening/actions',expect.objectContaining({body:expect.objectContaining({action:'end',target_id:room.id})}));
|
||||
expect(e.put).not.toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();
|
||||
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
|
||||
e.coordinator.dispose();expect(e.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('does not take over another source device while its request is pending',async()=>{
|
||||
const e=setup(host);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]}});
|
||||
await e.coordinator.poll(true);
|
||||
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
|
||||
expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();e.coordinator.dispose();expect(e.post).not.toHaveBeenCalled();
|
||||
});
|
||||
it('does not start a pending guest request with no admitted room',async()=>{
|
||||
const e=setup();e.setState({...e.state,room:null,requests:[{id:'request',owner_id:host,sender_id:guest,sender_name:'Guest',recipient_id:host,kind:'join',expires_at:'2026-10-23T00:02:00Z'}]});
|
||||
await e.coordinator.poll(true);expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.coordinator.locked).toBe(false);e.coordinator.dispose();
|
||||
});
|
||||
it('requires explicit audio on a second owner device and does not end the source when it closes',async()=>{
|
||||
const e=setup(host);await e.coordinator.poll(true);
|
||||
expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:false}),3000,expect.any(Function));
|
||||
e.coordinator.beginAudio();await vi.waitFor(()=>expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:true}),3000,expect.any(Function)));
|
||||
e.coordinator.dispose();expect(e.post).not.toHaveBeenCalled();
|
||||
});
|
||||
it('discards a room lookup that arrives after a local exit',async()=>{
|
||||
const e=setup();let resolve!:(value:{data:ListeningState;response:Response})=>void;
|
||||
e.get.mockImplementationOnce(()=>new Promise(done=>{resolve=done;}));
|
||||
const pending=e.coordinator.poll(true);e.coordinator.leaveLocally();resolve({data:e.state,response:new Response()});await pending;
|
||||
expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
|
||||
});
|
||||
it('queues a refresh requested while a state lookup is in flight',async()=>{
|
||||
const e=setup();let resolve!:(value:{data:ListeningState;response:Response})=>void;
|
||||
e.get.mockImplementationOnce(()=>new Promise(done=>{resolve=done;}));
|
||||
const pending=e.coordinator.poll(true);e.coordinator.refresh();
|
||||
e.setState({...e.state,room:null});resolve({data:{...e.state,room:{...room}},response:new Response()});await pending;
|
||||
await vi.waitFor(()=>expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null})));
|
||||
expect(e.get).toHaveBeenCalledTimes(2);e.coordinator.dispose();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ export class ListeningCoordinator {
|
||||
private generation = 0;
|
||||
private sequence = 0;
|
||||
private busy = false;
|
||||
private refreshPending = false;
|
||||
private closed = false;
|
||||
private lastPoll = 0;
|
||||
private lastSuccess = 0;
|
||||
@@ -46,9 +47,12 @@ export class ListeningCoordinator {
|
||||
if (!room || !this.source(room)) return;
|
||||
const state = this.adapter.read();
|
||||
const key = `${state.track?.id}:${state.playing}`;
|
||||
if (key !== this.key) { this.key = key; void this.poll(true); }
|
||||
if (key !== this.key) { this.key = key; this.refresh(); }
|
||||
}
|
||||
refresh() {
|
||||
if (this.busy) { this.refreshPending = true; return; }
|
||||
void this.poll(true);
|
||||
}
|
||||
refresh() { void this.poll(true); }
|
||||
allowJoining() { this.ignored.clear(); }
|
||||
beginAudio() { if (this.snapshot?.room) this.enabled.add(this.snapshot.room.id); this.refresh(); }
|
||||
leaveLocally() {
|
||||
@@ -66,9 +70,10 @@ export class ListeningCoordinator {
|
||||
if (this.closed || this.busy || (!force && !this.snapshot?.room && this.now() - this.lastPoll < 10_000)) return;
|
||||
if (this.session.state?.status !== 'signed_in') return;
|
||||
this.busy = true; this.lastPoll = this.now();
|
||||
const requestGeneration = this.generation;
|
||||
try {
|
||||
const state = unwrap(await this.session.client.GET('/api/v1/me/social/listening'));
|
||||
if (this.closed) return;
|
||||
if (this.closed || requestGeneration !== this.generation) return;
|
||||
this.lastSuccess = this.now();
|
||||
const previous = this.snapshot?.room;
|
||||
this.snapshot = state;
|
||||
@@ -80,14 +85,32 @@ export class ListeningCoordinator {
|
||||
await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'leave', target_id: room.id } });
|
||||
return;
|
||||
}
|
||||
// The server reserves a room while a request is pending. Only admitted
|
||||
// participants turn it into a shared session; keep the source heartbeat
|
||||
// alive without displaying a stream or taking over another device.
|
||||
const shared = room.participants.some(person => person.id !== room.owner_id);
|
||||
if (!shared && !this.source(room)) {
|
||||
if (this.locked) { this.generation++; this.adapter.pause(); }
|
||||
this.emit(null);
|
||||
return;
|
||||
}
|
||||
if (this.source(room)) {
|
||||
this.emit(room);
|
||||
// A cancelled or expired reservation must not keep an invisible room
|
||||
// alive forever and prevent its owner from joining another stream.
|
||||
if (!shared && !state.requests.some(request => request.owner_id === room.owner_id)) {
|
||||
unwrap(await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'end', target_id: room.id } }));
|
||||
this.snapshot = { ...state, room: null };
|
||||
this.emit(null);
|
||||
return;
|
||||
}
|
||||
this.emit(shared ? room : null);
|
||||
const playback = this.adapter.read();
|
||||
unwrap(await this.session.client.PUT('/api/v1/me/social/listening/state', { body: { room_id: room.id, device_id: this.deviceId, sequence: ++this.sequence, track_id: playback.track?.id, playing: playback.playing && !!playback.track, position_ms: Math.max(0, Math.round(playback.positionMs)) } }));
|
||||
return;
|
||||
}
|
||||
if (!this.locked) this.adapter.pause();
|
||||
const needsStart = !state.preferences.autoplay && !this.enabled.has(room.id);
|
||||
const ownOtherDevice = room.owner_id === this.session.state.user.id;
|
||||
const needsStart = (!state.preferences.autoplay || ownOtherDevice) && !this.enabled.has(room.id);
|
||||
this.emit(room, needsStart);
|
||||
const version = this.generation;
|
||||
const elapsed = room.playing ? Math.max(0, Date.parse(state.server_time) - Date.parse(room.updated_at)) : 0;
|
||||
@@ -95,7 +118,10 @@ export class ListeningCoordinator {
|
||||
} catch {
|
||||
if (this.closed) return;
|
||||
if (this.locked && this.now() - this.lastSuccess > 10_000) { this.adapter.pause(); this.emit(this.snapshot?.room ?? null, false, true); }
|
||||
} finally { this.busy = false; }
|
||||
} finally {
|
||||
this.busy = false;
|
||||
if (this.refreshPending && !this.closed) { this.refreshPending = false; this.refresh(); }
|
||||
}
|
||||
}
|
||||
dispose() {
|
||||
if (this.closed) return;
|
||||
@@ -104,7 +130,8 @@ export class ListeningCoordinator {
|
||||
clients.get(this.session)?.delete(this);
|
||||
const room = this.snapshot?.room;
|
||||
if (this.locked) this.adapter.pause();
|
||||
if (room) void this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: this.source(room) ? 'end' : 'leave', target_id: room.id } }).catch(() => undefined);
|
||||
const ownAccount = this.session.state.status === 'signed_in' && room?.owner_id === this.session.state.user.id;
|
||||
if (room && (this.source(room) || !ownAccount)) void this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: this.source(room) ? 'end' : 'leave', target_id: room.id } }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
"main": "Navigation",
|
||||
"home": "Home",
|
||||
"admin": "Admin",
|
||||
"admin_media": "Media library",
|
||||
"admin_uploads": "Uploads",
|
||||
"admin_system": "System",
|
||||
"users": "Users",
|
||||
"audit": "Audit log",
|
||||
"library": "Library",
|
||||
@@ -387,6 +390,13 @@
|
||||
"no_results": "No playlists found"
|
||||
},
|
||||
"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",
|
||||
"offline": "Offline",
|
||||
"together_offline": "Listen together when your friend is online",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"target_changed": "The source changed after checking. Check for updates again."
|
||||
},
|
||||
"social": {
|
||||
"listening_unavailable": "Shared listening is unavailable. This person does not accept your requests or invitations, or is no longer available. Request preferences are in the profile menu → Privacy and communication.",
|
||||
"person_offline": "This person is offline. Try inviting them again when they connect",
|
||||
"invalid": "Check your message or action",
|
||||
"unavailable": "Chats are available to friends only. The friendship may have ended or the person is unavailable",
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
"main": "Навигация",
|
||||
"home": "Главная",
|
||||
"admin": "Админка",
|
||||
"admin_media": "Медиатека",
|
||||
"admin_uploads": "Загрузки",
|
||||
"admin_system": "Система",
|
||||
"users": "Пользователи",
|
||||
"audit": "Журнал",
|
||||
"library": "Библиотека",
|
||||
@@ -387,6 +390,13 @@
|
||||
"no_results": "Плейлисты не найдены"
|
||||
},
|
||||
"social": {
|
||||
"notifications_all": "Все",
|
||||
"notifications_unread": "Непрочитанные",
|
||||
"notifications_caught_up": "Всё прочитано",
|
||||
"open_notification": "Открыть: {{name}}",
|
||||
"mini_chat": "Друзья и чат",
|
||||
"open_full": "Открыть раздел",
|
||||
"minimize_chat": "Свернуть чат",
|
||||
"online": "В сети",
|
||||
"offline": "Не в сети",
|
||||
"together_offline": "Слушать вместе можно, когда друг в сети",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
|
||||
},
|
||||
"social": {
|
||||
"listening_unavailable": "Совместное прослушивание недоступно. Пользователь не принимает запросы или приглашения от вас либо больше недоступен. Настройки приёма находятся в меню профиля → «Приватность и общение».",
|
||||
"person_offline": "Пользователь не в сети. Повторите приглашение, когда он подключится",
|
||||
"invalid": "Проверьте сообщение или действие",
|
||||
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package genius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
artistImageBatch = 20
|
||||
artistImageRecheck = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// artistSource — то, что нужно проходу по аватаркам от Genius-клиента.
|
||||
type artistSource interface {
|
||||
Search(ctx context.Context, query string) ([]Song, error)
|
||||
Artist(ctx context.Context, id int64) (Artist, error)
|
||||
}
|
||||
|
||||
// artistImage ищет исполнителя по имени: берёт песню, у которой primary_artist совпал с именем,
|
||||
// и аватарку из неё либо из отдельного запроса артиста. Пустая строка — не нашли.
|
||||
func artistImage(ctx context.Context, src artistSource, name string) (string, error) {
|
||||
songs, err := src.Search(ctx, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, song := range songs {
|
||||
if !sameArtist(song.Artist.Name, name) {
|
||||
continue
|
||||
}
|
||||
if img := imageURL(song.Artist.ImageURL); img != "" {
|
||||
return img, nil
|
||||
}
|
||||
if song.Artist.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
a, err := src.Artist(ctx, song.Artist.ID)
|
||||
if err != nil {
|
||||
var he *HTTPError
|
||||
if errors.As(err, &he) && he.StatusCode == 404 {
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return imageURL(a.ImageURL), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// fillArtistImages проходит по исполнителям без аватарки; каждого проверяет раз в artistImageRecheck.
|
||||
func (s *Service) fillArtistImages(ctx context.Context) error {
|
||||
var active bool
|
||||
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM genius_state WHERE NOT paused AND cooldown_until<=now())`).Scan(&active); err != nil || !active {
|
||||
return err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT id, name FROM artists
|
||||
WHERE image_path IS NULL AND genius_image_url IS NULL
|
||||
AND (genius_image_checked_at IS NULL OR genius_image_checked_at < now()-$1::interval)
|
||||
ORDER BY genius_image_checked_at NULLS FIRST LIMIT $2`, artistImageRecheck.String(), artistImageBatch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type row struct {
|
||||
id uuid.UUID
|
||||
name string
|
||||
}
|
||||
var batch []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if err := rows.Scan(&r.id, &r.name); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
batch = append(batch, r)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range batch {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
img, err := artistImage(reqCtx, s.client, r.name)
|
||||
cancel()
|
||||
if err != nil {
|
||||
var he *HTTPError
|
||||
if errors.As(err, &he) && he.StatusCode == 429 {
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE genius_state SET cooldown_until=GREATEST(cooldown_until,$1)`, time.Now().Add(retryDelay(he.RetryAfter)))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `UPDATE artists SET genius_image_checked_at=now(),
|
||||
genius_image_url=COALESCE(genius_image_url, NULLIF($2,'')) WHERE id=$1`, r.id, img); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -16,11 +16,23 @@ func normalize(s string) string {
|
||||
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }), " ")
|
||||
}
|
||||
|
||||
// sameArtist сравнивает имя на Genius с нашим: у Genius часто «имя (транслитерация)»,
|
||||
// поэтому скобочный хвост допустим.
|
||||
func sameArtist(geniusName, name string) bool {
|
||||
if normalize(geniusName) == normalize(name) {
|
||||
return true
|
||||
}
|
||||
if i := strings.Index(geniusName, "("); i > 0 && strings.HasSuffix(strings.TrimSpace(geniusName), ")") {
|
||||
return normalize(geniusName[:i]) == normalize(name)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Exact normalized identity; retain version qualifiers such as live/remix.
|
||||
func matchSong(songs []Song, artist, title string) (Song, bool) {
|
||||
var matched Song
|
||||
for _, s := range songs {
|
||||
if normalize(s.Title) == normalize(title) && normalize(s.Artist.Name) == normalize(artist) {
|
||||
if normalize(s.Title) == normalize(title) && sameArtist(s.Artist.Name, artist) {
|
||||
if matched.ID != 0 && matched.ID != s.ID {
|
||||
return Song{}, false
|
||||
}
|
||||
|
||||
@@ -95,3 +95,25 @@ func TestAnnotations(t *testing.T) {
|
||||
require.True(t, notes[0].Verified)
|
||||
require.Equal(t, "https://genius.com/annotations/1", notes[0].Url)
|
||||
}
|
||||
|
||||
type fakeArtists struct {
|
||||
songs []Song
|
||||
artist Artist
|
||||
}
|
||||
|
||||
func (f fakeArtists) Search(context.Context, string) ([]Song, error) { return f.songs, nil }
|
||||
func (f fakeArtists) Artist(context.Context, int64) (Artist, error) { return f.artist, nil }
|
||||
|
||||
func TestArtistImage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := fakeArtists{songs: []Song{
|
||||
{Artist: Artist{ID: 1, Name: "Other", ImageURL: "https://images.genius.com/other.jpg"}},
|
||||
{Artist: Artist{ID: 2, Name: "Некич (Nekich)"}},
|
||||
}, artist: Artist{ID: 2, ImageURL: "https://images.genius.com/nekich.jpg"}}
|
||||
img, err := artistImage(ctx, src, "некич")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://images.genius.com/nekich.jpg", img)
|
||||
img, err = artistImage(ctx, src, "Unknown")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, img)
|
||||
}
|
||||
@@ -48,6 +48,9 @@ func (s *Service) Run(ctx context.Context) {
|
||||
if err := s.drain(ctx); err != nil && ctx.Err() == nil {
|
||||
s.log.Error("genius worker", "err", err)
|
||||
}
|
||||
if err := s.fillArtistImages(ctx); err != nil && ctx.Err() == nil {
|
||||
s.log.Error("genius artist images", "err", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
@@ -32,6 +32,9 @@ func TestSplitArtists(t *testing.T) {
|
||||
assert.Equal(t, []string{"main:Simon & Garfunkel"}, names(SplitArtists("Simon & Garfunkel", known)))
|
||||
assert.Equal(t, []string{"main:Tom", "main:Jerry"}, names(SplitArtists("Tom & Jerry", known)))
|
||||
assert.Equal(t, []string{"main:A", "main:B"}, names(SplitArtists("A; B", nil)))
|
||||
assert.Equal(t, []string{"main:A", "main:B", "main:C"}, names(SplitArtists("A, B, C", nil)))
|
||||
assert.Equal(t, []string{"main:A", "main:B"}, names(SplitArtists("A, B", func(string) bool { return true })), "already glued name is still split")
|
||||
assert.Equal(t, []string{"main:Tyler, the Creator"}, names(SplitArtists("Tyler, the Creator", nil)))
|
||||
assert.Equal(t, []string{"main:A", "feat:B", "feat:C"}, names(SplitArtists("A feat. B & C", nil)))
|
||||
assert.Equal(t, []string{"main:A"}, names(SplitArtists("A / a", nil)), "duplicates by key are dropped")
|
||||
assert.Empty(t, SplitArtists(" ", nil))
|
||||
|
||||
@@ -85,13 +85,15 @@ func SplitArtists(s string, known func(key string) bool) []ArtistCredit {
|
||||
}
|
||||
}
|
||||
for _, part := range splitAny(main, artistSeparators) {
|
||||
if strings.Contains(part, " & ") && (known == nil || !known(Key(part))) {
|
||||
for _, p := range strings.Split(part, " & ") {
|
||||
add(p, "main")
|
||||
for _, seg := range splitComma(part) {
|
||||
if strings.Contains(seg, " & ") && (known == nil || !known(Key(seg))) {
|
||||
for _, p := range strings.Split(seg, " & ") {
|
||||
add(p, "main")
|
||||
}
|
||||
continue
|
||||
}
|
||||
continue
|
||||
add(seg, "main")
|
||||
}
|
||||
add(part, "main")
|
||||
}
|
||||
for _, part := range splitAny(feat, append(artistSeparators, " & ", ", ")) {
|
||||
add(part, "feat")
|
||||
@@ -99,6 +101,20 @@ func SplitArtists(s string, known func(key string) bool) []ArtistCredit {
|
||||
return out
|
||||
}
|
||||
|
||||
// splitComma делит по «, » без оглядки на библиотеку: иначе уже склеенное имя («A, B») навсегда
|
||||
// остаётся «известным». Продолжение «the …» — часть имени («Tyler, the Creator»).
|
||||
func splitComma(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ", ") {
|
||||
if n := len(out); n > 0 && strings.HasPrefix(strings.ToLower(p), "the ") {
|
||||
out[n-1] += ", " + p
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitAny(s string, seps []string) []string {
|
||||
parts := []string{s}
|
||||
for _, sep := range seps {
|
||||
|
||||
@@ -422,7 +422,13 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
|
||||
if m.Album == "" {
|
||||
m.Album = UnknownAlbum
|
||||
}
|
||||
albumArtist, err := sc.store.EnsureArtist(ctx, m.AlbumArtist)
|
||||
known := func(k string) bool { return sc.store.ArtistKeyExists(ctx, k) }
|
||||
// Альбом принадлежит одному исполнителю: у составного «A, B» это первый основной.
|
||||
albumArtistName := m.AlbumArtist
|
||||
if credits := meta.SplitArtists(m.AlbumArtist, known); len(credits) > 0 {
|
||||
albumArtistName = credits[0].Name
|
||||
}
|
||||
albumArtist, err := sc.store.EnsureArtist(ctx, albumArtistName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -437,7 +443,9 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !dup {
|
||||
// Тот же файл, перечитанный в тот же трек, заново разбирает исполнителей: правила разбора меняются.
|
||||
sameTrack := dup && r.oldTrack != nil && *r.oldTrack == trackID
|
||||
if !dup || sameTrack {
|
||||
// Перечитанный файл сохраняет свой трек (лайки, история); новый — получает новый.
|
||||
if r.oldTrack != nil {
|
||||
trackID = *r.oldTrack
|
||||
@@ -445,7 +453,7 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
|
||||
if trackID, err = sc.store.SaveTrack(ctx, trackID, in); err != nil {
|
||||
return false, err
|
||||
}
|
||||
credits := meta.SplitArtists(m.Artist, func(k string) bool { return sc.store.ArtistKeyExists(ctx, k) })
|
||||
credits := meta.SplitArtists(m.Artist, known)
|
||||
ids := make([]uuid.UUID, 0, len(credits))
|
||||
roles := make([]string, 0, len(credits))
|
||||
for _, c := range credits {
|
||||
|
||||
@@ -85,6 +85,18 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
|
||||
require.NoError(t, s.publishActivity(ctx, owner, activity))
|
||||
join := gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}
|
||||
require.Error(t, s.listeningAction(ctx, friend, join)) // default nobody
|
||||
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='nobody' WHERE id=$1`, owner)
|
||||
require.NoError(t, e)
|
||||
require.NoError(t, s.listeningAction(ctx, friend, join))
|
||||
pendingJoin, e := s.listeningState(ctx, friend)
|
||||
require.NoError(t, e)
|
||||
require.Nil(t, pendingJoin.Room, "sending a request is not admission")
|
||||
require.Len(t, pendingJoin.Requests, 1)
|
||||
pendingHost, e := s.listeningState(ctx, owner)
|
||||
require.NoError(t, e)
|
||||
require.Len(t, pendingHost.Room.Participants, 1)
|
||||
require.NoError(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: join.Id}))
|
||||
join.Id = uuid.New()
|
||||
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='everyone' WHERE id=$1`, owner)
|
||||
require.NoError(t, e)
|
||||
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}))
|
||||
@@ -94,6 +106,12 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
|
||||
require.NotNil(t, state.Room)
|
||||
require.Equal(t, owner, state.Room.OwnerId)
|
||||
require.Len(t, state.Room.Participants, 2)
|
||||
// Reopening the dialog must not create another pending request for an
|
||||
// already admitted listener, even with a new action ID.
|
||||
require.NoError(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}))
|
||||
state, e = s.listeningState(ctx, friend)
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, state.Requests)
|
||||
hostctx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner})
|
||||
badctx := auth.WithPrincipal(ctx, auth.Principal{UserID: friend})
|
||||
input := gen.ListeningRoomInput{RoomId: state.Room.Id, DeviceId: device, Sequence: 1, Playing: false, TrackId: &track, PositionMs: 3000}
|
||||
@@ -113,11 +131,50 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
|
||||
require.NoError(t, e)
|
||||
require.Len(t, state.Requests, 1)
|
||||
require.Nil(t, state.Room)
|
||||
reserved, e := s.listeningState(ctx, owner)
|
||||
require.NoError(t, e)
|
||||
require.NotNil(t, reserved.Room)
|
||||
require.Len(t, reserved.Room.Participants, 1, "pending invite must not admit the guest")
|
||||
require.NoError(t, s.listeningAction(ctx, owner, invite), "network retry is idempotent")
|
||||
duplicate := invite
|
||||
duplicate.Id = uuid.New()
|
||||
require.ErrorContains(t, s.listeningAction(ctx, owner, duplicate), "listening_pending")
|
||||
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: invite.Id}))
|
||||
require.NoError(t, s.listeningAction(ctx, owner, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: invite.Id}))
|
||||
state, e = s.listeningState(ctx, other)
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, state.Requests)
|
||||
require.Nil(t, state.Room)
|
||||
invite.Id = uuid.New()
|
||||
require.NoError(t, s.listeningAction(ctx, owner, invite))
|
||||
_, e = pool.Exec(ctx, `UPDATE listening_requests SET expires_at=now()-interval '1 second' WHERE id=$1`, invite.Id)
|
||||
require.NoError(t, e)
|
||||
require.ErrorContains(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}), "listening_expired")
|
||||
state, e = s.listeningState(ctx, other)
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, state.Requests)
|
||||
require.Nil(t, state.Room)
|
||||
invite.Id = uuid.New()
|
||||
require.NoError(t, s.listeningAction(ctx, owner, invite))
|
||||
require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "decline", TargetId: invite.Id}))
|
||||
state, e = s.listeningState(ctx, other)
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, state.Requests)
|
||||
require.Nil(t, state.Room)
|
||||
invite.Id = uuid.New()
|
||||
require.NoError(t, s.listeningAction(ctx, owner, invite))
|
||||
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='nobody' WHERE id=$1`, other)
|
||||
require.NoError(t, e)
|
||||
require.ErrorContains(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}), "listening_unavailable")
|
||||
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='everyone' WHERE id=$1`, other)
|
||||
require.NoError(t, e)
|
||||
require.Error(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
|
||||
require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
|
||||
state, e = s.listeningState(ctx, other)
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, owner, state.Room.OwnerId)
|
||||
require.Len(t, state.Room.Participants, 2)
|
||||
require.ErrorContains(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "invite", TargetId: other, DeviceId: &device, TrackId: &track}), "listening_busy")
|
||||
_, e = pool.Exec(ctx, `UPDATE listening_rooms SET updated_at=now()-interval '31 seconds' WHERE id=$1`, state.Room.Id)
|
||||
require.NoError(t, e)
|
||||
state, e = s.listeningState(ctx, other)
|
||||
|
||||
@@ -17,6 +17,9 @@ func audienceAllows(policy string, friends bool) bool {
|
||||
func validAudience(v gen.ActivityVisibility) bool {
|
||||
return v == "nobody" || v == "friends" || v == "everyone"
|
||||
}
|
||||
func listeningDenied() error {
|
||||
return apperr.Forbidden("social.listening_unavailable", "shared listening is not permitted")
|
||||
}
|
||||
func (h *Handler) SetListeningPreferences(ctx context.Context, r gen.SetListeningPreferencesRequestObject) (gen.SetListeningPreferencesResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
@@ -75,7 +78,7 @@ func (h *Handler) PublishListeningState(ctx context.Context, r gen.PublishListen
|
||||
return nil, e
|
||||
}
|
||||
if !own {
|
||||
return nil, denied()
|
||||
return nil, listeningDenied()
|
||||
}
|
||||
}
|
||||
return gen.PublishListeningState204Response{}, nil
|
||||
@@ -147,7 +150,7 @@ func listeningPolicy(ctx context.Context, tx pgx.Tx, recipient, sender uuid.UUID
|
||||
COALESCE(social_seen_at>now()-interval '45 seconds',false) AND EXISTS(SELECT 1 FROM users WHERE id=$2 AND social_seen_at>now()-interval '45 seconds')
|
||||
FROM users WHERE id=$1`, recipient, sender).Scan(&policy, &automatic, &friends, &blocked, &online)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return false, false, denied()
|
||||
return false, false, listeningDenied()
|
||||
}
|
||||
if e != nil {
|
||||
return false, false, e
|
||||
@@ -164,7 +167,7 @@ func admitListening(ctx context.Context, tx pgx.Tx, request, room, guest, recipi
|
||||
return e
|
||||
}
|
||||
if !allowed {
|
||||
return denied()
|
||||
return listeningDenied()
|
||||
}
|
||||
result, e := tx.Exec(ctx, `INSERT INTO listening_members(user_id,room_id) VALUES($1,$2) ON CONFLICT(user_id) DO UPDATE SET last_seen=now() WHERE listening_members.room_id=EXCLUDED.room_id`, guest, room)
|
||||
if e != nil {
|
||||
@@ -199,7 +202,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
|
||||
return e
|
||||
}
|
||||
if a.Action == "end" && owner != id {
|
||||
return denied()
|
||||
return listeningDenied()
|
||||
}
|
||||
if owner == id {
|
||||
_, e = tx.Exec(ctx, `DELETE FROM listening_rooms WHERE id=$1`, a.TargetId)
|
||||
@@ -217,7 +220,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
|
||||
return e
|
||||
}
|
||||
if (a.Action == "cancel" && sender != id) || (a.Action != "cancel" && recipient != id) {
|
||||
return denied()
|
||||
return listeningDenied()
|
||||
}
|
||||
if status != "pending" {
|
||||
return nil
|
||||
@@ -257,7 +260,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
|
||||
return err
|
||||
}
|
||||
if !permitted {
|
||||
return denied()
|
||||
return listeningDenied()
|
||||
}
|
||||
var repeated bool
|
||||
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_requests WHERE id=$1 AND sender_id=$2 AND owner_id=$3 AND guest_id=$4 AND kind=$5)`, a.Id, id, owner, guest, a.Action).Scan(&repeated); e != nil {
|
||||
@@ -273,6 +276,13 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
|
||||
if busy {
|
||||
return apperr.Conflict("social.listening_busy", "already in another room")
|
||||
}
|
||||
var alreadyJoined bool
|
||||
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_members m JOIN listening_rooms r ON r.id=m.room_id WHERE m.user_id=$1 AND r.owner_id=$2)`, guest, owner).Scan(&alreadyJoined); e != nil {
|
||||
return e
|
||||
}
|
||||
if alreadyJoined {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
var recent int
|
||||
if e = tx.QueryRow(ctx, `SELECT count(*) FROM listening_requests WHERE sender_id=$1 AND created_at>now()-interval '1 minute'`, id).Scan(&recent); e != nil {
|
||||
return e
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE artists ADD COLUMN genius_image_checked_at timestamptz;
|
||||
-- +goose Down
|
||||
ALTER TABLE artists DROP COLUMN genius_image_checked_at;
|
||||
Reference in new issue
Block a user