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