Compare commits

..
12 Commits
Author SHA1 Message Date
mrleo1nid 66d3897f66 feat(admin): group navigation into media, uploads and system
ci / ts / ts (packages + web) (push) Failing after 4m33s
release / android (APK в релиз) (push) Successful in 4m45s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 8s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m14s
release / prune (старые файлы и прогоны) (push) Successful in 12s
2026-10-28 23:47:17 +03:00
mrleo1nid 17bb0e1846 fix(uploads): allow WebAssembly hashing under CSP
ci / ts / ts (packages + web) (push) Failing after 4m29s
ci / desktop / rust (desktop proxy) (push) Successful in 5m54s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 3m23s
2026-10-28 23:34:42 +03:00
mrleo1nidandClaude Sonnet 5.5 17c85493b7 fix(library): always split comma-separated artists even if glued name exists
ci / api / api (push) Successful in 34s
ci / db / db (push) Successful in 42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:26:49 +03:00
mrleo1nidandClaude Sonnet 5.5 75f9c57eca fix(library): split compound album artist and re-split artists on rescan
ci / api / api (push) Successful in 37s
ci / db / db (push) Successful in 55s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:29:50 +03:00
mrleo1nidandClaude Sonnet 5.5 8a007be036 feat(web): show spinner on play button while track is loading
ci / ts / ts (packages + web) (push) Successful in 3m15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:23:19 +03:00
mrleo1nidandClaude Sonnet 5.5 f77148df2c fix(web): show notification count as menu badge and mark read on open
ci / ts / ts (packages + web) (push) Successful in 2m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:56:05 +03:00
mrleo1nidandClaude Sonnet 5.5 139dd226fa feat(genius): fetch artist avatars by name and match names with transliteration suffix
ci / api / api (push) Successful in 27s
ci / db / db (push) Successful in 53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:50:55 +03:00
mrleo1nidandClaude Sonnet 5.5 795bb7de46 fix(library): split comma-separated main artists
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 1m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 3m43s
ci / db / db (push) Canceled after 11m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:39:32 +03:00
mrleo1nid 11b890c2e7 docs: record architecture and correctness review 2026-10-24 02:16:05 +03:00
mrleo1nid 59983ed1b3 fix(social): complete shared listening request lifecycle 2026-10-24 02:16:00 +03:00
mrleo1nid cb22f15912 fix(web): polish social UI and prevent doubled shared audio
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m20s
release / desktop (Linux и Windows в релиз) (push) Successful in 19m22s
ci / ts / ts (packages + web) (push) Successful in 3m9s
release / release (версия и заметки) (push) Successful in 10s
release / android (APK в релиз) (push) Successful in 4m7s
release / prune (старые файлы и прогоны) (push) Successful in 11s
2026-10-23 19:54:10 +03:00
mrleo1nid 6450027b88 feat(web): add desktop mini chat and move social controls to profile
ci / ts / ts (packages + web) (push) Successful in 4m55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m37s
2026-10-23 18:37:32 +03:00
48 changed files with 1477 additions and 145 deletions

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }], "windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
"security": { "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": { "bundle": {
@@ -38,7 +38,7 @@ export function ListenTogetherButton({ person }: { person: SocialPerson }) {
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) { 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 { 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 [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> 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.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')} /> <Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
+73
View File
@@ -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();
}
});
+49
View File
@@ -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);
});
}
+72
View File
@@ -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('Продолжить в полном чате');
});
}
+45
View File
@@ -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();
});
}
+43
View File
@@ -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);
});
}
+5 -3
View File
@@ -65,9 +65,11 @@ for (const width of [320, 800, 1280]) {
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click(); await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
await page.setViewportSize({ width, height: 560 }); await page.setViewportSize({ width, height: 560 });
} }
const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!; await expect.poll(async () => {
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!; const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!;
expect(composerBox.y + composerBox.height).toBeLessThanOrEqual(playerBox.y); const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!;
return composerBox.y + composerBox.height - playerBox.y;
}).toBeLessThanOrEqual(0);
await expect.poll(() => page.evaluate(() => { await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!; const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth); return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
+48
View File
@@ -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');
});
}
+77 -5
View File
@@ -15,6 +15,73 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup); afterEach(cleanup);
describe('social', () => { 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 () => { 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 alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice }); 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 () => { it('saves privacy and independent request admission preferences', async () => {
const alice = makeUser('alice', 'user'); const alice = makeUser('alice', 'user');
const { api, user } = await renderApp([alice], '/friends', { signedInAs: alice }); const { api, user } = await renderApp([alice], '/', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Моя музыка скрыта' })); await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
await user.click(await screen.findByRole('menuitem', { name: 'Моя музыка скрыта' }));
const privacy = await screen.findByRole('group', { name: 'Кто видит мою музыку' }); const privacy = await screen.findByRole('group', { name: 'Кто видит мою музыку' });
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true'); expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' })); await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
@@ -152,15 +220,19 @@ describe('social', () => {
it('shows persistent notifications and acknowledges only the chosen event', async () => { it('shows persistent notifications and acknowledges only the chosen event', async () => {
const alice = makeUser('alice', 'user'); const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice }); const { api, user, queryClient, router } = await renderApp([alice, bob], '/', { signedInAs: alice });
await screen.findByRole('heading', { name: 'Друзья и общение' }); api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false }); api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] }); await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Уведомления 1' })); await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
await user.click(await screen.findByRole('menuitem', { name: 'Уведомления 1' }));
await screen.findByText('Хочет добавить вас в друзья'); await screen.findByText('Хочет добавить вас в друзья');
await user.click(screen.getByRole('button', { name: 'Прочитано' })); await user.click(screen.getByRole('button', { name: 'Прочитано' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument());
expect(api.notifications[0]?.read).toBe(true); expect(api.notifications[0]?.read).toBe(true);
await user.click(screen.getByRole('button', { name: 'Открыть: bob' }));
await screen.findByRole('button', { name: 'Принять' });
expect(router.state.location.pathname).toBe('/friends');
expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id }); expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id });
}); });
it('searches, requests, cancels, accepts and exchanges messages and music', async () => { it('searches, requests, cancels, accepts and exchanges messages and music', async () => {
+1 -1
View File
@@ -149,7 +149,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage }); const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> }); const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> });
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string } => typeof s.peer === 'string' ? { peer: s.peer } : {}, component: () => <SocialPage peer={socialRoute.useSearch().peer} /> }); const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string; tab?: 'messages' | 'people' | 'requests' } => ({ ...(typeof s.peer === 'string' ? { peer: s.peer } : {}), ...(s.tab === 'messages' || s.tab === 'people' || s.tab === 'requests' ? { tab: s.tab } : {}) }), component: () => { const search = socialRoute.useSearch(); return <SocialPage peer={search.peer} initialTab={search.tab} />; } });
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> }); const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> }); const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage }); const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
+1 -1
View File
@@ -42,7 +42,7 @@ function ListenTogetherDialog({ person, close }: { person: SocialPerson; close:
const { api, key } = useListening(); const state = usePlayerState(); const { api, key } = useListening(); const state = usePlayerState();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
const [id] = useState(uuidv7); 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> 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 === '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> <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>;
}
+52 -46
View File
@@ -1,10 +1,8 @@
import { socialApi, uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core'; import { uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router'; import { Link, useNavigate } from '@tanstack/react-router';
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useDesktop } from '../../desktop';
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, EmptyState, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui'; import { Button, Dialog, EmptyState, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldIcon, visibilityOffIcon } from '../../shared/ui/icons'; import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldIcon, visibilityOffIcon } from '../../shared/ui/icons';
@@ -12,20 +10,8 @@ import s from './social.module.css';
import { ListeningPanel, ListenTogetherButton } from './Listening'; import { ListeningPanel, ListenTogetherButton } from './Listening';
import { ChatConversation } from './ChatConversation'; import { ChatConversation } from './ChatConversation';
import { Presence } from './Presence'; import { Presence } from './Presence';
import { useSocial } from './queries';
const scopes = new WeakMap<object, number>(); import { NotificationsDialog } from './NotificationsDialog';
let nextScope = 0;
function useSocial() {
const session = useSession();
const user = useUser();
const server = useDesktop()?.server ?? '';
const api = useMemo(() => socialApi(session), [session]);
if (!scopes.has(session)) scopes.set(session, ++nextScope);
const key = ['social', server, scopes.get(session), user.id] as const;
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, staleTime: 5000, refetchInterval: 15_000, refetchOnWindowFocus: true });
return { api, key, inbox, user };
}
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; } export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
@@ -36,11 +22,15 @@ export function FriendRecommendations() {
} }
type ShareValue = { attachment: MusicAttachment; url: string }; type ShareValue = { attachment: MusicAttachment; url: string };
type AccountDialog = 'privacy' | 'notifications' | null;
const AccountDialogContext = createContext<{ dialog: AccountDialog; setDialog: (dialog: AccountDialog) => void }>({ dialog: null, setDialog: () => {} });
export const useSocialDialogOpen = () => useContext(AccountDialogContext).dialog !== null;
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); }); const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
export const useMusicShare = () => useContext(ShareContext); export const useMusicShare = () => useContext(ShareContext);
export function SocialProvider({ children }: { children: ReactNode }) { export function SocialProvider({ children }: { children: ReactNode }) {
const [share, setShare] = useState<ShareValue | null>(null); const [share, setShare] = useState<ShareValue | null>(null);
return <ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider>; const [dialog, setDialog] = useState<AccountDialog>(null);
return <AccountDialogContext.Provider value={{ dialog, setDialog }}><ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider></AccountDialogContext.Provider>;
} }
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) { function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -75,10 +65,46 @@ function ShareDialog({ value, close }: { value: ShareValue; close: () => void })
</Dialog>; </Dialog>;
} }
type SocialTab = 'messages' | 'people' | 'requests' | 'notifications'; export function useSocialProfileMenu() {
const { t } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const navigate = useNavigate();
const errorText = useErrorText();
const { dialog, setDialog } = useContext(AccountDialogContext);
const closeDialog = useCallback(() => setDialog(null), [setDialog]);
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const people = inbox.data?.people ?? [];
const currentVisibility = inbox.data?.activity_visibility ?? 'nobody';
const open = (id: string) => {
const relationship = people.find(p => p.id === id)?.relationship;
const tab = relationship === 'friend' ? 'messages' : relationship === 'incoming' || relationship === 'outgoing' ? 'requests' : 'people';
setDialog(null);
void navigate({ to: '/friends', search: { peer: id, tab } });
};
return {
items: [
{ label: t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings'), icon: currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon, onSelect: () => setDialog('privacy') },
{ label: t('social.notifications'), 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' }; type ConfirmAction = { person: SocialPerson; action: 'remove' | 'block' };
export function SocialPage({ peer = '' }: { peer?: string }) { export function SocialPage({ peer = '', initialTab }: { peer?: string; initialTab?: SocialTab }) {
const { dialog } = useContext(AccountDialogContext);
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { api, inbox, key, user } = useSocial(); const { api, inbox, key, user } = useSocial();
const cache = useQueryClient(); const cache = useQueryClient();
@@ -87,7 +113,6 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
const [tab, setTab] = useState<SocialTab>('messages'); const [tab, setTab] = useState<SocialTab>('messages');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [settings, setSettings] = useState(false);
const [confirm, setConfirm] = useState<ConfirmAction | null>(null); const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false); const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
@@ -102,10 +127,10 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]); useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: tab === 'people' && q.length >= 2 }); const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: tab === 'people' && q.length >= 2 });
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { setConfirm(null); await cache.invalidateQueries({ queryKey: key }); } }); const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { setConfirm(null); await cache.invalidateQueries({ queryKey: key }); } });
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const people = inbox.data?.people ?? []; const people = inbox.data?.people ?? [];
const friends = people.filter(p => p.relationship === 'friend'); const friends = people.filter(p => p.relationship === 'friend');
const conversations = [...friends].sort((a, b) => (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name)); const conversations = [...friends].sort((a, b) => (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name));
useEffect(() => { if (initialTab) setTab(initialTab); }, [peer, initialTab]);
const initialPeer = conversations[0]?.id; const initialPeer = conversations[0]?.id;
useEffect(() => { useEffect(() => {
// Pin the initial desktop selection: a newer message in another dialog must // Pin the initial desktop selection: a newer message in another dialog must
@@ -130,15 +155,10 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
if (op === 'block' || op === 'remove' && person.relationship === 'friend') setConfirm({ person, action: op }); if (op === 'block' || op === 'remove' && person.relationship === 'friend') setConfirm({ person, action: op });
else action.mutate({ action: op, target_id: person.id }); else action.mutate({ action: op, target_id: person.id });
}; };
const currentVisibility = inbox.data?.activity_visibility ?? 'nobody';
const listLabel = t(tab === 'messages' ? 'social.find_conversation' : tab === 'requests' ? 'social.find_request' : 'social.search'); const listLabel = t(tab === 'messages' ? 'social.find_conversation' : tab === 'requests' ? 'social.find_request' : 'social.search');
return <div className={`${s.page} ${tab === 'messages' && peer && selected ? s.conversationOpen : ''}`}> return <div className={`${s.page} ${tab === 'messages' && peer && selected ? s.conversationOpen : ''}`}>
<header className={s.pageHeader}> <header className={s.pageHeader}>
<div><span className={s.eyebrow}>{t('social.space')}</span><h1>{t('social.title')}</h1><p className={s.muted}>{t('social.subtitle')}</p></div> <h1>{t('social.title')}</h1>
<div className={s.headerActions}>
<Button variant="ghost" icon={currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon} onClick={() => setSettings(true)}>{t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings')}</Button>
<Button variant="ghost" icon={inboxIcon} aria-pressed={tab === 'notifications'} onClick={() => setTab('notifications')}>{t('social.notifications')}{inbox.data?.unread ? <span className={s.badge}>{inbox.data.unread}</span> : null}</Button>
</div>
</header> </header>
<ListeningPanel showPreferences={false} /> <ListeningPanel showPreferences={false} />
<section className={s.hub}> <section className={s.hub}>
@@ -147,15 +167,7 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
<Button className={s.addFriend} variant="ghost" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button> <Button className={s.addFriend} variant="ghost" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button>
</nav> </nav>
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError> <FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
{tab === 'notifications' ? <div className={s.events}> <div className={tab === 'messages' ? s.workspace : s.directory}>
<h2>{t('social.notifications')}</h2>
{inbox.data?.notifications.length === 0 && <EmptyState icon={inboxIcon}>{t('social.no_notifications')}</EmptyState>}
{inbox.data?.notifications.map(n => <article key={n.id} className={`${s.event} ${!n.read ? s.unread : ''}`}>
<span className={s.avatar} aria-hidden>{n.actor_name.slice(0, 1).toUpperCase()}</span>
<div className={s.eventBody}><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString(i18n.language)}</time></div>
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
</article>)}
</div> : <div className={tab === 'messages' ? s.workspace : s.directory}>
<aside ref={listRef} className={s.peopleList} aria-label={t(tab === 'messages' ? 'social.messages' : 'social.people')}> <aside ref={listRef} className={s.peopleList} aria-label={t(tab === 'messages' ? 'social.messages' : 'social.people')}>
<div className={s.listSearch}><Icon src={searchIcon} size={18} /><input ref={searchRef} type="search" aria-label={listLabel} placeholder={listLabel} value={search} maxLength={80} onChange={e => setSearch(e.target.value)} /></div> <div className={s.listSearch}><Icon src={searchIcon} size={18} /><input ref={searchRef} type="search" aria-label={listLabel} placeholder={listLabel} value={search} maxLength={80} onChange={e => setSearch(e.target.value)} /></div>
{inbox.isPending && <div className={s.stack} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(i => <Skeleton key={i} style={{ height: 64 }} />)}</div>} {inbox.isPending && <div className={s.stack} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(i => <Skeleton key={i} style={{ height: 64 }} />)}</div>}
@@ -167,15 +179,9 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
{p.unread > 0 && <span className={s.badge}>{p.unread}</span>} {p.unread > 0 && <span className={s.badge}>{p.unread}</span>}
</button> : <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={op => act(p, op)} />)} </button> : <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={op => act(p, op)} />)}
</aside> </aside>
{tab === 'messages' && (selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={(!narrow || !!peer) && !settings && !confirm} onBack={back} actions={<FriendMenu person={selected} act={op => act(selected, op)} />} /> : <div className={s.welcome}><span className={s.welcomeIcon}><Icon src={musicIcon} size={36} /></span><h2>{t('social.conversation_title')}</h2><p>{t('social.choose_chat')}</p><Button variant="primary" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button></div>)} {tab === 'messages' && (selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={(!narrow || !!peer) && !dialog && !confirm} onBack={back} actions={<FriendMenu person={selected} act={op => act(selected, op)} />} /> : <div className={s.welcome}><span className={s.welcomeIcon}><Icon src={musicIcon} size={36} /></span><h2>{t('social.conversation_title')}</h2><p>{t('social.choose_chat')}</p><Button variant="primary" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button></div>)}
</div>} </div>
</section> </section>
{settings && <Dialog title={t('social.privacy_settings')} onClose={() => setSettings(false)}>
<fieldset className={s.privacy} disabled={!inbox.data || visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => visibility.mutate(v)}>{t(`social.visibility_${v}`)}</Button>)}</div><p className={s.muted}>{t('social.activity_privacy_hint')}</p></fieldset>
<FormError>{visibility.error ? errorText(visibility.error) : null}</FormError>
<ListeningPanel showRequests={false} />
{people.some(p => p.relationship === 'blocked') && <section><h3>{t('social.blocked_people')}</h3>{people.filter(p => p.relationship === 'blocked').map(p => <Person key={p.id} person={p} busy={action.isPending} open={() => {}} act={op => act(p, op)} />)}</section>}
</Dialog>}
{confirm && <Dialog title={t(confirm.action === 'block' ? 'social.confirm_block' : 'social.confirm_remove', { name: confirm.person.display_name })} onClose={() => { if (!action.isPending) setConfirm(null); }} footer={<><Button disabled={action.isPending} onClick={() => setConfirm(null)}>{t('form.cancel')}</Button><Button variant="danger" busy={action.isPending} onClick={() => action.mutate({ action: confirm.action, target_id: confirm.person.id })}>{t(`social.${confirm.action}`)}</Button></>}><p className={s.muted}>{t(confirm.action === 'block' ? 'social.block_hint' : 'social.remove_hint')}</p><FormError>{action.error ? errorText(action.error) : null}</FormError></Dialog>} {confirm && <Dialog title={t(confirm.action === 'block' ? 'social.confirm_block' : 'social.confirm_remove', { name: confirm.person.display_name })} onClose={() => { if (!action.isPending) setConfirm(null); }} footer={<><Button disabled={action.isPending} onClick={() => setConfirm(null)}>{t('form.cancel')}</Button><Button variant="danger" busy={action.isPending} onClick={() => action.mutate({ action: confirm.action, target_id: confirm.person.id })}>{t(`social.${confirm.action}`)}</Button></>}><p className={s.muted}>{t(confirm.action === 'block' ? 'social.block_hint' : 'social.remove_hint')}</p><FormError>{action.error ? errorText(action.error) : null}</FormError></Dialog>}
</div>; </div>;
} }
@@ -0,0 +1,40 @@
.dialog { width: 600px; max-height: min(680px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
.dialog > :first-child { flex-shrink: 0; padding: 20px 24px 16px; }
.dialog > :nth-child(2) { display: flex; flex-direction: column; gap: 0; min-height: 0; padding: 0; overflow: hidden; }
.filters { display: flex; gap: 6px; flex-shrink: 0; padding: 0 24px 16px; border-bottom: 1px solid var(--line); }
.filters button { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 12px; border-radius: 9px; color: var(--text2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
.filters button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent2); }
.filters button:hover { background: var(--hover); color: var(--text); }
.count { font-size: 11px; font-variant-numeric: tabular-nums; color: var(--accent2); }
.list { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line2) transparent; padding: 8px 12px 12px; }
.row { position: relative; display: flex; align-items: center; border-radius: 12px; }
.row + .row { margin-top: 4px; }
.row.unread { background: var(--accent-soft); }
.row:hover { background: var(--hover); }
.open { display: flex; align-items: center; gap: 14px; flex: 1; min-width: 0; padding: 14px 12px; border-radius: 12px; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
.avatar { display: grid; place-items: center; flex-shrink: 0; width: 40px; height: 40px; border: 1px solid var(--line2); border-radius: 50%; background: var(--surface2); color: var(--accent2); font-size: 14px; font-weight: 600; }
.content { display: flex; flex-direction: column; flex: 1; min-width: 0; gap: 4px; overflow-wrap: anywhere; }
.content strong { font-size: 13px; font-weight: 600; }
.content > span { font-size: 13px; color: var(--text2); line-height: 1.45; }
.content time { color: var(--text3); font-size: 11px; line-height: 1.4; }
.arrow { flex-shrink: 0; color: var(--text3); }
.read { position: relative; display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; margin-right: 10px; border-radius: 9px; color: var(--accent2); cursor: pointer; }
.read > svg, .read > span:last-child { opacity: 0; }
.dot { position: absolute; width: 6px; height: 6px; border-radius: 50%; background: var(--accent2); }
.read:hover, .read:focus-visible { background: var(--accent-soft); }
.read:hover .dot, .read:focus-visible .dot { opacity: 0; }
.read:hover > :last-child, .read:focus-visible > :last-child { opacity: 1; }
.read:disabled { opacity: .5; cursor: wait; }
.open:focus-visible, .read:focus-visible, .filters button:focus-visible, .list:focus-visible { outline: 2px solid var(--accent2); outline-offset: -2px; }
.loading { display: grid; gap: 8px; padding: 12px; }
.empty { padding: 36px 20px; }
@media(max-width: 767px) {
.dialog { max-height: min(680px, calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))); }
.dialog > :first-child { padding: 16px; }
.filters { padding: 0 16px 12px; }
.list { padding: 6px; }
.open { gap: 10px; padding: 12px 10px; }
.avatar { width: 34px; height: 34px; }
.read { width: 36px; min-height: 44px; margin-right: 4px; }
}
@media(hover: none) { .read > :last-child { opacity: 1; } .read .dot { display: none; } }
+20
View File
@@ -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 };
}
+64 -19
View File
@@ -1,10 +1,7 @@
.page { width: 100%; max-width: 1480px; margin: 0 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 20px; } .page { width: 100%; max-width: 1480px; margin: 0 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
.pageHeader { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-shrink: 0; } .pageHeader { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-shrink: 0; }
.page h1 { font-size: clamp(22px, 2.2vw, 30px); letter-spacing: -.035em; margin: 4px 0 0; } .page h1 { font-size: 24px; letter-spacing: -.035em; margin: 0; }
.pageHeader p { margin: 8px 0 0; }
.eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: 10px; font-weight: 600; color: var(--text3); }
.muted { color: var(--text2); font-size: 13px; line-height: 1.6; } .muted { color: var(--text2); font-size: 13px; line-height: 1.6; }
.headerActions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: end; }
.hub { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--surface); border: 1px solid var(--line2); border-radius: 20px; overflow: hidden; box-shadow: 0 12px 48px var(--hover); } .hub { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--surface); border: 1px solid var(--line2); border-radius: 20px; overflow: hidden; box-shadow: 0 12px 48px var(--hover); }
.tabs { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); padding: 10px 16px; flex-shrink: 0; } .tabs { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); padding: 10px 16px; flex-shrink: 0; }
.tabs > button:not(.addFriend) { position: relative; display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 10px; color: var(--text2); font-weight: 600; transition: color .18s, background .18s; } .tabs > button:not(.addFriend) { position: relative; display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 10px; color: var(--text2); font-weight: 600; transition: color .18s, background .18s; }
@@ -20,7 +17,7 @@
.listSearch:focus-within { border-color: var(--accent); } .listSearch:focus-within { border-color: var(--accent); }
.listSearch input { color: var(--text); font: inherit; font-size: 13px; width: 100%; min-width: 0; background: transparent; border: 0; height: 44px; } .listSearch input { color: var(--text); font: inherit; font-size: 13px; width: 100%; min-width: 0; background: transparent; border: 0; height: 44px; }
.listSearch input::placeholder { color: var(--text3); } .listSearch input::placeholder { color: var(--text3); }
.listSearch input:focus-visible { outline-offset: 4px; } .listSearch input:focus-visible { outline: none; }
.dialogRow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 12px; text-align: left; border-radius: 12px; margin-bottom: 4px; transition: background .18s, transform .18s; } .dialogRow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 12px; text-align: left; border-radius: 12px; margin-bottom: 4px; transition: background .18s, transform .18s; }
.dialogRow:hover { background: var(--hover); } .dialogRow:hover { background: var(--hover); }
.dialogRow:active { transform: scale(.985); } .dialogRow:active { transform: scale(.985); }
@@ -36,7 +33,7 @@
.presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; } .presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; }
.presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; } .presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; }
.presenceOnline .presenceDot { background: var(--ok); } .presenceOnline .presenceDot { background: var(--ok); }
.directory, .events { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; } .directory { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; }
.directory .peopleList { padding: 0; overflow: visible; } .directory .peopleList { padding: 0; overflow: visible; }
.directory .listSearch { max-width: 440px; margin-left: 0; } .directory .listSearch { max-width: 440px; margin-left: 0; }
.person { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; padding: 20px 4px; border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; } .person { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; padding: 20px 4px; border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; }
@@ -56,17 +53,29 @@
.welcomeIcon { color: var(--accent2); background: var(--accent-soft); display: grid; place-items: center; width: 80px; height: 80px; border-radius: 24px; margin-bottom: 8px; } .welcomeIcon { color: var(--accent2); background: var(--accent-soft); display: grid; place-items: center; width: 80px; height: 80px; border-radius: 24px; margin-bottom: 8px; }
.welcome h2 { font-size: 22px; letter-spacing: -.025em; margin: 0; } .welcome h2 { font-size: 22px; letter-spacing: -.025em; margin: 0; }
.welcome p { color: var(--text2); max-width: 280px; margin: 0 0 8px; line-height: 1.7; } .welcome p { color: var(--text2); max-width: 280px; margin: 0 0 8px; line-height: 1.7; }
.events h2 { font-size: 18px; margin: 8px 0 20px; }
.event { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 18px 12px; border-bottom: 1px solid var(--line); border-radius: 10px; }
.eventBody { flex: 1; min-width: 100px; overflow-wrap: anywhere; }
.eventBody p { color: var(--text2); font-size: 13px; margin: 4px 0; }
.event.unread { background: var(--accent-soft); }
.privacy { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 0 0 16px; min-width: 0; } .privacy { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 0 0 16px; min-width: 0; }
.privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; } .privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
.privacy summary { cursor: pointer; font-weight: 600; } .privacy summary { cursor: pointer; font-weight: 600; }
.privacy p { margin-bottom: 0; } .privacy p { margin-bottom: 0; }
.privacy .privacy { margin: 12px 0; } .privacy .privacy { margin: 12px 0; }
.choice { display: flex; gap: 10px; padding: 12px 0; align-items: center; } .privacy input { accent-color: var(--accent); }
.preferencesDialog { width: 560px; max-height: min(720px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
.preferencesDialog > :first-child { padding: 20px 24px 16px; flex-shrink: 0; border-bottom: 1px solid var(--line); }
.preferencesDialog > :nth-child(2) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
.preferencesDialog fieldset.privacy { border: 0; padding: 0; margin: 0; }
.preferencesDialog .privacy legend { padding: 0; margin-bottom: 12px; color: var(--text); font-weight: 600; }
.preferencesDialog .privacy .actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); }
.preferencesDialog .privacy .actions button { min-width: 0; padding: 0 8px; border-radius: 8px; min-height: 36px; }
.preferencesDialog details.privacy { padding: 16px; margin: 8px 0 0; border: 1px solid var(--line); background: var(--surface2); }
.preferencesDialog details.privacy > summary { line-height: 1.5; }
.preferencesDialog details.privacy fieldset { margin: 20px 0; }
.preferencesDialog details.privacy > label { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.5; }
.preferencesDialog details.privacy > label input { margin-top: 3px; }
.choice { display: flex; gap: 12px; padding: 14px; align-items: center; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; background: var(--surface2); font-size: 13px; line-height: 1.5; }
.choice + .choice { margin-top: 8px; }
.choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
.choice:has(input:disabled) { opacity: .5; cursor: not-allowed; }
.choice input { flex-shrink: 0; accent-color: var(--accent); }
.stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; } .stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; }
.chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); animation: conversationIn .24s cubic-bezier(.2,.8,.2,1); } .chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); animation: conversationIn .24s cubic-bezier(.2,.8,.2,1); }
.chatHeader { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; } .chatHeader { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
@@ -97,7 +106,7 @@
.messageText { white-space: pre-wrap; user-select: text; line-height: 1.65; font-size: 14px; min-width: 0; } .messageText { white-space: pre-wrap; user-select: text; line-height: 1.65; font-size: 14px; min-width: 0; }
.messageText a, .attachmentCaption a { color: var(--accent2); } .messageText a, .attachmentCaption a { color: var(--accent2); }
.messageFoot { display: flex; align-items: center; justify-content: end; gap: 10px; min-height: 30px; } .messageFoot { display: flex; align-items: center; justify-content: end; gap: 10px; min-height: 30px; }
.messageFoot small { font-size: 10px; color: var(--text3); } .messageFoot small { font-size: 11px; color: var(--text2); }
.messageFoot button { width: 28px; height: 28px; min-width: 28px; padding: 0; border-radius: 8px; color: var(--text3); } .messageFoot button { width: 28px; height: 28px; min-width: 28px; padding: 0; border-radius: 8px; color: var(--text3); }
.messageFoot button:hover { color: var(--text); background: var(--hover); } .messageFoot button:hover { color: var(--text); background: var(--hover); }
.quote { margin: 0 0 2px; padding: 6px 10px; border-left: 2px solid var(--accent2); border-radius: 0 6px 6px 0; background: var(--hover); color: var(--text2); font-size: 12px; max-height: 72px; overflow: hidden; } .quote { margin: 0 0 2px; padding: 6px 10px; border-left: 2px solid var(--accent2); border-radius: 0 6px 6px 0; background: var(--hover); color: var(--text2); font-size: 12px; max-height: 72px; overflow: hidden; }
@@ -133,6 +142,42 @@
.musicUnavailable { opacity: .7; } .musicUnavailable { opacity: .7; }
.musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; } .musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; }
.musicMessages .musicCard { width: 100%; } .musicMessages .musicCard { width: 100%; }
.miniDock { grid-area: content; align-self: end; justify-self: end; z-index: 25 !important; display: flex; flex-direction: column; align-items: end; gap: 12px; width: min(600px, calc(100% - 32px)); min-height: 0; margin: 16px; pointer-events: none; }
.miniPanel { pointer-events: auto; width: 100%; height: min(640px, calc(100dvh - 250px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line2); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); overflow: hidden; animation: panelIn .2s ease-out; }
.miniDockIsland { margin-bottom: 132px; }
.miniDockIsland .miniPanel { height: min(640px, calc(100dvh - 290px)); }
.miniPanel[hidden] { display: none; }
.miniHeader { display: flex; align-items: center; gap: 6px; padding: 8px 10px 8px 16px; border-bottom: 1px solid var(--line); }
.miniHeader > strong { flex: 1; font-size: 14px; }
.miniHeader > button { min-height: 36px; padding: 0 8px; font-size: 12px; }
.miniWorkspace { flex: 1; min-height: 0; display: grid; grid-template-columns: 170px minmax(0, 1fr); }
.miniFriends { overflow-y: auto; min-width: 0; padding: 8px; border-right: 1px solid var(--line); }
.miniFriends .listSearch { padding: 0 8px; gap: 5px; margin: 0 0 8px; min-height: 36px; }
.miniFriends .listSearch input { height: 36px; font-size: 11px; }
.miniFriends .dialogRow { gap: 8px; padding: 10px 6px; }
.miniFriends .avatar { width: 28px; height: 28px; font-size: 12px; }
.miniFriends .dialogBody strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; }
.miniFriends .badge { min-width: 17px; height: 17px; padding: 0 4px; font-size: 10px; }
.miniWorkspace .chat { min-width: 0; }
.miniWorkspace .chatHeader { gap: 8px; padding: 10px; }
.miniWorkspace .chatHeader .avatar { width: 30px; height: 30px; font-size: 12px; }
.miniWorkspace .chatIdentity h2 { font-size: 14px; }
.miniWorkspace .chatTools .togetherLabel { display: none; }
.miniWorkspace .chatTools > button, .miniWorkspace .chatTools > div > button { width: 34px; min-width: 34px; padding: 0; }
.miniWorkspace .chatTabs { gap: 14px; padding: 0 12px; }
.miniWorkspace .chatTabs button { font-size: 11px; }
.miniWorkspace .messages { padding: 12px; gap: 10px; }
.miniWorkspace .bubble { max-width: 94%; font-size: 13px; padding: 8px 10px 4px; }
.miniWorkspace .composer { padding: 8px; gap: 4px; }
.miniWorkspace .composer .iconButton { width: 34px; min-width: 34px; }
.miniWorkspace .composer textarea { padding: 8px; font-size: 13px; }
.miniWorkspace .welcome { padding: 20px; }
.miniTrigger { position: relative; pointer-events: auto; display: grid; place-items: center; width: 48px; height: 48px; flex-shrink: 0; border-radius: 50%; color: var(--text); border: 1px solid var(--line2); background: var(--surface); box-shadow: 0 6px 24px #0005; cursor: pointer; }
.miniTrigger:hover, .miniTrigger[aria-expanded='true'] { background: var(--accent-fill); color: var(--on-accent, #fff); }
.miniTrigger:focus-visible { outline: 2px solid var(--accent2); outline-offset: 3px; }
.miniTrigger > .badge { position: absolute; top: -5px; right: -5px; }
@media(max-width: 1023px) { .miniDock { display: none; } }
@media(prefers-reduced-motion: reduce) { .miniPanel { animation: none; } }
.attachmentCaption { font-size: 12px; color: var(--text2); white-space: pre-wrap; } .attachmentCaption { font-size: 12px; color: var(--text2); white-space: pre-wrap; }
.expandText { display: block; color: var(--accent2); font-size: 12px; padding: 6px 0; } .expandText { display: block; color: var(--accent2); font-size: 12px; padding: 6px 0; }
@keyframes conversationIn { from { opacity: .4; transform: translateX(8px); } to { opacity: 1; transform: none; } } @keyframes conversationIn { from { opacity: .4; transform: translateX(8px); } to { opacity: 1; transform: none; } }
@@ -140,10 +185,10 @@
@keyframes messageIn { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } } @keyframes messageIn { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
@keyframes tabIn { from { opacity: 0; transform: scaleX(.5); } to { opacity: 1; transform: none; } } @keyframes tabIn { from { opacity: 0; transform: scaleX(.5); } to { opacity: 1; transform: none; } }
@keyframes typingPulse { from { opacity: .35; transform: translateY(0); } to { opacity: 1; transform: translateY(-3px); } } @keyframes typingPulse { from { opacity: .35; transform: translateY(0); } to { opacity: 1; transform: translateY(-3px); } }
@media(max-width: 1150px) { .headerActions { gap: 0; } .pageHeader { align-items: start; } .headerActions > button { font-size: 12px; padding: 0 10px; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } } @media(max-width: 1150px) { .pageHeader { align-items: start; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } }
@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .headerActions { justify-content: start; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } } @media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } }
@media(max-width: 767px) { @media(max-width: 767px) {
.page { gap: 12px; } .pageHeader { padding: 0 2px; } .pageHeader p { display: none; } .eyebrow { display: none; } .page h1 { font-size: 23px; } .headerActions { width: 100%; justify-content: space-between; } .page { gap: 12px; } .pageHeader { padding: 0 2px; } .page h1 { font-size: 23px; }
.hub { border-radius: 14px; } .tabs { flex-wrap: wrap; } .tabs > button:not(.addFriend) { flex: 1; font-size: 12px; } .tabs .addFriend { width: 100%; height: 36px; font-size: 12px; } .hub { border-radius: 14px; } .tabs { flex-wrap: wrap; } .tabs > button:not(.addFriend) { flex: 1; font-size: 12px; } .tabs .addFriend { width: 100%; height: 36px; font-size: 12px; }
.workspace { display: flex; flex-direction: column; } .workspace > .peopleList { flex: 1; border: 0; } .workspace > .chat, .workspace > .welcome { display: none; } .workspace { display: flex; flex-direction: column; } .workspace > .peopleList { flex: 1; border: 0; } .workspace > .chat, .workspace > .welcome { display: none; }
.conversationOpen > .pageHeader, .conversationOpen > .stack, .conversationOpen .hub > .tabs, .conversationOpen .workspace > .peopleList { display: none; } .conversationOpen > .pageHeader, .conversationOpen > .stack, .conversationOpen .hub > .tabs, .conversationOpen .workspace > .peopleList { display: none; }
@@ -153,9 +198,9 @@
.chatTabs { padding: 0 14px; gap: 18px; } .chatTabs button { min-height: 44px; font-size: 11px; } .chatTabs { padding: 0 14px; gap: 18px; } .chatTabs button { min-height: 44px; font-size: 11px; }
.nowListening { padding: 8px 14px; } .messages { padding: 12px; gap: 10px; } .bubble { max-width: 92%; font-size: 14px; padding: 9px 12px 4px; } .nowListening { padding: 8px 14px; } .messages { padding: 12px; gap: 10px; } .bubble { max-width: 92%; font-size: 14px; padding: 9px 12px 4px; }
.composer { gap: 4px; padding: 10px 8px; } .composer textarea { font-size: 16px; padding: 10px; } .composer .iconButton { min-width: 40px; width: 40px; height: 44px; } .composer { gap: 4px; padding: 10px 8px; } .composer textarea { font-size: 16px; padding: 10px; } .composer .iconButton { min-width: 40px; width: 40px; height: 44px; }
.directory, .events { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; } .directory { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; }
.musicCard { gap: 8px; } .musicCard > :first-child { width: 40px !important; height: 40px !important; } .musicCard .iconButton { min-width: 32px; width: 32px; } .musicInfo a { font-size: 13px; } .musicCard { gap: 8px; } .musicCard > :first-child { width: 40px !important; height: 40px !important; } .musicCard .iconButton { min-width: 32px; width: 32px; } .musicInfo a { font-size: 13px; }
.chatSearch { padding: 10px 12px; gap: 6px; } .replyBar { padding: 8px 12px; } .privacy { padding: 12px; } .privacy .actions { gap: 4px; } .privacy .actions button { padding: 0 12px; } .chatSearch { padding: 10px 12px; gap: 6px; } .replyBar { padding: 8px 12px; } .privacy { padding: 12px; } .privacy .actions { gap: 4px; } .privacy .actions button { padding: 0 12px; }
} }
@media(pointer: coarse) { .chatTools button, .messageFoot button, .reactions button { min-height: 44px; } } @media(pointer: coarse) { .chatTools button, .messageFoot button, .reactions button { min-height: 44px; } }
@media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .events, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } } @media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } }
+102
View File
@@ -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);
});
});
+29 -7
View File
@@ -49,6 +49,7 @@ export class Player {
private get audio() { return this.engine.audio; } private get audio() { return this.engine.audio; }
private get overlap() { return this.engine.overlap; } private get overlap() { return this.engine.overlap; }
private fading = false; private fading = false;
private fadeVersion = 0;
private ignoreEnded = false; private ignoreEnded = false;
private readonly queue = new PlayQueue(); private readonly queue = new PlayQueue();
private readonly events: EventBuffer; private readonly events: EventBuffer;
@@ -106,7 +107,7 @@ export class Player {
if (!this.listening) { if (!this.listening) {
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, { this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
read: this.getState, read: this.getState,
pause: () => { this.restoreVersion++; this.leaveWave(); this.audio.pause(); }, pause: () => { this.restoreVersion++; this.leaveWave(); this.cancelCrossfade(); this.audio.pause(); },
apply: (room, position, current) => this.syncListening(room, position, current), apply: (room, position, current) => this.syncListening(room, position, current),
changed: listening => this.update({ listening }), changed: listening => this.update({ listening }),
}); });
@@ -291,6 +292,7 @@ export class Player {
pause() { pause() {
if (this.listening?.locked && !this.applyingSync) return; if (this.listening?.locked && !this.applyingSync) return;
this.cancelCrossfade();
this.audio.pause(); this.audio.pause();
} }
@@ -332,6 +334,7 @@ export class Player {
const track = this.queue.current; const track = this.queue.current;
if (!track) return; if (!track) return;
const to = Math.min(Math.max(ms, 0), track.duration_ms); const to = Math.min(Math.max(ms, 0), track.duration_ms);
this.cancelCrossfade();
const from = Math.round(this.audio.currentTime * 1000); const from = Math.round(this.audio.currentTime * 1000);
this.attempt?.seek(to); this.attempt?.seek(to);
this.audio.currentTime = to / 1000; this.audio.currentTime = to / 1000;
@@ -398,6 +401,7 @@ export class Player {
// --- загрузка трека --- // --- загрузка трека ---
private async load(autoplay: boolean, startMs = 0, current = () => true) { private async load(autoplay: boolean, startMs = 0, current = () => true) {
this.cancelCrossfade();
const track = this.queue.current; const track = this.queue.current;
if (!track?.stream_url) return; if (!track?.stream_url) return;
const version = this.restoreVersion; const version = this.restoreVersion;
@@ -484,10 +488,25 @@ export class Player {
this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix); this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix);
} }
/** A cancelled transition must not keep a second source audible or change the new queue. */
private cancelCrossfade() {
this.fadeVersion++;
const wasFading = this.fading;
this.fading = false;
this.ignoreEnded = false;
this.overlap.pause();
this.overlap.volume = 0;
if (this.overlap.hasAttribute('src')) {
this.overlap.removeAttribute('src');
this.overlap.load();
}
if (wasFading) this.applyLoudness();
}
/** В конце трека накладывает следующий на crossfade_s секунд. */ /** В конце трека накладывает следующий на crossfade_s секунд. */
private maybeCrossfade(positionMs: number) { private maybeCrossfade(positionMs: number) {
const cf = this.crossfadeSeconds(); const cf = this.crossfadeSeconds();
if (this.fading || cf <= 0 || this.state.repeat === 'one') return; if (this.listening?.locked || this.fading || cf <= 0 || this.state.repeat === 'one') return;
const dur = this.audio.duration; const dur = this.audio.duration;
if (!Number.isFinite(dur)) return; if (!Number.isFinite(dur)) return;
const left = dur * 1000 - positionMs; const left = dur * 1000 - positionMs;
@@ -495,15 +514,16 @@ export class Player {
const next = this.state.queue[this.state.index + 1]; const next = this.state.queue[this.state.index + 1];
if (!next?.stream_url || !next.available) return; if (!next?.stream_url || !next.available) return;
this.fading = true; this.fading = true;
const version = ++this.fadeVersion;
this.offline?.keep([this.queue.current!.id, next.id]); this.offline?.keep([this.queue.current!.id, next.id]);
this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url; this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url;
this.overlap.volume = 0; this.overlap.volume = 0;
void this.overlap.play().catch(() => { void this.overlap.play().catch(() => {
this.fading = false; if (version === this.fadeVersion) this.cancelCrossfade();
}); });
const started = performance.now(); const started = performance.now();
const step = () => { const step = () => {
if (!this.fading) return; if (!this.fading || version !== this.fadeVersion) return;
const p = Math.min(1, (performance.now() - started) / (cf * 1000)); const p = Math.min(1, (performance.now() - started) / (cf * 1000));
this.applyLoudness(1 - p); this.applyLoudness(1 - p);
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p); this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
@@ -511,12 +531,12 @@ export class Player {
requestAnimationFrame(step); requestAnimationFrame(step);
return; return;
} }
this.finishCrossfade(next); this.finishCrossfade(next, version);
}; };
requestAnimationFrame(step); requestAnimationFrame(step);
} }
private finishCrossfade(next: Track) { private finishCrossfade(next: Track, version: number) {
const at = this.overlap.currentTime; const at = this.overlap.currentTime;
this.ignoreEnded = true; this.ignoreEnded = true;
this.audio.pause(); this.audio.pause();
@@ -545,6 +565,7 @@ export class Player {
this.media.updateMediaMetadata(next); this.media.updateMediaMetadata(next);
void this.offline?.save(next).catch(() => undefined); void this.offline?.save(next).catch(() => undefined);
void this.audio.play().finally(() => { void this.audio.play().finally(() => {
if (version !== this.fadeVersion) return;
this.fading = false; this.fading = false;
this.ignoreEnded = false; this.ignoreEnded = false;
}); });
@@ -690,6 +711,7 @@ export class Player {
this.activity = null; this.activity = null;
this.restoreVersion++; this.restoreVersion++;
this.wavePolicy.activate(null); this.wavePolicy.activate(null);
this.fadeVersion++;
this.fading = false; this.fading = false;
this.finishAttempt('skip'); this.finishAttempt('skip');
this.engine.dispose(); this.engine.dispose();
@@ -708,7 +730,7 @@ export class Player {
this.listening?.beginAudio(); this.listening?.beginAudio();
} }
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) { private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
if (!room.track_id) { this.audio.pause(); return; } if (!room.track_id) { this.cancelCrossfade(); this.audio.pause(); return; }
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) { if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } })); const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
if (!current()) return; if (!current()) return;
+15
View File
@@ -420,6 +420,21 @@
background: var(--hover); 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 { .menuDanger {
color: var(--danger); color: var(--danger);
} }
+9 -4
View File
@@ -149,21 +149,25 @@ export function Dialog({
onClose, onClose,
children, children,
footer, footer,
className,
}: { }: {
title: string; title: string;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
footer?: ReactNode; footer?: ReactNode;
className?: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const titleId = useId(); const titleId = useId();
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => { useEffect(() => {
const prev = document.activeElement as HTMLElement | null; const prev = document.activeElement as HTMLElement | null;
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])'); const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
(first ?? ref.current)?.focus(); (first ?? ref.current)?.focus();
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') closeRef.current();
if (e.key === 'Tab') { if (e.key === 'Tab') {
const controls = [...(ref.current?.querySelectorAll<HTMLElement>( const controls = [...(ref.current?.querySelectorAll<HTMLElement>(
'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]', 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
@@ -182,10 +186,10 @@ export function Dialog({
document.removeEventListener('keydown', onKey); document.removeEventListener('keydown', onKey);
prev?.focus(); prev?.focus();
}; };
}, [onClose]); }, []);
return createPortal( return createPortal(
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}> <div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}> <div ref={ref} className={cx(s.dialog, className)} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
<div className={s.dialogHead}> <div className={s.dialogHead}>
<h2 id={titleId}>{title}</h2> <h2 id={titleId}>{title}</h2>
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}> <button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
@@ -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. */ /** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
export function Menu({ export function Menu({
@@ -264,6 +268,7 @@ export function Menu({
> >
{it.icon && <Icon src={it.icon} size={18} />} {it.icon && <Icon src={it.icon} size={18} />}
{it.label} {it.label}
{it.badge ? <span className={s.menuBadge}>{it.badge > 99 ? '99+' : it.badge}</span> : null}
</button> </button>
))} ))}
</div> </div>
+24 -1
View File
@@ -1,4 +1,4 @@
import { fakeApi, makeUser } from './fakeApi'; import { albumTracks, fakeApi, makeUser } from './fakeApi';
import { mobileFixtures } from './mobileFixtures'; import { mobileFixtures } from './mobileFixtures';
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */ /** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
@@ -14,6 +14,29 @@ export function installE2EFetch() {
const alice = [...api.users.values()].find(u => u.username === 'alice')!; const alice = [...api.users.values()].find(u => u.username === 'alice')!;
const friend = [...api.users.values()].find(u => u.username === 'moderator')!; const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true }); api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
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); const orig = window.fetch.bind(window);
window.fetch = async (input, init) => { window.fetch = async (input, init) => {
const req = new Request(input, init); const req = new Request(input, init);
+6 -1
View File
@@ -260,8 +260,13 @@ export function fakeApi(initial: FakeUser[]) {
if(op==='join'||op==='invite'){ 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 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; 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'); 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); 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);} 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); if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id);
+21
View File
@@ -170,6 +170,22 @@
display: none; 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 { .navItem {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -295,6 +311,9 @@
.navGroupFolded { .navGroupFolded {
display: contents; display: contents;
} }
.navSubsection { min-height: 0; }
.navSubgroup > .navItem { margin-left: 0; padding-left: 0; border-left: 0; }
} }
.mobileBottom { display: none; } .mobileBottom { display: none; }
@@ -358,6 +377,8 @@
border: 0; pointer-events: none; border: 0; pointer-events: none;
} }
.mobileMenu .navSectionTitle { display: block; } .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; } .mobileMenu .navChevron { display: none; }
.content, .island .content { padding: 20px 16px 24px; } .content, .island .content { padding: 20px 16px 24px; }
} }
+53 -28
View File
@@ -2,7 +2,7 @@ import { queueIcon } from '../shared/ui/icons';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; 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 { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session'; import { useAuthState, useSession, useUser } from '../app/session';
@@ -38,7 +38,8 @@ import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox'; import { SearchBox } from './SearchBox';
import s from './AppShell.module.css'; import s from './AppShell.module.css';
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures'; import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
import { useSocialUnread, SocialProvider } from '../features/social/Social'; import { useSocialUnread, useSocialProfileMenu, SocialProvider } from '../features/social/Social';
import { MiniSocial } from '../features/social/MiniSocial';
import { ListeningBanner } from '../features/social/Listening'; import { ListeningBanner } from '../features/social/Listening';
type NavPath = type NavPath =
@@ -109,6 +110,8 @@ function NavSection({
children, children,
defaultFolded = false, defaultFolded = false,
mobile = false, mobile = false,
nested = false,
paths,
}: { }: {
id: string; id: string;
title: string; title: string;
@@ -116,12 +119,21 @@ function NavSection({
children: ReactNode; children: ReactNode;
defaultFolded?: boolean; defaultFolded?: boolean;
mobile?: 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(() => { const [folded, setFolded] = useState(() => {
if (active) return false;
if (readIds(FOLDED_KEY).includes(id)) return true; if (readIds(FOLDED_KEY).includes(id)) return true;
if (readIds(TOUCHED_KEY).includes(id)) return false; if (readIds(TOUCHED_KEY).includes(id)) return false;
return defaultFolded; return defaultFolded;
}); });
useEffect(() => {
if (active) setFolded(false);
}, [active, pathname]);
const toggle = () => { const toggle = () => {
const next = !folded; const next = !folded;
setFolded(next); setFolded(next);
@@ -137,15 +149,15 @@ function NavSection({
return ( return (
<> <>
{mobile ? ( {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> <span className={s.navSectionTitle}>{title}</span>
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null} {folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} /> <Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
</button> </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 { t } = useTranslation();
const user = useUser(); const user = useUser();
const socialUnread = useSocialUnread(); const socialUnread = useSocialUnread();
const socialProfile = useSocialProfileMenu();
const session = useSession(); const session = useSession();
const desktop = useDesktop(); const desktop = useDesktop();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -176,6 +189,7 @@ function Shell() {
const reviews = user.reviews_uploads; const reviews = user.reviews_uploads;
const pending = usePendingUploads(reviews); const pending = usePendingUploads(reviews);
const unseen = useUnseenUploads(staff); const unseen = useUnseenUploads(staff);
const uploadBadge = (reviews ? pending ?? 0 : 0) + (unseen ?? 0);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []); const closeMobile = useCallback(() => setMobileOpen(false), []);
const navigation = (mobile = 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 && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
{staff && ( {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} /> <NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && ( <NavSection mobile={mobile} nested id="admin-media" title={t('nav.admin_media')} paths={['/admin/library', '/admin/sources']} defaultFolded>
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} /> <NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
)} {user.role === 'admin' && (
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} /> <NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
<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} /> </NavSection>
{reviews && ( <NavSection mobile={mobile} nested id="admin-uploads" title={t('nav.admin_uploads')} badge={uploadBadge} paths={['/uploads', '/admin/uploads']} defaultFolded>
<NavLink <NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
to="/admin/uploads" <NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
label={t('nav.upload_queue')} {reviews && (
icon={inboxIcon} <NavLink
activeIcon={inboxFillIcon} to="/admin/uploads"
badge={pending} label={t('nav.upload_queue')}
/> icon={inboxIcon}
)} activeIcon={inboxFillIcon}
{(user.role === 'admin' || user.manages_updates) && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />} badge={pending}
{user.role === 'admin' && ( />
<> )}
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} /> </NavSection>
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} /> {(user.role === 'admin' || user.manages_updates) && (
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} /> <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> </NavSection>
)} )}
@@ -270,6 +292,7 @@ function Shell() {
</div> </div>
} }
items={[ items={[
...socialProfile.items,
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) }, { label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
...(!desktop ...(!desktop
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }] ? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
@@ -301,7 +324,9 @@ function Shell() {
</Dialog> </Dialog>
)} )}
{socialProfile.dialogs}
<ShellMain /> <ShellMain />
<MiniSocial />
<PlayerBar variant={island ? 'island' : 'bar'} /> <PlayerBar variant={island ? 'island' : 'bar'} />
</div> </div>
+22 -1
View File
@@ -259,7 +259,28 @@ a.source:hover {
} }
.play[aria-busy='true'] { .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 { .progress {
+3 -3
View File
@@ -143,7 +143,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
aria-busy={st.loading || undefined} aria-busy={st.loading || undefined}
disabled={locked} onClick={() => player.toggle()} 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>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}> <button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={26} /> <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()}> <button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
<Icon src={prevIcon} size={28} /> <Icon src={prevIcon} size={28} />
</button> </button>
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} disabled={locked} onClick={() => player.toggle()}> <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()}>
<Icon src={st.playing ? pauseIcon : playIcon} size={32} /> {st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={32} />}
</button> </button>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}> <button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={28} /> <Icon src={nextIcon} size={28} />
+2 -1
View File
@@ -3,4 +3,5 @@
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" 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;
+9
View File
@@ -14,6 +14,15 @@
## 2. Пользователи и роли ## 2. Пользователи и роли
В меню «Админка» пользователи доступны отдельным пунктом, остальные страницы собраны в подразделы:
- **Медиатека:** библиотека и хранилища.
- **Загрузки:** предложить треки, мои предложения и очередь на рассмотрение.
- **Система:** обновления, конфигурация, настройки и журнал.
На компьютере подразделы можно сворачивать; браузер запоминает выбор. Подраздел текущей страницы
раскрывается при переходе. На телефоне группы показаны заголовками в меню «Ещё». Видимость страниц зависит от прав.
«Администрирование → Пользователи». «Администрирование → Пользователи».
| Роль | Что может | | Роль | Что может |
+106
View File
@@ -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 остаются ограничениями проверки.
+14
View File
@@ -3,6 +3,12 @@
Социальные функции работают между пользователями одного сервера. В web и desktop Социальные функции работают между пользователями одного сервера. В web и desktop
открывается раздел «Друзья и общение», на Android — вкладка «Друзья». открывается раздел «Друзья и общение», на Android — вкладка «Друзья».
На ПК справа над плеером доступна мини-панель друзей и чата шириной до 600 px.
Она позволяет переписываться без ухода с текущей страницы, сворачивается с сохранением
черновика и открывает выбранную переписку в полном разделе. На экранах уже 1024 px
используется полный раздел общения.
## Поведение ## Поведение
- Поиск по логину и отображаемому имени начинается с двух символов, возвращает до 30 человек. - Поиск по логину и отображаемому имени начинается с двух символов, возвращает до 30 человек.
@@ -83,6 +89,14 @@ React Query; Android останавливает polling при уходе при
«Подключиться» отправляет запрос хозяину и оставляет управление у него. «Подключиться» отправляет запрос хозяину и оставляет управление у него.
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем. «Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину. Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
Комната резервируется на время ожидания подтверждения. До принятия запроса
слушатель не становится участником, а баннер совместного прослушивания не показывается.
Исходное устройство ведущего поддерживает резервирование, не прерывая свою музыку.
После успешной отправки окно закрывается; ожидающий запрос можно отменить в разделе общения.
После отмены или истечения последнего запроса исходное устройство освобождает
пустую комнату при следующем опросе, не прерывая свою музыку.
Повторный запрос уже подключённого слушателя не создаёт нового ожидания.
Второе устройство ведущего не включает звук автоматически и при закрытии не завершает стрим.
Настройки приёма запросов/приглашений и автоматического принятия независимы, Настройки приёма запросов/приглашений и автоматического принятия независимы,
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории. обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
Дополнительная настройка управляет запуском звука при подключении. Дополнительная настройка управляет запуском звука при подключении.
+44
View File
@@ -31,4 +31,48 @@ describe('shared listening',()=>{
it('pauses on connection loss and on room termination',async()=>{ 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(); 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();
});
}); });
+34 -7
View File
@@ -22,6 +22,7 @@ export class ListeningCoordinator {
private generation = 0; private generation = 0;
private sequence = 0; private sequence = 0;
private busy = false; private busy = false;
private refreshPending = false;
private closed = false; private closed = false;
private lastPoll = 0; private lastPoll = 0;
private lastSuccess = 0; private lastSuccess = 0;
@@ -46,9 +47,12 @@ export class ListeningCoordinator {
if (!room || !this.source(room)) return; if (!room || !this.source(room)) return;
const state = this.adapter.read(); const state = this.adapter.read();
const key = `${state.track?.id}:${state.playing}`; 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(); } allowJoining() { this.ignored.clear(); }
beginAudio() { if (this.snapshot?.room) this.enabled.add(this.snapshot.room.id); this.refresh(); } beginAudio() { if (this.snapshot?.room) this.enabled.add(this.snapshot.room.id); this.refresh(); }
leaveLocally() { 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.closed || this.busy || (!force && !this.snapshot?.room && this.now() - this.lastPoll < 10_000)) return;
if (this.session.state?.status !== 'signed_in') return; if (this.session.state?.status !== 'signed_in') return;
this.busy = true; this.lastPoll = this.now(); this.busy = true; this.lastPoll = this.now();
const requestGeneration = this.generation;
try { try {
const state = unwrap(await this.session.client.GET('/api/v1/me/social/listening')); 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(); this.lastSuccess = this.now();
const previous = this.snapshot?.room; const previous = this.snapshot?.room;
this.snapshot = state; 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 } }); await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'leave', target_id: room.id } });
return; 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)) { 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(); 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)) } })); 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; return;
} }
if (!this.locked) this.adapter.pause(); 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); this.emit(room, needsStart);
const version = this.generation; const version = this.generation;
const elapsed = room.playing ? Math.max(0, Date.parse(state.server_time) - Date.parse(room.updated_at)) : 0; 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 { } catch {
if (this.closed) return; if (this.closed) return;
if (this.locked && this.now() - this.lastSuccess > 10_000) { this.adapter.pause(); this.emit(this.snapshot?.room ?? null, false, true); } 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() { dispose() {
if (this.closed) return; if (this.closed) return;
@@ -104,7 +130,8 @@ export class ListeningCoordinator {
clients.get(this.session)?.delete(this); clients.get(this.session)?.delete(this);
const room = this.snapshot?.room; const room = this.snapshot?.room;
if (this.locked) this.adapter.pause(); 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);
} }
} }
+10
View File
@@ -50,6 +50,9 @@
"main": "Navigation", "main": "Navigation",
"home": "Home", "home": "Home",
"admin": "Admin", "admin": "Admin",
"admin_media": "Media library",
"admin_uploads": "Uploads",
"admin_system": "System",
"users": "Users", "users": "Users",
"audit": "Audit log", "audit": "Audit log",
"library": "Library", "library": "Library",
@@ -387,6 +390,13 @@
"no_results": "No playlists found" "no_results": "No playlists found"
}, },
"social": { "social": {
"notifications_all": "All",
"notifications_unread": "Unread",
"notifications_caught_up": "You’re all caught up",
"open_notification": "Open: {{name}}",
"mini_chat": "Friends and chat",
"open_full": "Open full view",
"minimize_chat": "Minimize chat",
"online": "Online", "online": "Online",
"offline": "Offline", "offline": "Offline",
"together_offline": "Listen together when your friend is online", "together_offline": "Listen together when your friend is online",
+1
View File
@@ -124,6 +124,7 @@
"target_changed": "The source changed after checking. Check for updates again." "target_changed": "The source changed after checking. Check for updates again."
}, },
"social": { "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", "person_offline": "This person is offline. Try inviting them again when they connect",
"invalid": "Check your message or action", "invalid": "Check your message or action",
"unavailable": "Chats are available to friends only. The friendship may have ended or the person is unavailable", "unavailable": "Chats are available to friends only. The friendship may have ended or the person is unavailable",
+10
View File
@@ -50,6 +50,9 @@
"main": "Навигация", "main": "Навигация",
"home": "Главная", "home": "Главная",
"admin": "Админка", "admin": "Админка",
"admin_media": "Медиатека",
"admin_uploads": "Загрузки",
"admin_system": "Система",
"users": "Пользователи", "users": "Пользователи",
"audit": "Журнал", "audit": "Журнал",
"library": "Библиотека", "library": "Библиотека",
@@ -387,6 +390,13 @@
"no_results": "Плейлисты не найдены" "no_results": "Плейлисты не найдены"
}, },
"social": { "social": {
"notifications_all": "Все",
"notifications_unread": "Непрочитанные",
"notifications_caught_up": "Всё прочитано",
"open_notification": "Открыть: {{name}}",
"mini_chat": "Друзья и чат",
"open_full": "Открыть раздел",
"minimize_chat": "Свернуть чат",
"online": "В сети", "online": "В сети",
"offline": "Не в сети", "offline": "Не в сети",
"together_offline": "Слушать вместе можно, когда друг в сети", "together_offline": "Слушать вместе можно, когда друг в сети",
+1
View File
@@ -124,6 +124,7 @@
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз." "target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
}, },
"social": { "social": {
"listening_unavailable": "Совместное прослушивание недоступно. Пользователь не принимает запросы или приглашения от вас либо больше недоступен. Настройки приёма находятся в меню профиля → «Приватность и общение».",
"person_offline": "Пользователь не в сети. Повторите приглашение, когда он подключится", "person_offline": "Пользователь не в сети. Повторите приглашение, когда он подключится",
"invalid": "Проверьте сообщение или действие", "invalid": "Проверьте сообщение или действие",
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен", "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
}
+13 -1
View File
@@ -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) }), " ") 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. // Exact normalized identity; retain version qualifiers such as live/remix.
func matchSong(songs []Song, artist, title string) (Song, bool) { func matchSong(songs []Song, artist, title string) (Song, bool) {
var matched Song var matched Song
for _, s := range songs { 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 { if matched.ID != 0 && matched.ID != s.ID {
return Song{}, false return Song{}, false
} }
@@ -95,3 +95,25 @@ func TestAnnotations(t *testing.T) {
require.True(t, notes[0].Verified) require.True(t, notes[0].Verified)
require.Equal(t, "https://genius.com/annotations/1", notes[0].Url) 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)
}
+3
View File
@@ -48,6 +48,9 @@ func (s *Service) Run(ctx context.Context) {
if err := s.drain(ctx); err != nil && ctx.Err() == nil { if err := s.drain(ctx); err != nil && ctx.Err() == nil {
s.log.Error("genius worker", "err", err) 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 { select {
case <-ctx.Done(): case <-ctx.Done():
return 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: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: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"}, 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", "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.Equal(t, []string{"main:A"}, names(SplitArtists("A / a", nil)), "duplicates by key are dropped")
assert.Empty(t, SplitArtists(" ", nil)) 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) { for _, part := range splitAny(main, artistSeparators) {
if strings.Contains(part, " & ") && (known == nil || !known(Key(part))) { for _, seg := range splitComma(part) {
for _, p := range strings.Split(part, " & ") { if strings.Contains(seg, " & ") && (known == nil || !known(Key(seg))) {
add(p, "main") for _, p := range strings.Split(seg, " & ") {
add(p, "main")
}
continue
} }
continue add(seg, "main")
} }
add(part, "main")
} }
for _, part := range splitAny(feat, append(artistSeparators, " & ", ", ")) { for _, part := range splitAny(feat, append(artistSeparators, " & ", ", ")) {
add(part, "feat") add(part, "feat")
@@ -99,6 +101,20 @@ func SplitArtists(s string, known func(key string) bool) []ArtistCredit {
return out 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 { func splitAny(s string, seps []string) []string {
parts := []string{s} parts := []string{s}
for _, sep := range seps { for _, sep := range seps {
+11 -3
View File
@@ -422,7 +422,13 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
if m.Album == "" { if m.Album == "" {
m.Album = UnknownAlbum 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 { if err != nil {
return false, err return false, err
} }
@@ -437,7 +443,9 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
if err != nil { if err != nil {
return false, err return false, err
} }
if !dup { // Тот же файл, перечитанный в тот же трек, заново разбирает исполнителей: правила разбора меняются.
sameTrack := dup && r.oldTrack != nil && *r.oldTrack == trackID
if !dup || sameTrack {
// Перечитанный файл сохраняет свой трек (лайки, история); новый — получает новый. // Перечитанный файл сохраняет свой трек (лайки, история); новый — получает новый.
if r.oldTrack != nil { if r.oldTrack != nil {
trackID = *r.oldTrack 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 { if trackID, err = sc.store.SaveTrack(ctx, trackID, in); err != nil {
return false, err 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)) ids := make([]uuid.UUID, 0, len(credits))
roles := make([]string, 0, len(credits)) roles := make([]string, 0, len(credits))
for _, c := range credits { for _, c := range credits {
@@ -85,6 +85,18 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
require.NoError(t, s.publishActivity(ctx, owner, activity)) require.NoError(t, s.publishActivity(ctx, owner, activity))
join := gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner} join := gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}
require.Error(t, s.listeningAction(ctx, friend, join)) // default nobody 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) _, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='everyone' WHERE id=$1`, owner)
require.NoError(t, e) require.NoError(t, e)
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner})) 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.NotNil(t, state.Room)
require.Equal(t, owner, state.Room.OwnerId) require.Equal(t, owner, state.Room.OwnerId)
require.Len(t, state.Room.Participants, 2) 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}) hostctx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner})
badctx := auth.WithPrincipal(ctx, auth.Principal{UserID: friend}) 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} 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.NoError(t, e)
require.Len(t, state.Requests, 1) require.Len(t, state.Requests, 1)
require.Nil(t, state.Room) 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.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})) require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other) state, e = s.listeningState(ctx, other)
require.NoError(t, e) require.NoError(t, e)
require.Equal(t, owner, state.Room.OwnerId) 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) _, e = pool.Exec(ctx, `UPDATE listening_rooms SET updated_at=now()-interval '31 seconds' WHERE id=$1`, state.Room.Id)
require.NoError(t, e) require.NoError(t, e)
state, e = s.listeningState(ctx, other) state, e = s.listeningState(ctx, other)
+16 -6
View File
@@ -17,6 +17,9 @@ func audienceAllows(policy string, friends bool) bool {
func validAudience(v gen.ActivityVisibility) bool { func validAudience(v gen.ActivityVisibility) bool {
return v == "nobody" || v == "friends" || v == "everyone" 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) { func (h *Handler) SetListeningPreferences(ctx context.Context, r gen.SetListeningPreferencesRequestObject) (gen.SetListeningPreferencesResponseObject, error) {
id, e := userID(ctx) id, e := userID(ctx)
if e != nil { if e != nil {
@@ -75,7 +78,7 @@ func (h *Handler) PublishListeningState(ctx context.Context, r gen.PublishListen
return nil, e return nil, e
} }
if !own { if !own {
return nil, denied() return nil, listeningDenied()
} }
} }
return gen.PublishListeningState204Response{}, nil 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') 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) FROM users WHERE id=$1`, recipient, sender).Scan(&policy, &automatic, &friends, &blocked, &online)
if errors.Is(e, pgx.ErrNoRows) { if errors.Is(e, pgx.ErrNoRows) {
return false, false, denied() return false, false, listeningDenied()
} }
if e != nil { if e != nil {
return false, false, e return false, false, e
@@ -164,7 +167,7 @@ func admitListening(ctx context.Context, tx pgx.Tx, request, room, guest, recipi
return e return e
} }
if !allowed { 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) 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 { if e != nil {
@@ -199,7 +202,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
return e return e
} }
if a.Action == "end" && owner != id { if a.Action == "end" && owner != id {
return denied() return listeningDenied()
} }
if owner == id { if owner == id {
_, e = tx.Exec(ctx, `DELETE FROM listening_rooms WHERE id=$1`, a.TargetId) _, 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 return e
} }
if (a.Action == "cancel" && sender != id) || (a.Action != "cancel" && recipient != id) { if (a.Action == "cancel" && sender != id) || (a.Action != "cancel" && recipient != id) {
return denied() return listeningDenied()
} }
if status != "pending" { if status != "pending" {
return nil return nil
@@ -257,7 +260,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
return err return err
} }
if !permitted { if !permitted {
return denied() return listeningDenied()
} }
var repeated bool 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 { 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 { if busy {
return apperr.Conflict("social.listening_busy", "already in another room") 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 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 { 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 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;