diff --git a/apps/web/e2e/mini-social.spec.ts b/apps/web/e2e/mini-social.spec.ts index 51dbddd..40b468b 100644 --- a/apps/web/e2e/mini-social.spec.ts +++ b/apps/web/e2e/mini-social.spec.ts @@ -1,8 +1,9 @@ +import axe from 'axe-core'; import { expect, test } from '@playwright/test'; -for (const width of [1280, 1920]) { - test(`mini chat keeps the page and draft at ${width}px`, async ({ page }, info) => { - await page.setViewportSize({ width, height: 900 }); +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'); @@ -15,11 +16,13 @@ for (const width of [1280, 1920]) { 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('Черновик из мини-чата'); @@ -40,6 +43,17 @@ for (const width of [1280, 1920]) { 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'); @@ -48,7 +62,7 @@ for (const width of [1280, 1920]) { await expect(composer).toHaveValue('Продолжить в полном чате'); await page.setViewportSize({ width: 800, height: 900 }); await expect(dock).toHaveCount(0); - await page.setViewportSize({ width, height: 900 }); + await page.setViewportSize({ width, height }); await expect(composer).toHaveValue('Продолжить в полном чате'); await panel.getByRole('button', { name: 'Открыть раздел', exact: true }).click(); await expect(page).toHaveURL(/\/friends\?/); diff --git a/apps/web/e2e/notifications.spec.ts b/apps/web/e2e/notifications.spec.ts new file mode 100644 index 0000000..072304c --- /dev/null +++ b/apps/web/e2e/notifications.spec.ts @@ -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(); + }); +} diff --git a/apps/web/e2e/social-profile.spec.ts b/apps/web/e2e/social-profile.spec.ts new file mode 100644 index 0000000..2b6fe81 --- /dev/null +++ b/apps/web/e2e/social-profile.spec.ts @@ -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); + }); +} diff --git a/apps/web/e2e/social.spec.ts b/apps/web/e2e/social.spec.ts index ad448b6..a645e8a 100644 --- a/apps/web/e2e/social.spec.ts +++ b/apps/web/e2e/social.spec.ts @@ -65,9 +65,11 @@ for (const width of [320, 800, 1280]) { await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click(); await page.setViewportSize({ width, height: 560 }); } - const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!; - const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!; - expect(composerBox.y + composerBox.height).toBeLessThanOrEqual(playerBox.y); + await expect.poll(async () => { + const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!; + const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!; + return composerBox.y + composerBox.height - playerBox.y; + }).toBeLessThanOrEqual(0); await expect.poll(() => page.evaluate(() => { const main = document.querySelector('main')!; return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth); diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 03878e5..7c7309e 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -163,7 +163,7 @@ describe('social', () => { await user.click(screen.getByRole('button', { name: 'Прочитано' })); await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument()); expect(api.notifications[0]?.read).toBe(true); - await user.click(screen.getByRole('button', { name: 'Открыть' })); + 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 }); diff --git a/apps/web/src/features/social/MiniSocial.tsx b/apps/web/src/features/social/MiniSocial.tsx index a6471de..bcfba44 100644 --- a/apps/web/src/features/social/MiniSocial.tsx +++ b/apps/web/src/features/social/MiniSocial.tsx @@ -6,7 +6,8 @@ import { chatIcon, closeIcon, groupIcon, searchIcon } from '../../shared/ui/icon import { useErrorText } from '../../shared/lib/errors'; import { ChatConversation } from './ChatConversation'; import { Presence } from './Presence'; -import { useSocial, useSocialDialogOpen } from './Social'; +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. */ diff --git a/apps/web/src/features/social/NotificationsDialog.tsx b/apps/web/src/features/social/NotificationsDialog.tsx new file mode 100644 index 0000000..61bb7aa --- /dev/null +++ b/apps/web/src/features/social/NotificationsDialog.tsx @@ -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 + + {read.error || inbox.error ? errorText(read.error ?? inbox.error) : null} +
+ {inbox.isPending &&
{[0, 1, 2].map(value => )}
} + {inbox.isSuccess && !shown.length &&
{t(unreadOnly ? 'social.notifications_caught_up' : 'social.no_notifications')}
} + {shown.map(event => { + const date = new Date(event.created_at); + return
+ + {!event.read && } +
; + })} +
+
; +} diff --git a/apps/web/src/features/social/Social.tsx b/apps/web/src/features/social/Social.tsx index 4ba48ae..73e9a33 100644 --- a/apps/web/src/features/social/Social.tsx +++ b/apps/web/src/features/social/Social.tsx @@ -1,10 +1,8 @@ -import { socialApi, uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core'; +import { uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link, useNavigate } from '@tanstack/react-router'; -import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { useSession, useUser } from '../../app/session'; -import { useDesktop } from '../../desktop'; import { useErrorText } from '../../shared/lib/errors'; import { Button, Dialog, EmptyState, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui'; import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldIcon, visibilityOffIcon } from '../../shared/ui/icons'; @@ -12,20 +10,8 @@ import s from './social.module.css'; import { ListeningPanel, ListenTogetherButton } from './Listening'; import { ChatConversation } from './ChatConversation'; import { Presence } from './Presence'; - -const scopes = new WeakMap(); -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 }; -} +import { useSocial } from './queries'; +import { NotificationsDialog } from './NotificationsDialog'; export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; } @@ -80,12 +66,13 @@ function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) } export function useSocialProfileMenu() { - const { t, i18n } = useTranslation(); + 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 ?? []; @@ -102,23 +89,13 @@ export function useSocialProfileMenu() { { label: `${t('social.notifications')}${inbox.data?.unread ? ` ${inbox.data.unread}` : ''}`, icon: inboxIcon, onSelect: () => setDialog('notifications') }, ], dialogs: <> - {dialog === 'privacy' && setDialog(null)}> -
{t('social.activity_visibility')}
{(['nobody', 'friends', 'everyone'] as const).map(v => )}

{t('social.activity_privacy_hint')}

+ {dialog === 'privacy' && +
{t('social.activity_visibility')}
{(['nobody', 'friends', 'everyone'] as const).map(v => )}

{t('social.activity_privacy_hint')}

{visibility.error || action.error || inbox.error ? errorText(visibility.error ?? action.error ?? inbox.error) : null} {people.some(p => p.relationship === 'blocked') &&

{t('social.blocked_people')}

{people.filter(p => p.relationship === 'blocked').map(p => {}} act={op => action.mutate({ action: op, target_id: p.id })} />)}
}
} - {dialog === 'notifications' && setDialog(null)}> - {inbox.error || action.error ? errorText(inbox.error ?? action.error) : null} -
- {inbox.data?.notifications.length === 0 && {t('social.no_notifications')}} - {inbox.data?.notifications.map(n =>
- {n.actor_name.slice(0, 1).toUpperCase()} -
{n.actor_name}

{t(`social.kind_${n.kind}`)}

-
{!n.read && }
-
)} -
-
} + {dialog === 'notifications' && } , }; } diff --git a/apps/web/src/features/social/notifications.module.css b/apps/web/src/features/social/notifications.module.css new file mode 100644 index 0000000..75ee9d3 --- /dev/null +++ b/apps/web/src/features/social/notifications.module.css @@ -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; } } diff --git a/apps/web/src/features/social/queries.ts b/apps/web/src/features/social/queries.ts new file mode 100644 index 0000000..4c506e6 --- /dev/null +++ b/apps/web/src/features/social/queries.ts @@ -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(); +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 }; +} + diff --git a/apps/web/src/features/social/social.module.css b/apps/web/src/features/social/social.module.css index 2232e71..d1e6e58 100644 --- a/apps/web/src/features/social/social.module.css +++ b/apps/web/src/features/social/social.module.css @@ -17,7 +17,7 @@ .listSearch:focus-within { border-color: var(--accent); } .listSearch input { color: var(--text); font: inherit; font-size: 13px; width: 100%; min-width: 0; background: transparent; border: 0; height: 44px; } .listSearch input::placeholder { color: var(--text3); } -.listSearch input:focus-visible { outline-offset: 4px; } +.listSearch input:focus-visible { outline: none; } .dialogRow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 12px; text-align: left; border-radius: 12px; margin-bottom: 4px; transition: background .18s, transform .18s; } .dialogRow:hover { background: var(--hover); } .dialogRow:active { transform: scale(.985); } @@ -33,7 +33,7 @@ .presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; } .presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; } .presenceOnline .presenceDot { background: var(--ok); } -.directory, .events { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; } +.directory { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; } .directory .peopleList { padding: 0; overflow: visible; } .directory .listSearch { max-width: 440px; margin-left: 0; } .person { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; padding: 20px 4px; border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; } @@ -53,17 +53,29 @@ .welcomeIcon { color: var(--accent2); background: var(--accent-soft); display: grid; place-items: center; width: 80px; height: 80px; border-radius: 24px; margin-bottom: 8px; } .welcome h2 { font-size: 22px; letter-spacing: -.025em; margin: 0; } .welcome p { color: var(--text2); max-width: 280px; margin: 0 0 8px; line-height: 1.7; } -.events h2 { font-size: 18px; margin: 8px 0 20px; } -.event { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 18px 12px; border-bottom: 1px solid var(--line); border-radius: 10px; } -.eventBody { flex: 1; min-width: 100px; overflow-wrap: anywhere; } -.eventBody p { color: var(--text2); font-size: 13px; margin: 4px 0; } -.event.unread { background: var(--accent-soft); } .privacy { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 0 0 16px; min-width: 0; } .privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; } .privacy summary { cursor: pointer; font-weight: 600; } .privacy p { margin-bottom: 0; } .privacy .privacy { margin: 12px 0; } -.choice { display: flex; gap: 10px; padding: 12px 0; align-items: center; } +.privacy input { accent-color: var(--accent); } +.preferencesDialog { width: 560px; max-height: min(720px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); } +.preferencesDialog > :first-child { padding: 20px 24px 16px; flex-shrink: 0; border-bottom: 1px solid var(--line); } +.preferencesDialog > :nth-child(2) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; } +.preferencesDialog fieldset.privacy { border: 0; padding: 0; margin: 0; } +.preferencesDialog .privacy legend { padding: 0; margin-bottom: 12px; color: var(--text); font-weight: 600; } +.preferencesDialog .privacy .actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); } +.preferencesDialog .privacy .actions button { min-width: 0; padding: 0 8px; border-radius: 8px; min-height: 36px; } +.preferencesDialog details.privacy { padding: 16px; margin: 8px 0 0; border: 1px solid var(--line); background: var(--surface2); } +.preferencesDialog details.privacy > summary { line-height: 1.5; } +.preferencesDialog details.privacy fieldset { margin: 20px 0; } +.preferencesDialog details.privacy > label { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.5; } +.preferencesDialog details.privacy > label input { margin-top: 3px; } +.choice { display: flex; gap: 12px; padding: 14px; align-items: center; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; background: var(--surface2); font-size: 13px; line-height: 1.5; } +.choice + .choice { margin-top: 8px; } +.choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); } +.choice:has(input:disabled) { opacity: .5; cursor: not-allowed; } +.choice input { flex-shrink: 0; accent-color: var(--accent); } .stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; } .chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); animation: conversationIn .24s cubic-bezier(.2,.8,.2,1); } .chatHeader { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; } @@ -94,7 +106,7 @@ .messageText { white-space: pre-wrap; user-select: text; line-height: 1.65; font-size: 14px; min-width: 0; } .messageText a, .attachmentCaption a { color: var(--accent2); } .messageFoot { display: flex; align-items: center; justify-content: end; gap: 10px; min-height: 30px; } -.messageFoot small { font-size: 10px; color: var(--text3); } +.messageFoot small { font-size: 11px; color: var(--text2); } .messageFoot button { width: 28px; height: 28px; min-width: 28px; padding: 0; border-radius: 8px; color: var(--text3); } .messageFoot button:hover { color: var(--text); background: var(--hover); } .quote { margin: 0 0 2px; padding: 6px 10px; border-left: 2px solid var(--accent2); border-radius: 0 6px 6px 0; background: var(--hover); color: var(--text2); font-size: 12px; max-height: 72px; overflow: hidden; } @@ -131,7 +143,7 @@ .musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; } .musicMessages .musicCard { width: 100%; } .miniDock { grid-area: content; align-self: end; justify-self: end; z-index: 25 !important; display: flex; flex-direction: column; align-items: end; gap: 12px; width: min(600px, calc(100% - 32px)); min-height: 0; margin: 16px; pointer-events: none; } -.miniPanel { pointer-events: auto; width: 100%; height: min(640px, calc(100dvh - 250px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line2); border-radius: 18px; background: var(--surface); box-shadow: 0 16px 64px #0008; overflow: hidden; animation: panelIn .2s ease-out; } +.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; } @@ -186,9 +198,9 @@ .chatTabs { padding: 0 14px; gap: 18px; } .chatTabs button { min-height: 44px; font-size: 11px; } .nowListening { padding: 8px 14px; } .messages { padding: 12px; gap: 10px; } .bubble { max-width: 92%; font-size: 14px; padding: 9px 12px 4px; } .composer { gap: 4px; padding: 10px 8px; } .composer textarea { font-size: 16px; padding: 10px; } .composer .iconButton { min-width: 40px; width: 40px; height: 44px; } - .directory, .events { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; } + .directory { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; } .musicCard { gap: 8px; } .musicCard > :first-child { width: 40px !important; height: 40px !important; } .musicCard .iconButton { min-width: 32px; width: 32px; } .musicInfo a { font-size: 13px; } .chatSearch { padding: 10px 12px; gap: 6px; } .replyBar { padding: 8px 12px; } .privacy { padding: 12px; } .privacy .actions { gap: 4px; } .privacy .actions button { padding: 0 12px; } } @media(pointer: coarse) { .chatTools button, .messageFoot button, .reactions button { min-height: 44px; } } -@media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .events, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } } +@media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } } diff --git a/apps/web/src/player/listening.test.ts b/apps/web/src/player/listening.test.ts new file mode 100644 index 0000000..7d62d61 --- /dev/null +++ b/apps/web/src/player/listening.test.ts @@ -0,0 +1,102 @@ +import type { AuthSession, ListeningState, Track } from '@pipu/core'; +import { refreshListening } from '@pipu/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Player } from './player'; + +const track: Track = { + id: 'local', title: 'Local', duration_ms: 200_000, artists: [], + album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] }, + genres: [], disc_no: null, track_no: null, available: true, stream_url: '/s/local', reaction: null, features: null, +}; +const remote = { ...track, id: 'remote', stream_url: '/s/remote' }; +const ok = (data: unknown) => ({ data, response: new Response() }); +const players: Player[] = []; + +function fixture() { + let snapshot: ListeningState = { room: null, requests: [], preferences: { requests: 'friends', auto_accept: 'friends', autoplay: true }, server_time: new Date().toISOString() }; + const session = { + state: { status: 'signed_in', user: { id: 'guest', ui_prefs: { crossfade_s: 6, normalize: false } } }, + client: { + GET: vi.fn(async (path: string) => ok(path === '/api/v1/me/social/listening' ? snapshot : remote)), + PUT: vi.fn(async () => ok({})), POST: vi.fn(async () => ok({})), + }, + } as unknown as AuthSession; + const audio = document.createElement('audio'); + const overlap = document.createElement('audio'); + vi.spyOn(globalThis, 'Audio').mockImplementation(function () { return overlap; }); + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frames.push(callback); return frames.length; }); + const clock = vi.spyOn(performance, 'now').mockReturnValue(0); + const player = new Player(session, audio); + players.push(player); + const startFade = () => { + player.playList([track, { ...track, id: 'next', stream_url: '/s/next' }], 0, { type: 'queue' }); + Object.defineProperty(audio, 'duration', { configurable: true, value: 200 }); + audio.currentTime = 196; + audio.dispatchEvent(new Event('timeupdate')); + clock.mockReturnValue(3000); + frames.shift()!(3000); + expect(overlap.paused).toBe(false); + expect(overlap.volume).toBeGreaterThan(0); + }; + const follow = async () => { + snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [] } }; + player.start(); + await vi.waitFor(() => expect(player.getState()).toMatchObject({ track: { id: 'remote' }, playing: true, listening: { locked: true } })); + }; + return { player, audio, overlap, frames, clock, startFade, follow, session, setRoom: (room: ListeningState['room']) => { snapshot = { ...snapshot, room }; } }; +} + +afterEach(() => { players.splice(0).forEach(player => player.dispose()); vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); }); + +describe('shared listening audio ownership', () => { + it('stops a local crossfade before following and ignores its queued animation', async () => { + const e = fixture(); + e.startFade(); + const staleFrame = e.frames.shift()!; + await e.follow(); + expect(e.audio.paused).toBe(false); + expect(e.overlap.paused).toBe(true); + expect(e.overlap.getAttribute('src')).toBeNull(); + staleFrame(6000); + expect(e.player.getState().track?.id).toBe('remote'); + expect(e.overlap.paused).toBe(true); + e.setRoom(null); + refreshListening(e.session); + await vi.waitFor(() => expect(e.player.getState().listening?.room).toBeNull()); + expect(e.audio.paused).toBe(true); + expect(e.overlap.paused).toBe(true); + }); + + it('silences the overlapping element on pause', () => { + const e = fixture(); + e.startFade(); + e.player.pause(); + expect(e.audio.paused).toBe(true); + expect(e.overlap.paused).toBe(true); + }); + + it('cancels an old transition when another track is selected', () => { + const e = fixture(); + e.startFade(); + const staleFrame = e.frames.shift()!; + e.player.playList([remote], 0, { type: 'queue' }); + e.clock.mockReturnValue(6000); + staleFrame(6000); + expect(e.audio.getAttribute('src')).toBe('/s/remote'); + expect(e.player.getState().track?.id).toBe('remote'); + expect(e.overlap.paused).toBe(true); + }); + + it('still completes an uninterrupted local crossfade with one active source', async () => { + const e = fixture(); + e.startFade(); + e.clock.mockReturnValue(6000); + e.frames.shift()!(6000); + await Promise.resolve(); + expect(e.player.getState().track?.id).toBe('next'); + expect(e.audio.getAttribute('src')).toBe('/s/next'); + expect(e.audio.paused).toBe(false); + expect(e.overlap.paused).toBe(true); + }); +}); diff --git a/apps/web/src/player/player.ts b/apps/web/src/player/player.ts index 3af10ea..7a6d0fc 100644 --- a/apps/web/src/player/player.ts +++ b/apps/web/src/player/player.ts @@ -49,6 +49,7 @@ export class Player { private get audio() { return this.engine.audio; } private get overlap() { return this.engine.overlap; } private fading = false; + private fadeVersion = 0; private ignoreEnded = false; private readonly queue = new PlayQueue(); private readonly events: EventBuffer; @@ -106,7 +107,7 @@ export class Player { if (!this.listening) { this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, { read: this.getState, - pause: () => { this.restoreVersion++; this.leaveWave(); this.audio.pause(); }, + pause: () => { this.restoreVersion++; this.leaveWave(); this.cancelCrossfade(); this.audio.pause(); }, apply: (room, position, current) => this.syncListening(room, position, current), changed: listening => this.update({ listening }), }); @@ -291,6 +292,7 @@ export class Player { pause() { if (this.listening?.locked && !this.applyingSync) return; + this.cancelCrossfade(); this.audio.pause(); } @@ -332,6 +334,7 @@ export class Player { const track = this.queue.current; if (!track) return; const to = Math.min(Math.max(ms, 0), track.duration_ms); + this.cancelCrossfade(); const from = Math.round(this.audio.currentTime * 1000); this.attempt?.seek(to); this.audio.currentTime = to / 1000; @@ -398,6 +401,7 @@ export class Player { // --- загрузка трека --- private async load(autoplay: boolean, startMs = 0, current = () => true) { + this.cancelCrossfade(); const track = this.queue.current; if (!track?.stream_url) return; const version = this.restoreVersion; @@ -484,10 +488,25 @@ export class Player { this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix); } + /** A cancelled transition must not keep a second source audible or change the new queue. */ + private cancelCrossfade() { + this.fadeVersion++; + const wasFading = this.fading; + this.fading = false; + this.ignoreEnded = false; + this.overlap.pause(); + this.overlap.volume = 0; + if (this.overlap.hasAttribute('src')) { + this.overlap.removeAttribute('src'); + this.overlap.load(); + } + if (wasFading) this.applyLoudness(); + } + /** В конце трека накладывает следующий на crossfade_s секунд. */ private maybeCrossfade(positionMs: number) { const cf = this.crossfadeSeconds(); - if (this.fading || cf <= 0 || this.state.repeat === 'one') return; + if (this.listening?.locked || this.fading || cf <= 0 || this.state.repeat === 'one') return; const dur = this.audio.duration; if (!Number.isFinite(dur)) return; const left = dur * 1000 - positionMs; @@ -495,15 +514,16 @@ export class Player { const next = this.state.queue[this.state.index + 1]; if (!next?.stream_url || !next.available) return; this.fading = true; + const version = ++this.fadeVersion; this.offline?.keep([this.queue.current!.id, next.id]); this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url; this.overlap.volume = 0; void this.overlap.play().catch(() => { - this.fading = false; + if (version === this.fadeVersion) this.cancelCrossfade(); }); const started = performance.now(); const step = () => { - if (!this.fading) return; + if (!this.fading || version !== this.fadeVersion) return; const p = Math.min(1, (performance.now() - started) / (cf * 1000)); this.applyLoudness(1 - p); this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p); @@ -511,12 +531,12 @@ export class Player { requestAnimationFrame(step); return; } - this.finishCrossfade(next); + this.finishCrossfade(next, version); }; requestAnimationFrame(step); } - private finishCrossfade(next: Track) { + private finishCrossfade(next: Track, version: number) { const at = this.overlap.currentTime; this.ignoreEnded = true; this.audio.pause(); @@ -545,6 +565,7 @@ export class Player { this.media.updateMediaMetadata(next); void this.offline?.save(next).catch(() => undefined); void this.audio.play().finally(() => { + if (version !== this.fadeVersion) return; this.fading = false; this.ignoreEnded = false; }); @@ -690,6 +711,7 @@ export class Player { this.activity = null; this.restoreVersion++; this.wavePolicy.activate(null); + this.fadeVersion++; this.fading = false; this.finishAttempt('skip'); this.engine.dispose(); @@ -708,7 +730,7 @@ export class Player { this.listening?.beginAudio(); } private async syncListening(room: ListeningRoom, position: number, current: () => boolean) { - if (!room.track_id) { this.audio.pause(); return; } + if (!room.track_id) { this.cancelCrossfade(); this.audio.pause(); return; } if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) { const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } })); if (!current()) return; diff --git a/apps/web/src/shared/ui/ui.tsx b/apps/web/src/shared/ui/ui.tsx index bf07272..1771412 100644 --- a/apps/web/src/shared/ui/ui.tsx +++ b/apps/web/src/shared/ui/ui.tsx @@ -149,21 +149,25 @@ export function Dialog({ onClose, children, footer, + className, }: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode; + className?: string; }) { const { t } = useTranslation(); const ref = useRef(null); const titleId = useId(); + const closeRef = useRef(onClose); + closeRef.current = onClose; useEffect(() => { const prev = document.activeElement as HTMLElement | null; const first = ref.current?.querySelector('input, select, button:not([data-close])'); (first ?? ref.current)?.focus(); const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + if (e.key === 'Escape') closeRef.current(); if (e.key === 'Tab') { const controls = [...(ref.current?.querySelectorAll( 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]', @@ -182,10 +186,10 @@ export function Dialog({ document.removeEventListener('keydown', onKey); prev?.focus(); }; - }, [onClose]); + }, []); return createPortal(
e.target === e.currentTarget && onClose()}> -
+

{title}