Redesign social conversations and shared music
ci / api / api (push) Successful in 19m12s
ci / ts / ts (packages + web) (push) Successful in 2m44s
ci / module boundaries / boundaries (push) Successful in 18m23s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 59s
ci / db / db (push) Failing after 18s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s

This commit is contained in:
mrleo1nid committed 2026-10-23 11:27:47 +03:00
1 parent 11a16cd023
commit 1a693848f2
23 files changed
+1145 -579

No files matched your search

+16
View File
@@ -194,6 +194,10 @@ paths:
- in: query
name: q
schema: {type: string, maxLength: 100}
- in: query
name: music
description: Only non-deleted track and playlist attachments, with the same history pagination.
schema: {type: boolean, default: false}
responses:
'200':
description: Message page
@@ -2361,6 +2365,18 @@ components:
relationship: {type: string, enum: [none, incoming, outgoing, friend, blocked]}
unread: {type: integer}
activity: {$ref: '#/components/schemas/ListeningActivity'}
last_message: {$ref: '#/components/schemas/SocialMessagePreview'}
SocialMessagePreview:
type: object
description: Latest message in a current friendship, without marking it read. Deleted content is never returned.
required: [id, sender_id, text, attachment_kind, created_at, deleted]
properties:
id: {type: string, format: uuid}
sender_id: {type: string, format: uuid}
text: {type: string}
attachment_kind: {type: string, enum: [none, track, playlist]}
created_at: {type: string, format: date-time}
deleted: {type: boolean}
SocialNotification:
type: object
required: [id, actor_id, actor_name, kind, created_at, read]
+28 -5
View File
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
for (const width of [320, 800, 1280]) {
test(`friends, messages and sharing at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.emulateMedia({ reducedMotion: width === 1280 ? 'no-preference' : 'reduce' });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
if (width === 800) await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { sessionStorage.setItem('shared-url', data.url ?? ''); } }));
await page.goto('/login');
@@ -16,7 +16,8 @@ for (const width of [320, 800, 1280]) {
};
await openFriends();
if (width === 800) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
await page.getByRole('button', { name: 'Написать', exact: true }).click();
await page.getByRole('button', { name: /moderator Начать разговор/ }).click();
await expect(page.getByRole('region', { name: 'Написать', exact: true })).toHaveCSS('animation-name', width === 1280 ? /conversationIn/ : 'none');
if (width === 800) expect((await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!.width).toBeGreaterThan(180);
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill('Привет! Нашёл музыку для нашей поездки 🎵');
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
@@ -41,13 +42,35 @@ for (const width of [320, 800, 1280]) {
await page.screenshot({ path: info.outputPath('share-music.png') });
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
await openFriends();
await page.getByRole('button', { name: 'Написать', exact: true }).click();
await expect(page.getByRole('link', { name: 'Открыть трек ↗', exact: true })).toBeVisible();
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Музыка из переписки', exact: true }).click();
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveCount(0);
await page.screenshot({ path: info.outputPath('shared-music.png'), fullPage: true });
await page.getByRole('button', { name: 'Переписка', exact: true }).click();
const longMessage = '%20%D0%BC%D1%83%D0%B7%D1%8B%D0%BA%D0%B0'.repeat(45);
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill(longMessage);
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(page.getByRole('button', { name: 'Показать полностью', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Показать полностью', exact: true }).click();
await expect(page.getByRole('button', { name: 'Свернуть текст', exact: true }).locator('..')).toContainText(longMessage);
await page.getByRole('button', { name: 'Свернуть текст', exact: true }).click();
if (width === 320) {
await page.getByRole('button', { name: 'Назад к сообщениям', exact: true }).click();
await expect(page.getByRole('searchbox', { name: 'Найти диалог', exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeHidden();
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(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
await page.getByRole('link', { name: 'Открыть трек ↗', exact: true }).click();
await page.getByRole('link', { name: 'Открыть трек', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Группа крови', exact: true })).toBeVisible();
});
}
+49 -4
View File
@@ -15,6 +15,48 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup);
describe('social', () => {
it('finds music beyond the last text page without acknowledging unread messages', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
for (let seq = 1; seq <= 61; seq++) api.messages.push({ id: crypto.randomUUID(), seq, sender_id: bob.id, recipient: alice.id, text: seq === 1 ? albumTracks[0]!.title : `Сообщение ${seq}`, attachment_kind: seq === 1 ? 'track' : 'none', ...(seq === 1 ? { attachment_id: albumTracks[0]!.id } : {}), created_at: new Date().toISOString(), read: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Музыка из переписки' }));
await screen.findByRole('link', { name: 'Открыть трек' });
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
const firstCall = api.calls.length;
api.messages.push({ id: crypto.randomUUID(), seq: 62, sender_id: bob.id, recipient: alice.id, text: 'Новая музыка', attachment_kind: 'track', attachment_id: albumTracks[0]!.id, created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByText('Новая музыка');
expect(api.messages.at(-1)?.read).toBe(false);
expect(api.calls.slice(firstCall).some(c => c.path.endsWith('/actions') && (c.body as { action?: string })?.action === 'read_chat')).toBe(false);
});
it('keeps the current conversation when another friend sends newer messages and confirms blocking', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const carol = makeUser('carol', 'user');
const { api, user, queryClient } = await renderApp([alice, bob, carol], '/friends', { signedInAs: alice });
for (const friend of [bob, carol]) api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByRole('heading', { name: 'bob' });
await user.type(screen.getByRole('textbox', { name: 'Сообщение' }), 'Черновик для bob');
api.messages.push({ id: crypto.randomUUID(), seq: 1, sender_id: carol.id, recipient: alice.id, text: 'Новое сообщение от carol', attachment_kind: 'none', created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByText('Новое сообщение от carol');
expect(screen.getByRole('heading', { name: 'bob' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Сообщение' })).toHaveValue('Черновик для bob');
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
const confirm = await screen.findByRole('dialog', { name: 'Заблокировать bob?' });
expect(api.friendships.size).toBe(2);
await user.click(within(confirm).getByRole('button', { name: 'Отмена' }));
expect(api.friendships.size).toBe(2);
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
await user.click(within(await screen.findByRole('dialog', { name: 'Заблокировать bob?' })).getByRole('button', { name: 'Заблокировать' }));
await waitFor(() => expect(api.friendships.size).toBe(1));
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
});
it('follows a room with locked playback controls, independent volume and local exit', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, session, user } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
@@ -30,12 +72,12 @@ describe('social', () => {
await waitFor(() => expect(screen.queryByText('Слушаете вместе с bob')).not.toBeInTheDocument());
expect(api.listeningMembers.has(alice.id)).toBe(false);
});
it('edits, replies, reacts, searches, deletes and preserves a minimized draft', async () => {
it('edits, replies, reacts, searches, deletes and preserves a draft between sections', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Написать' }));
await user.click(await screen.findByRole('button', { name: /bob Начать разговор/ }));
const composer = () => screen.getByRole('textbox', { name: 'Сообщение' });
await user.type(composer(), 'Первый вариант');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
@@ -55,21 +97,23 @@ describe('social', () => {
await user.click(screen.getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(api.messages).toHaveLength(2));
expect(api.messages[1]?.reply_to).toBe(api.messages[0]?.id);
await user.click(screen.getByRole('button', { name: 'Поиск в переписке' }));
await user.type(screen.getByRole('searchbox', { name: 'Поиск в переписке' }), 'Исправленный');
await waitFor(() => expect(screen.queryByText('Ответ на музыку', { exact: true })).not.toBeInTheDocument());
await actions('Удалить для всех');
await waitFor(() => expect(api.messages[0]?.deleted).toBe(true));
await user.clear(screen.getByRole('searchbox', { name: 'Поиск в переписке' }));
await user.type(composer(), 'Мой черновик');
await user.click(screen.getByRole('button', { name: 'Свернуть чат' }));
await user.click(screen.getByRole('button', { name: 'Друзья' }));
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'bob' }));
await user.click(screen.getByRole('button', { name: 'Написать' }));
expect(composer()).toHaveValue('Мой черновик');
});
it('saves privacy and independent request admission preferences', async () => {
const alice = makeUser('alice', 'user');
const { api, user } = await renderApp([alice], '/friends', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Мой статус скрыт' }));
const privacy = await screen.findByRole('group', { name: 'Показывать мой статус' });
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
@@ -101,6 +145,7 @@ describe('social', () => {
const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user', { display_name: 'Борис' });
const { api, router, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
await user.click((await screen.findAllByRole('button', { name: 'Найти друзей' }))[0]!);
await user.type(await screen.findByLabelText('Найти по имени или логину'), 'bob');
await user.click(await screen.findByRole('button', { name: 'В друзья' }));
await screen.findByRole('button', { name: 'Отменить заявку' });
@@ -1,89 +1,131 @@
import { attachmentPath, CHAT_REACTIONS, chatDraftKey, uuidv7, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
import { CHAT_REACTIONS, chatDraftKey, uuidv7, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Fragment, useEffect, useRef, useState } from 'react';
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useDesktop } from '../../desktop';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Field, FormError, Menu } from '../../shared/ui/ui';
import { arrowBackIcon, arrowDownIcon, arrowUpIcon, chatIcon, closeIcon, moreIcon, musicIcon, searchIcon, smileIcon } from '../../shared/ui/icons';
import { Button, EmptyState, Field, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
import { ListenTogetherButton } from './Listening';
import { ChatMusic, MessageText } from './ChatMusic';
import s from './social.module.css';
export function ChatConversation({ person, api, queryKey, userId, compact = false, active = true }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; compact?: boolean; active?: boolean }) {
const { t, i18n } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText();
export function ChatConversation({ person, api, queryKey, userId, active = true, onBack, actions }: {
person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; active?: boolean; onBack?: () => void; actions?: ReactNode;
}) {
const { t, i18n } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const server = useDesktop()?.server ?? window.location.origin;
const draftKey = chatDraftKey(server, userId, person.id);
const [text, setText] = useState(() => { try { return localStorage.getItem(draftKey) ?? ''; } catch { return ''; } });
const [reply, setReply] = useState<DirectMessage | null>(null); const [editing, setEditing] = useState<DirectMessage | null>(null);
const [search, setSearch] = useState(''); const [q, setQ] = useState(''); const [emojis, setEmojis] = useState(false);
const beforeEdit = useRef(''); const pending = useRef<{ id: string; text: string; reply_to?: string } | null>(null);
const [reply, setReply] = useState<DirectMessage | null>(null);
const [editing, setEditing] = useState<DirectMessage | null>(null);
const [view, setView] = useState<'conversation' | 'music'>('conversation');
const [searchOpen, setSearchOpen] = useState(false);
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [emojis, setEmojis] = useState(false);
const [atBottom, setAtBottom] = useState(true);
const beforeEdit = useRef('');
const pending = useRef<{ id: string; text: string; reply_to?: string } | null>(null);
const typedAt = useRef(0);
const composer = useRef<HTMLTextAreaElement>(null);
const scroll = useRef<HTMLDivElement>(null);
const previousLast = useRef<number | undefined>(undefined);
const [visible, setVisible] = useState(document.visibilityState === 'visible');
const music = view === 'music';
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 250); return () => clearTimeout(timer); }, [search]);
useEffect(() => {
if (editing) return;
const timer = setTimeout(() => { try { if (text) localStorage.setItem(draftKey, text); else localStorage.removeItem(draftKey); } catch { /* Private mode can deny storage. */ } }, 250);
return () => clearTimeout(timer);
try { if (text) localStorage.setItem(draftKey, text); else localStorage.removeItem(draftKey); } catch { /* Storage may be unavailable in private mode. */ }
}, [text, draftKey, editing]);
useEffect(() => {
const timer = setTimeout(() => { void api.typing(person.id, active && visible && !!text.trim() && !editing && Date.now() - typedAt.current < 6000).catch(() => undefined); }, 400);
const timer = setTimeout(() => { void api.typing(person.id, active && visible && !music && !!text.trim() && !editing && Date.now() - typedAt.current < 6000).catch(() => undefined); }, 400);
return () => clearTimeout(timer);
}, [text, active, visible, editing, person.id, api]);
}, [text, active, visible, editing, music, person.id, api]);
useEffect(() => () => { void api.typing(person.id, false).catch(() => undefined); }, [api, person.id]);
const meta = useQuery({ queryKey: [...queryKey, 'typing', person.id], queryFn: () => api.chatMeta(person.id), enabled: active && visible, refetchInterval: active && visible ? 2000 : false });
const messages = useInfiniteQuery({ queryKey: [...queryKey, 'chat', person.id, q], initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) => api.messages(person.id, pageParam, q), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined, enabled: active, refetchInterval: active && visible ? 3000 : false });
const list = [...(messages.data?.pages ?? [])].reverse().flat(); const last = list.at(-1)?.seq;
useEffect(() => { if (composer.current) { composer.current.style.height = 'auto'; composer.current.style.height = `${Math.min(composer.current.scrollHeight, 144)}px`; } }, [text, music, active]);
const meta = useQuery({ queryKey: [...queryKey, 'typing', person.id], queryFn: () => api.chatMeta(person.id), enabled: active && visible && !music, refetchInterval: active && visible && !music ? 2000 : false });
const messages = useInfiniteQuery({ queryKey: [...queryKey, 'chat', person.id, q, music], initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) => api.messages(person.id, pageParam, q, music), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined,
enabled: active && visible, refetchInterval: active && visible ? 3000 : false });
const list = [...(messages.data?.pages ?? [])].reverse().flat();
const last = list.at(-1)?.seq;
const firstUnread = list.find(m => m.sender_id !== userId && !m.read)?.id;
useEffect(() => {
if (last && firstUnread && visible && active && !q) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey })).catch(() => undefined);
}, [last, firstUnread, visible, active, q, api, person.id, cache, messages.dataUpdatedAt]);
const end = useRef<HTMLDivElement>(null);
useEffect(() => { if (active && !q) end.current?.scrollIntoView?.({ block: 'nearest' }); }, [last, active, q]);
const el = scroll.current;
if (el && active && !q && !music && (atBottom || previousLast.current === undefined)) el.scrollTop = el.scrollHeight;
previousLast.current = last;
}, [last, active, q, music]);
useEffect(() => {
const el = scroll.current;
const latestVisible = el && el.scrollHeight - el.scrollTop - el.clientHeight < 64;
if (last && firstUnread && latestVisible && visible && active && !q && !music && atBottom) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey })).catch(() => undefined);
}, [last, firstUnread, visible, active, q, music, atBottom, api, person.id, cache, messages.dataUpdatedAt]);
const refresh = () => cache.invalidateQueries({ queryKey });
const update = useMutation({ mutationFn: (input: { id: string; body: ChatMessageUpdate }) => api.updateMessage(person.id, input.id, input.body), onSuccess: refresh });
const send = useMutation({ mutationFn: async () => {
if (editing) { await api.updateMessage(person.id, editing.id, { action: 'edit', text }); return; }
if (!pending.current || pending.current.text !== text || pending.current.reply_to !== reply?.id) pending.current = { id: uuidv7(), text, ...(reply ? { reply_to: reply.id } : {}) };
await api.send(person.id, { ...pending.current, attachment_kind: 'none' });
}, onSuccess: async () => { setText(editing ? beforeEdit.current : ''); setEditing(null); setReply(null); pending.current = null; await refresh(); } });
}, onSuccess: async () => { setText(editing ? beforeEdit.current : ''); setEditing(null); setReply(null); pending.current = null; setAtBottom(true); await refresh(); if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; composer.current?.focus(); } });
const cancelEdit = () => { setText(beforeEdit.current); setEditing(null); };
const mutate = (id: string, body: ChatMessageUpdate) => { if (!update.isPending) update.mutate({ id, body }); };
const changeView = (next: typeof view) => { setView(next); setSearch(''); setQ(''); setSearchOpen(false); previousLast.current = undefined; setAtBottom(true); };
const older = async () => {
const el = scroll.current;
const height = el?.scrollHeight ?? 0;
const top = el?.scrollTop ?? 0;
await messages.fetchNextPage();
requestAnimationFrame(() => { if (el) el.scrollTop = top + el.scrollHeight - height; });
};
return <section className={s.chat} aria-label={t('social.chat')}>
{!compact && <header><strong>{person.display_name}</strong><p className={s.muted}>{t('social.chat_hint')}</p></header>}
<div className={s.chatSearch}><Field label={t('social.chat_search')} value={search} onChange={setSearch} type="search" maxLength={100} /></div>
<header className={s.chatHeader}>
{onBack && <Button className={`${s.iconButton} ${s.backButton}`} variant="ghost" icon={arrowBackIcon} aria-label={t('social.back_to_messages')} onClick={onBack} />}
<span className={`${s.avatar} ${person.activity ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
<div className={s.chatIdentity}><h2>{person.display_name}</h2><span className={s.muted}>@{person.username}</span></div>
<div className={s.chatTools}><ListenTogetherButton person={person} /><Button variant="ghost" className={s.iconButton} icon={searchIcon} aria-label={t('social.chat_search')} aria-expanded={searchOpen} onClick={() => { setSearchOpen(v => !v); setSearch(''); setQ(''); }} />{actions}</div>
</header>
{person.activity && <div className={s.nowListening}><Icon src={musicIcon} size={18} /><div><span>{t('social.listening')}</span><Link to="/track/$id" params={{ id: person.activity.track_id }}>{person.activity.track_title}{person.activity.artist ? ` · ${person.activity.artist}` : ''}</Link>{person.activity.context === 'playlist' && person.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: person.id }}>{t('social.from_playlist', { name: person.activity.playlist_name ?? '' })}</Link>}</div></div>}
<nav className={s.chatTabs} aria-label={t('social.conversation_content')}><button type="button" aria-pressed={!music} onClick={() => changeView('conversation')}><Icon src={chatIcon} size={16} />{t('social.conversation')}</button><button type="button" aria-pressed={music} onClick={() => changeView('music')}><Icon src={musicIcon} size={16} />{t('social.shared_music')}</button></nav>
{searchOpen && <div className={s.chatSearch}><Field label={t('social.chat_search')} value={search} onChange={setSearch} type="search" maxLength={100} autoFocus /><Button variant="ghost" icon={closeIcon} className={s.iconButton} aria-label={t('social.close_search')} onClick={() => { setSearchOpen(false); setSearch(''); setQ(''); }} /></div>}
<FormError>{messages.error || send.error || update.error ? errorText(messages.error ?? send.error ?? update.error) : null}</FormError>
<div className={s.messages}>
{messages.hasNextPage && <Button busy={messages.isFetchingNextPage} onClick={() => void messages.fetchNextPage()}>{t('social.older')}</Button>}
{messages.isPending && <p>{t('social.loading')}</p>}
{!list.length && messages.isSuccess && <p className={s.empty}>{t(q ? 'social.chat_no_results' : 'social.first_message')}</p>}
<div ref={scroll} className={`${s.messages} ${music ? s.musicMessages : ''}`} onScroll={e => { const el = e.currentTarget; setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 64); }}>
{messages.hasNextPage && <Button className={s.older} busy={messages.isFetchingNextPage} onClick={() => void older()}>{t('social.older')}</Button>}
{messages.isPending && <div className={s.chatSkeleton} role="status" aria-label={t('social.loading')}><Skeleton style={{ height: 70, width: '70%' }} /><Skeleton style={{ height: 46, width: '55%', alignSelf: 'end' }} /><Skeleton style={{ height: 80, width: '65%' }} /></div>}
{!list.length && messages.isSuccess && <EmptyState icon={music ? musicIcon : chatIcon}>{t(q ? 'social.chat_no_results' : music ? 'social.no_shared_music' : 'social.first_message')}</EmptyState>}
{list.map((m, i) => <Fragment key={m.id}>
{(i === 0 || new Date(list[i - 1]!.created_at).toDateString() !== new Date(m.created_at).toDateString()) && <time className={s.date}>{new Date(m.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'long', year: 'numeric' })}</time>}
{m.id === firstUnread && !q && <div className={s.date}>{t('social.new_messages')}</div>}
<article id={`message-${m.id}`} className={`${s.bubble} ${m.sender_id === userId ? s.mine : ''} ${m.deleted ? s.deleted : ''}`}>
{m.reply_to && <blockquote className={s.quote}>{m.reply_preview || t('social.message_deleted')}</blockquote>}
<div className={s.messageText}>{m.deleted ? t('social.message_deleted') : m.text}</div>
{!m.deleted && attachmentPath(m) && <Link className={s.music} to={m.attachment_kind === 'track' ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id: m.attachment_id!, shareId: m.attachment_id! }}>{t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} ↗</Link>}
{m.id === firstUnread && !q && !music && <div className={s.newMessages}>{t('social.new_messages')}</div>}
<article id={`message-${m.id}`} className={`${s.bubble} ${m.sender_id === userId ? s.mine : ''} ${m.deleted ? s.deleted : ''} ${music ? s.mediaBubble : ''}`}>
{m.reply_to && !music && <blockquote className={s.quote}>{m.reply_preview || t('social.message_deleted')}</blockquote>}
{(m.deleted || m.text && m.attachment_kind === 'none') && <div className={s.messageText}>{m.deleted ? t('social.message_deleted') : <MessageText text={m.text} />}</div>}
{!m.deleted && m.attachment_id && m.attachment_kind !== 'none' && <ChatMusic message={m} scope={queryKey} />}
{!m.deleted && !!m.reactions?.length && <div className={s.reactions}>{m.reactions.map(r => <button key={r.emoji} type="button" aria-pressed={r.mine} disabled={update.isPending} onClick={() => mutate(m.id, { action: 'react', emoji: r.emoji as typeof CHAT_REACTIONS[number] })}>{r.emoji} {r.count}</button>)}</div>}
<div className={s.messageFoot}><small className={s.muted}>{new Date(m.created_at).toLocaleTimeString(i18n.language, { hour: '2-digit', minute: '2-digit' })}{m.edited_at && !m.deleted ? ` · ${t('social.edited')}` : ''}{m.sender_id === userId ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</small>
{!m.deleted && <Menu portal label={t('social.message_actions')} trigger="•••" items={[
{ label: t('social.reply'), onSelect: () => { if (editing) cancelEdit(); setReply(m); } },
{!m.deleted && <Menu portal label={t('social.message_actions')} trigger={<Icon src={moreIcon} size={18} />} items={[
{ label: t('social.reply'), onSelect: () => { changeView('conversation'); if (editing) cancelEdit(); setReply(m); requestAnimationFrame(() => composer.current?.focus()); } },
...CHAT_REACTIONS.map(emoji => ({ label: emoji, onSelect: () => mutate(m.id, { action: 'react', emoji }) })),
...(m.sender_id === userId ? [{ label: t('social.edit_message'), onSelect: () => { beforeEdit.current = text; setEditing(m); setReply(null); setText(m.text); } }, { label: t('social.delete_message'), danger: true, onSelect: () => mutate(m.id, { action: 'delete' }) }] : []),
...(m.sender_id === userId ? [{ label: t('social.edit_message'), onSelect: () => { changeView('conversation'); beforeEdit.current = text; setEditing(m); setReply(null); setText(m.text); requestAnimationFrame(() => composer.current?.focus()); } }, { label: t('social.delete_message'), danger: true, onSelect: () => mutate(m.id, { action: 'delete' }) }] : []),
]} />}
</div>
</article>
</Fragment>)}
{send.isPending && !editing && !list.some(m => m.id === pending.current?.id) && <article className={`${s.bubble} ${s.mine} ${s.sending}`} role="status">{text} · …</article>}
<div ref={end} />
{send.isPending && !!text.trim() && !editing && !music && !list.some(m => m.id === pending.current?.id) && <article className={`${s.bubble} ${s.mine} ${s.sending}`} role="status">{text} · …</article>}
</div>
<div className={s.typing} role="status" aria-live="polite">{active && meta.data?.typing ? `${person.display_name} ${t('social.typing')}` : ''}</div>
{(reply || editing) && <div className={s.replyBar}><div><small>{t(editing ? 'social.editing' : 'social.replying')}</small><p>{(editing ?? reply)?.text}</p></div><Button variant="ghost" onClick={() => editing ? cancelEdit() : setReply(null)} aria-label={t(editing ? 'social.cancel_edit' : 'social.cancel_reply')}>×</Button></div>}
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
<textarea aria-label={t('social.message')} placeholder={t('social.message')} maxLength={4000} rows={2} value={text} disabled={send.isPending} onChange={e => { typedAt.current = Date.now(); setText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); } }} />
<Button variant="ghost" aria-label={t('social.emoji')} aria-expanded={emojis} onClick={() => setEmojis(v => !v)}>☺</Button>
{emojis && <div className={s.reactions}>{CHAT_REACTIONS.map(emoji => <button key={emoji} type="button" onClick={() => { setText(v => (v + emoji).slice(0, 4000)); setEmojis(false); }}>{emoji}</button>)}</div>}
<Button type="submit" variant="primary" busy={send.isPending} disabled={!text.trim()}>{t(editing ? 'playlists.save' : 'social.send')}</Button>
</form>
{!atBottom && !music && <Button className={s.jumpToBottom} icon={arrowDownIcon} onClick={() => { if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; setAtBottom(true); }}>{t('social.latest_messages')}</Button>}
{!music && <>
<div className={s.typing} role="status" aria-live="polite">{active && meta.data?.typing ? <><span className={s.typingDots} aria-hidden><i /><i /><i /></span>{person.display_name} {t('social.typing')}</> : null}</div>
{(reply || editing) && <div className={s.replyBar}><div><small>{t(editing ? 'social.editing' : 'social.replying')}</small><p>{(editing ?? reply)?.text}</p></div><Button variant="ghost" icon={closeIcon} className={s.iconButton} onClick={() => editing ? cancelEdit() : setReply(null)} aria-label={t(editing ? 'social.cancel_edit' : 'social.cancel_reply')} /></div>}
{emojis && <div className={s.emojiPicker}>{CHAT_REACTIONS.map(emoji => <button key={emoji} type="button" aria-label={emoji} onClick={() => { setText(v => (v + emoji).slice(0, 4000)); setEmojis(false); composer.current?.focus(); }}>{emoji}</button>)}</div>}
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
<Button variant="ghost" icon={smileIcon} className={s.iconButton} aria-label={t('social.emoji')} aria-expanded={emojis} onClick={() => setEmojis(v => !v)} />
<textarea ref={composer} aria-label={t('social.message')} placeholder={t('social.message_placeholder')} maxLength={4000} rows={1} value={text} disabled={send.isPending} onChange={e => { typedAt.current = Date.now(); setText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); } }} />
<Button type="submit" variant="primary" className={s.iconButton} icon={arrowUpIcon} aria-label={t(editing ? 'playlists.save' : 'social.send')} busy={send.isPending} disabled={!text.trim()} />
</form>
</>}
</section>;
}
@@ -0,0 +1,33 @@
import { createI18n } from '@pipu/i18n';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, describe, expect, it } from 'vitest';
import { MessageText } from './ChatMusic';
afterEach(cleanup);
describe('message text', () => {
it('compacts a long URL while preserving its full destination when text is collapsed', async () => {
const i18n = await createI18n('ru');
const url = `https://example.com/music/${'long-path-'.repeat(90)}?share=abc`;
render(<I18nextProvider i18n={i18n}><MessageText text={`Послушай ${url}`} /></I18nextProvider>);
expect(screen.getByRole('link')).toHaveAttribute('href', url);
expect(screen.getByRole('link').textContent!.length).toBeLessThan(65);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Показать полностью' }));
expect(screen.getByRole('link')).toHaveAttribute('href', url);
expect(screen.getByRole('button', { name: 'Свернуть текст' })).toHaveAttribute('aria-expanded', 'true');
});
it('keeps encoded text and markup literal, with expansion for long messages', async () => {
const i18n = await createI18n('ru');
const text = `<script>alert(1)</script> ${'%D0%BC%20'.repeat(100)}`;
const { container } = render(<I18nextProvider i18n={i18n}><MessageText text={text} /></I18nextProvider>);
expect(container.querySelector('script')).toBeNull();
expect(container.textContent).toContain('%D0%BC%20');
expect(screen.queryByRole('link')).not.toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: 'Показать полностью' }));
expect(container.textContent).toContain(text);
});
});
@@ -0,0 +1,63 @@
import { unwrap, type DirectMessage } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { useSession } from '../../app/session';
import { Cover } from '../../entities/catalog';
import { usePlayer, usePlayerState } from '../../player/context';
import { formatDuration } from '../../shared/lib/format';
import { musicIcon, pauseIcon, playIcon, queueIcon } from '../../shared/ui/icons';
import { Button, Icon } from '../../shared/ui/ui';
import s from './social.module.css';
/** Catalog reads stay in the client adapter; message text is never decoded into metadata. */
export function ChatMusic({ message, scope }: { message: DirectMessage; scope: readonly unknown[] }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const state = usePlayerState();
const id = message.attachment_id!;
const track = useQuery({ queryKey: [...scope, 'attachment', 'track', id], staleTime: 60_000, retry: false,
enabled: message.attachment_kind === 'track', queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })) });
const playlist = useQuery({ queryKey: [...scope, 'attachment', 'playlist', id], retry: false,
enabled: message.attachment_kind === 'playlist', queryFn: async () => unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } })) });
const isTrack = message.attachment_kind === 'track';
const query = isTrack ? track : playlist;
const tr = !track.isError ? track.data : undefined;
const pl = !playlist.isError ? playlist.data : undefined;
const metadataTitle = isTrack ? tr?.title : pl?.name;
const title = metadataTitle || message.text || t(isTrack ? 'social.shared_track' : 'social.shared_playlist');
const album = isTrack ? tr?.album : pl?.tracks[0]?.album;
const current = !!tr && tr.id === state.track?.id;
const available = !query.isError && (isTrack ? tr?.available : pl?.tracks.some(x => x.available));
const subtitle = query.isError ? t('social.attachment_unavailable') : isTrack ? tr ? `${tr.artists.map(a => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}` : t('social.shared_track') : t('social.shared_playlist');
return <><div className={`${s.musicCard} ${query.isError ? s.musicUnavailable : ''}`}>
{album ? <Cover album={album} size={48} /> : <span className={s.musicCover}><Icon src={isTrack ? musicIcon : queueIcon} size={24} /></span>}
<div className={s.musicInfo}>
<Link to={isTrack ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id, shareId: id }} aria-label={t(isTrack ? 'social.listen_track' : 'social.listen_playlist')} title={title}>{title}</Link>
<span>{subtitle}</span>
</div>
<Button variant="ghost" className={s.iconButton} icon={current && state.playing ? pauseIcon : playIcon} aria-label={t(current && state.playing ? 'player.pause' : 'catalog.play')} disabled={!available || state.listening?.locked} onClick={() => {
if (isTrack && tr) { if (current) player.toggle(); else player.playList([tr], 0, { type: 'queue' }); }
else if (pl) player.playList(pl.tracks.filter(x => x.available), 0, { type: 'queue' });
}} />
</div>{message.text && message.text.trim() !== metadataTitle?.trim() && <span className={s.attachmentCaption}><MessageText text={message.text} /></span>}</>;
}
/** Compact only actual HTTP(S) URLs. Arbitrary encoded text remains literal. */
export function MessageText({ text }: { text: string }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const long = text.length > 600;
const shown = long && !expanded ? `${text.slice(0, 400)}…` : text;
const originalParts = text.split(/(https?:\/\/[^\s<>]+)/g);
return <>{shown.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) => {
if (!/^https?:\/\//.test(part)) return part;
try {
const original = originalParts[i] ?? part;
const url = new URL(original);
return <a key={i} href={url.href} target="_blank" rel="noopener noreferrer" title={original}>{url.hostname}{url.pathname === '/' ? '' : `${url.pathname.slice(0, 34)}${url.pathname.length > 34 || url.search ? '…' : ''}`}</a>;
} catch { return part; }
})}{long && <button type="button" className={s.expandText} aria-expanded={expanded} onClick={() => setExpanded(v => !v)}>{t(expanded ? 'social.show_less' : 'social.show_more')}</button>}</>;
}
+6 -5
View File
@@ -7,6 +7,7 @@ import { useDesktop } from '../../desktop';
import { usePlayer, usePlayerState } from '../../player/context';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import { waveHeadphonesIcon } from '../../shared/ui/icons';
import s from './social.module.css';
function useListening() {
@@ -16,24 +17,24 @@ function useListening() {
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
return { api, key, query, user };
}
export function ListeningPanel() {
export function ListeningPanel({ showPreferences = true, showRequests = true }: { showPreferences?: boolean; showRequests?: boolean }) {
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
const { api, key, query, user } = useListening();
const prefs = query.data?.preferences;
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const action = useMutation({ mutationFn: (input: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: uuidv7(), action: input.action, target_id: input.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <div className={s.stack}>
<details className={s.privacy}><summary>{t('social.together')}</summary><p className={s.muted}>{t('social.stream_prefs_hint')}</p>
{showPreferences && <details className={s.privacy}><summary>{t('social.together')}</summary><p className={s.muted}>{t('social.stream_prefs_hint')}</p>
{(['requests', 'auto_accept'] as const).map(field => <fieldset key={field} className={s.privacy} disabled={!prefs || save.isPending}><legend>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={(prefs?.[field] ?? 'nobody') === v} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => prefs && save.mutate({ ...prefs, [field]: v })}>{t(`social.visibility_${v}`)}</Button>)}</div></fieldset>)}
<label><input type="checkbox" checked={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onChange={e => prefs && save.mutate({ ...prefs, autoplay: e.target.checked })} /> {t('social.stream_autoplay')}</label>
</details>
{query.data?.requests.map(r => <article key={r.id} className={s.person}><strong>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</strong><div className={s.actions}>{r.recipient_id === user.id ? <><Button disabled={action.isPending} onClick={() => action.mutate({ action: 'accept', target: r.id })}>{t('social.accept')}</Button><Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'decline', target: r.id })}>{t('social.decline')}</Button></> : <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'cancel', target: r.id })}>{t('form.cancel')}</Button>}</div></article>)}
</details>}
{showRequests && query.data?.requests.map(r => <article key={r.id} className={s.person}><strong>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</strong><div className={s.actions}>{r.recipient_id === user.id ? <><Button disabled={action.isPending} onClick={() => action.mutate({ action: 'accept', target: r.id })}>{t('social.accept')}</Button><Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'decline', target: r.id })}>{t('social.decline')}</Button></> : <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'cancel', target: r.id })}>{t('form.cancel')}</Button>}</div></article>)}
<FormError>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</FormError>
</div>;
}
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
const { t } = useTranslation(); const [open, setOpen] = useState(false);
return <><Button variant="ghost" onClick={() => setOpen(true)}>{t('social.together')}</Button>{open && <ListenTogetherDialog person={person} close={() => setOpen(false)} />}</>;
return <><Button variant="ghost" icon={waveHeadphonesIcon} aria-label={t('social.together')} onClick={() => setOpen(true)}><span className={s.togetherLabel}>{t('social.together')}</span></Button>{open && <ListenTogetherDialog person={person} close={() => setOpen(false)} />}</>;
}
function ListenTogetherDialog({ person, close }: { person: SocialPerson; close: () => void }) {
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
+114 -75
View File
@@ -1,12 +1,13 @@
import { attachmentPath, socialApi, uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useRouterState } from '@tanstack/react-router';
import { socialApi, 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 { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useDesktop } from '../../desktop';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError } 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 s from './social.module.css';
import { ListeningPanel, ListenTogetherButton } from './Listening';
import { ChatConversation } from './ChatConversation';
@@ -35,27 +36,10 @@ export function FriendRecommendations() {
type ShareValue = { attachment: MusicAttachment; url: string };
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
const ChatContext = createContext<(id: string) => void>(() => {});
export const useMusicShare = () => useContext(ShareContext);
export function SocialProvider({ children }: { children: ReactNode }) {
const [share, setShare] = useState<ShareValue | null>(null);
const [chat, setChat] = useState<{ peer: string | null; open: boolean }>({ peer: null, open: false });
return <ShareContext.Provider value={setShare}><ChatContext.Provider value={id => setChat({ peer: id, open: true })}>{children}<ChatDock chat={chat} change={setChat} />{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ChatContext.Provider></ShareContext.Provider>;
}
function ChatDock({ chat, change }: { chat: { peer: string | null; open: boolean }; change: (value: { peer: string | null; open: boolean }) => void }) {
const { inbox } = useSocial(); const { t } = useTranslation();
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
const selected = friends.find(p => p.id === chat.peer);
const unread = friends.reduce((sum, p) => sum + p.unread, 0);
const pathname = useRouterState({ select: state => state.location.pathname });
useEffect(() => { if (window.matchMedia?.('(max-width: 1023px)').matches) change({ ...chat, open: false }); }, [pathname]);
return <aside className={`${s.dock} ${chat.open ? s.dockOpen : ''}`} aria-label={t('social.messages')}>
<div className={s.dockHeader}><button type="button" aria-expanded={chat.open} onClick={() => change({ ...chat, open: !chat.open })}>{selected?.display_name ?? t('social.messages')}{unread ? ` · ${unread}` : ''}</button>{chat.open && <><button type="button" onClick={() => change({ peer: null, open: true })} aria-label={t('social.people')}>☰</button><button type="button" onClick={() => change({ ...chat, open: false })} aria-label={t('social.minimize')}>−</button></>}</div>
<div hidden={!chat.open}>
{selected ? <Chat key={selected.id} person={selected} compact active={chat.open} /> : <div className={s.dockList}>{friends.map(p => <button key={p.id} type="button" onClick={() => change({ peer: p.id, open: true })}><span className={s.avatar} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><span>{p.display_name}</span>{p.unread ? <small>{p.unread}</small> : null}</button>)}{!friends.length && <p className={s.empty}>{t('social.no_friends_share')}</p>}</div>}
</div>
</aside>;
return <ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider>;
}
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
const { t } = useTranslation();
@@ -90,75 +74,130 @@ function ShareDialog({ value, close }: { value: ShareValue; close: () => void })
</Dialog>;
}
type SocialTab = 'messages' | 'people' | 'requests' | 'notifications';
type ConfirmAction = { person: SocialPerson; action: 'remove' | 'block' };
export function SocialPage({ peer = '' }: { peer?: string }) {
const { t } = useTranslation();
const { api, inbox, key } = useSocial();
const { t, i18n } = useTranslation();
const { api, inbox, key, user } = useSocial();
const cache = useQueryClient();
const navigate = useNavigate();
const showChat = useContext(ChatContext);
const errorText = useErrorText();
const [tab, setTab] = useState<'people' | 'notifications'>('people');
const [tab, setTab] = useState<SocialTab>('messages');
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [settings, setSettings] = useState(false);
const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLElement>(null);
useEffect(() => {
const media = window.matchMedia?.('(max-width: 767px)');
if (!media) return;
const change = () => setNarrow(media.matches);
media.addEventListener('change', change);
return () => media.removeEventListener('change', change);
}, []);
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: tab === 'people' && q.length >= 2 });
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { setConfirm(null); await cache.invalidateQueries({ queryKey: key }); } });
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
const open = (id: string) => { setTab('people'); setSearch(''); setQ(''); if (inbox.data?.people.some(p => p.id === id && p.relationship === 'friend')) showChat(id); else void navigate({ to: '/friends', search: { peer: id } }); };
return <div className={s.page}>
<header><h1>{t('social.title')}</h1><p className={s.muted}>{t('social.subtitle')}</p></header>
<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={(inbox.data?.activity_visibility ?? 'nobody') === v} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => visibility.mutate(v)}>{t(`social.visibility_${v}`)}</Button>)}</div>
<p className={s.muted}>{t('social.activity_privacy_hint')}</p>
const people = inbox.data?.people ?? [];
const friends = people.filter(p => p.relationship === 'friend');
const conversations = [...friends].sort((a, b) => (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name));
const initialPeer = conversations[0]?.id;
useEffect(() => {
// Pin the initial desktop selection: a newer message in another dialog must
// never switch the conversation the user is reading or composing in.
if (!narrow && !peer && initialPeer) void navigate({ to: '/friends', search: { peer: initialPeer }, replace: true });
}, [narrow, peer, initialPeer, navigate]);
const selected = friends.find(p => p.id === peer) ?? (!peer ? conversations[0] : undefined);
const requests = people.filter(p => p.relationship === 'incoming' || p.relationship === 'outgoing');
const incoming = requests.filter(p => p.relationship === 'incoming').length;
const messageUnread = friends.reduce((sum, p) => sum + p.unread, 0);
const filtered = (tab === 'messages' ? conversations : requests).filter(p => `${p.display_name} ${p.username}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
const shownPeople = tab === 'people' ? q.length >= 2 ? results.data ?? [] : friends : filtered;
const open = (id: string) => {
const person = people.find(p => p.id === id);
setSearch(''); setQ('');
setTab(person?.relationship === 'friend' ? 'messages' : person?.relationship === 'incoming' || person?.relationship === 'outgoing' ? 'requests' : 'people');
void navigate({ to: '/friends', search: { peer: id } });
};
const findPeople = () => { setTab('people'); setSearch(''); setQ(''); requestAnimationFrame(() => searchRef.current?.focus()); };
const back = () => { void navigate({ to: '/friends', search: {} }).then(() => listRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')?.focus()); };
const act = (person: SocialPerson, op: SocialAction['action']) => {
if (op === 'block' || op === 'remove' && person.relationship === 'friend') setConfirm({ person, action: op });
else action.mutate({ action: op, target_id: person.id });
};
const currentVisibility = inbox.data?.activity_visibility ?? 'nobody';
const listLabel = t(tab === 'messages' ? 'social.find_conversation' : tab === 'requests' ? 'social.find_request' : 'social.search');
return <div className={`${s.page} ${tab === 'messages' && peer && selected ? s.conversationOpen : ''}`}>
<header className={s.pageHeader}>
<div><span className={s.eyebrow}>{t('social.space')}</span><h1>{t('social.title')}</h1><p className={s.muted}>{t('social.subtitle')}</p></div>
<div className={s.headerActions}>
<Button variant="ghost" icon={currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon} onClick={() => setSettings(true)}>{t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings')}</Button>
<Button variant="ghost" icon={inboxIcon} aria-pressed={tab === 'notifications'} onClick={() => setTab('notifications')}>{t('social.notifications')}{inbox.data?.unread ? <span className={s.badge}>{inbox.data.unread}</span> : null}</Button>
</div>
</header>
<ListeningPanel showPreferences={false} />
<section className={s.hub}>
<nav className={s.tabs} aria-label={t('social.title')}>
{(['messages', 'people', 'requests'] as const).map(value => <button key={value} type="button" aria-pressed={tab === value} onClick={() => { setTab(value); setSearch(''); setQ(''); }}>{t(`social.${value}`)}{(value === 'requests' ? incoming : value === 'messages' ? messageUnread : 0) > 0 && <span className={s.badge}>{value === 'requests' ? incoming : messageUnread}</span>}</button>)}
<Button className={s.addFriend} variant="ghost" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button>
</nav>
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
{tab === 'notifications' ? <div className={s.events}>
<h2>{t('social.notifications')}</h2>
{inbox.data?.notifications.length === 0 && <EmptyState icon={inboxIcon}>{t('social.no_notifications')}</EmptyState>}
{inbox.data?.notifications.map(n => <article key={n.id} className={`${s.event} ${!n.read ? s.unread : ''}`}>
<span className={s.avatar} aria-hidden>{n.actor_name.slice(0, 1).toUpperCase()}</span>
<div className={s.eventBody}><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString(i18n.language)}</time></div>
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
</article>)}
</div> : <div className={tab === 'messages' ? s.workspace : s.directory}>
<aside ref={listRef} className={s.peopleList} aria-label={t(tab === 'messages' ? 'social.messages' : 'social.people')}>
<div className={s.listSearch}><Icon src={searchIcon} size={18} /><input ref={searchRef} type="search" aria-label={listLabel} placeholder={listLabel} value={search} maxLength={80} onChange={e => setSearch(e.target.value)} /></div>
{inbox.isPending && <div className={s.stack} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(i => <Skeleton key={i} style={{ height: 64 }} />)}</div>}
{tab === 'people' && q.length >= 2 && results.isFetching && <p className={s.muted} role="status">{t('social.loading')}</p>}
{!shownPeople.length && inbox.data && !results.isFetching && <EmptyState icon={tab === 'requests' ? inboxIcon : groupIcon} action={!search && tab !== 'people' ? <Button onClick={findPeople}>{t('social.find_people')}</Button> : undefined}>{t(search ? 'social.no_results' : tab === 'requests' ? 'social.no_requests' : 'social.empty')}</EmptyState>}
{shownPeople.map(p => tab === 'messages' ? <button key={p.id} type="button" className={`${s.dialogRow} ${selected?.id === p.id ? s.selected : ''}`} aria-pressed={selected?.id === p.id} onClick={() => open(p.id)}>
<span className={`${s.avatar} ${p.activity ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span>
<span className={s.dialogBody}><span className={s.dialogTitle}><strong>{p.display_name}</strong>{p.last_message && <time>{new Date(p.last_message.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'short' })}</time>}</span><span className={s.preview}>{p.last_message ? `${p.last_message.sender_id === user.id ? `${t('social.you')}: ` : ''}${p.last_message.deleted ? t('social.message_deleted') : p.last_message.attachment_kind !== 'none' ? t(p.last_message.attachment_kind === 'track' ? 'social.shared_track' : 'social.shared_playlist') : p.last_message.text}` : t('social.start_conversation')}</span>{p.activity && <span className={s.listeningPreview}><Icon src={musicIcon} size={13} />{p.activity.track_title}</span>}</span>
{p.unread > 0 && <span className={s.badge}>{p.unread}</span>}
</button> : <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={op => act(p, op)} />)}
</aside>
{tab === 'messages' && (selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={(!narrow || !!peer) && !settings && !confirm} onBack={back} actions={<FriendMenu person={selected} act={op => act(selected, op)} />} /> : <div className={s.welcome}><span className={s.welcomeIcon}><Icon src={musicIcon} size={36} /></span><h2>{t('social.conversation_title')}</h2><p>{t('social.choose_chat')}</p><Button variant="primary" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button></div>)}
</div>}
</section>
{settings && <Dialog title={t('social.privacy_settings')} onClose={() => setSettings(false)}>
<fieldset className={s.privacy} disabled={!inbox.data || visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => visibility.mutate(v)}>{t(`social.visibility_${v}`)}</Button>)}</div><p className={s.muted}>{t('social.activity_privacy_hint')}</p></fieldset>
<FormError>{visibility.error ? errorText(visibility.error) : null}</FormError>
</fieldset>
<ListeningPanel />
<nav className={s.tabs} aria-label={t('social.title')}>
<Button variant={tab === 'people' ? 'primary' : 'ghost'} onClick={() => setTab('people')}>{t('social.people')}</Button>
<Button variant={tab === 'notifications' ? 'primary' : 'ghost'} onClick={() => setTab('notifications')}>{t('social.notifications')} {inbox.data?.unread || ''}</Button>
</nav>
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
{inbox.isPending && <p role="status">{t('social.loading')}</p>}
{tab === 'notifications' ? <div className={s.stack}>
{inbox.data?.notifications.length === 0 && <p className={s.empty}>{t('social.no_notifications')}</p>}
{inbox.data?.notifications.map(n => <article key={n.id} className={`${s.person} ${!n.read ? s.unread : ''}`}>
<div><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString()}</time></div>
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
</article>)}
</div> : <div>
<aside className={s.stack}>
<Field label={t('social.search')} value={search} onChange={setSearch} type="search" maxLength={80} />
{q.length >= 2 && results.isFetching && <p role="status">{t('social.loading')}</p>}
{!people.length && inbox.data && !results.isFetching && <p className={s.empty}>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</p>}
{people.map(p => <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={a => action.mutate({ action: a, target_id: p.id })} />)}
</aside>
</div>}
<ListeningPanel showRequests={false} />
{people.some(p => p.relationship === 'blocked') && <section><h3>{t('social.blocked_people')}</h3>{people.filter(p => p.relationship === 'blocked').map(p => <Person key={p.id} person={p} busy={action.isPending} open={() => {}} act={op => act(p, op)} />)}</section>}
</Dialog>}
{confirm && <Dialog title={t(confirm.action === 'block' ? 'social.confirm_block' : 'social.confirm_remove', { name: confirm.person.display_name })} onClose={() => { if (!action.isPending) setConfirm(null); }} footer={<><Button disabled={action.isPending} onClick={() => setConfirm(null)}>{t('form.cancel')}</Button><Button variant="danger" busy={action.isPending} onClick={() => action.mutate({ action: confirm.action, target_id: confirm.person.id })}>{t(`social.${confirm.action}`)}</Button></>}><p className={s.muted}>{t(confirm.action === 'block' ? 'social.block_hint' : 'social.remove_hint')}</p><FormError>{action.error ? errorText(action.error) : null}</FormError></Dialog>}
</div>;
}
function Person({ person: p, busy, open, act, selected }: { person: SocialPerson; busy: boolean; selected: boolean; open: () => void; act: (a: SocialAction['action']) => void }) {
function FriendMenu({ person, act }: { person: SocialPerson; act: (a: SocialAction['action']) => void }) {
const { t } = useTranslation();
return <Menu label={t('social.person_actions', { name: person.display_name })} trigger={<Icon src={moreIcon} />} items={[
...(person.relationship === 'friend' ? [{ label: t('social.remove'), danger: true, onSelect: () => act('remove') }] : []),
{ label: t(person.relationship === 'blocked' ? 'social.unblock' : 'social.block'), danger: person.relationship !== 'blocked', onSelect: () => act(person.relationship === 'blocked' ? 'unblock' : 'block') },
]} />;
}
function Person({ person: p, busy, open, act, selected }: { person: SocialPerson; busy: boolean; selected?: boolean; open: () => void; act: (a: SocialAction['action']) => void }) {
const { t } = useTranslation();
return <article className={`${s.person} ${selected ? s.selected : ''}`}>
<div className={s.identity}><span className={s.avatar} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><div><strong>{p.display_name}</strong><div className={s.muted}>@{p.username} · {t(`social.rel_${p.relationship}`)}</div></div></div>
{p.activity && <div className={s.activity}>
<span className={s.listening}>{t('social.listening')}</span>
<Link to="/track/$id" params={{ id: p.activity.track_id }}>{p.activity.track_title}</Link>
{p.activity.artist && <span className={s.muted}>{p.activity.artist}</span>}
{p.activity.context === 'wave' && <span className={s.muted}>{t('wave.title')}</span>}
{p.activity.context === 'playlist' && p.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: p.id }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Link>}
</div>}
<div className={s.identity}><span className={`${s.avatar} ${p.activity ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><div><strong>{p.display_name}</strong><div className={s.muted}>@{p.username} · {t(`social.rel_${p.relationship}`)}</div></div></div>
{p.activity && <div className={s.activity}><span className={s.listening}><Icon src={musicIcon} size={16} />{t('social.listening')}</span><Link to="/track/$id" params={{ id: p.activity.track_id }}>{p.activity.track_title}</Link>{p.activity.artist && <span className={s.muted}>{p.activity.artist}</span>}{p.activity.context === 'playlist' && p.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: p.id }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Link>}</div>}
<div className={s.actions}>
{p.relationship === 'friend' && <Button onClick={open}>{t('social.chat')}{p.unread ? ` · ${p.unread}` : ''}</Button>}
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
{p.relationship === 'friend' && <><Button onClick={open}>{t('social.chat')}{p.unread ? ` · ${p.unread}` : ''}</Button><ListenTogetherButton person={p} /></>}
{p.relationship === 'none' && <Button disabled={busy} onClick={() => act('request')}>{t('social.add')}</Button>}
{p.relationship === 'incoming' && <Button disabled={busy} variant="primary" onClick={() => act('accept')}>{t('social.accept')}</Button>}
{['incoming', 'outgoing', 'friend'].includes(p.relationship) && <Button disabled={busy} variant="ghost" onClick={() => act('remove')}>{t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')}</Button>}
<Button disabled={busy} variant="ghost" onClick={() => act(p.relationship === 'blocked' ? 'unblock' : 'block')}>{t(p.relationship === 'blocked' ? 'social.unblock' : 'social.block')}</Button>
{['incoming', 'outgoing'].includes(p.relationship) && <Button disabled={busy} variant="ghost" onClick={() => act('remove')}>{t(p.relationship === 'incoming' ? 'social.decline' : 'social.cancel')}</Button>}
<FriendMenu person={p} act={act} />
</div>
</article>;
}
function Chat({ person, compact = false, active = true }: { person: SocialPerson; compact?: boolean; active?: boolean }) {
const { api,key,user }=useSocial();
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} compact={compact} active={active} />;
}
+147 -59
View File
@@ -1,69 +1,157 @@
.page { max-width: 1200px; padding: 32px; margin: auto; display: grid; gap: 24px; }
.page h1 { margin: 0; font-size: 28px; }
.muted { color: var(--text3); font-size: 13px; }
.tabs, .actions { display: flex; flex-wrap: wrap; gap: 8px; }
.layout { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(280px, 1fr); gap: 24px; align-items: start; }
.page { width: 100%; max-width: 1480px; margin: 0 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 20px; }
.pageHeader { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-shrink: 0; }
.page h1 { font-size: clamp(22px, 2.2vw, 30px); letter-spacing: -.035em; margin: 4px 0 0; }
.pageHeader p { margin: 8px 0 0; }
.eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: 10px; font-weight: 600; color: var(--text3); }
.muted { color: var(--text2); font-size: 13px; line-height: 1.6; }
.headerActions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: end; }
.hub { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--surface); border: 1px solid var(--line2); border-radius: 20px; overflow: hidden; box-shadow: 0 12px 48px var(--hover); }
.tabs { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); padding: 10px 16px; flex-shrink: 0; }
.tabs > button:not(.addFriend) { position: relative; display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 10px; color: var(--text2); font-weight: 600; transition: color .18s, background .18s; }
.tabs > button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent2); }
.tabs > button[aria-pressed='true']::after { content: ''; position: absolute; bottom: -11px; height: 2px; left: 16px; right: 16px; background: var(--accent2); border-radius: 2px; animation: tabIn .2s ease-out; }
.tabs > button:hover { color: var(--text); background: var(--hover); }
.addFriend { margin-left: auto; }
.badge { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; background: var(--accent-fill); color: var(--on-accent, #fff); font-size: 11px; font-weight: 600; flex-shrink: 0; font-variant-numeric: tabular-nums; }
.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(230px, 29%) minmax(0, 1fr); }
.peopleList { padding: 12px 10px; min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
.workspace > .peopleList { border-right: 1px solid var(--line); background: var(--bg2); }
.listSearch { display: flex; align-items: center; gap: 10px; background: var(--surface2); border: 1px solid var(--line); border-radius: 10px; padding: 0 12px; min-height: 44px; margin: 2px 4px 14px; color: var(--text3); }
.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; }
.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); }
.dialogRow.selected { background: var(--accent-soft); }
.dialogBody { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
.dialogTitle { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }
.dialogTitle strong { font-size: 14px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.dialogTitle time { color: var(--text3); font-size: 10px; white-space: nowrap; flex-shrink: 0; }
.preview { color: var(--text2); font-size: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.listeningPreview { display: flex; align-items: center; gap: 4px; color: var(--ok); font-size: 11px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.avatar { position: relative; width: 42px; height: 42px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; color: var(--accent2); background: var(--accent-soft); font-size: 16px; font-weight: 600; }
.avatarListening::after { content: ''; position: absolute; right: 0; bottom: 0; width: 10px; height: 10px; background: var(--ok); border-radius: 50%; border: 2px solid var(--surface); }
.directory, .events { 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; }
.person > .actions { margin-left: auto; }
.person.selected { background: var(--accent-soft); }
.identity { display: flex; gap: 12px; align-items: center; min-width: 0; }
.identity > div { min-width: 0; }
.identity strong { display: block; overflow-wrap: anywhere; }
.activity { display: grid; gap: 4px; max-width: 300px; font-size: 13px; }
.activity a, .nowListening a { color: var(--text); text-decoration: none; }
.activity a:hover, .nowListening a:hover { color: var(--accent2); text-decoration: underline; }
.listening { display: flex; align-items: center; gap: 5px; color: var(--ok); font-size: 11px; }
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.stack { display: grid; gap: 12px; }
.privacy { border: 1px solid var(--line); border-radius: 16px; padding: 16px; margin: 0; min-width: 0; }
.stack:empty { display: none; }
.welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 12px; padding: 32px; }
.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; }
.activity { display: grid; gap: 5px; border-left: 2px solid var(--ok); padding-left: 12px; overflow-wrap: anywhere; }
.activity a { color: var(--accent2); text-decoration: none; }
.activity a:hover { text-decoration: underline; }
.listening { font-size: 12px; color: var(--ok); }
.privacy .privacy { margin: 12px 0; }
.choice { display: flex; gap: 10px; padding: 12px 0; align-items: center; }
.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; }
.choice { display: flex; gap: 8px; padding: 10px 0; align-items: center; }
.dock { position: fixed; right: 24px; bottom: 104px; z-index: 45; width: 220px; border-radius: 16px; border: 1px solid var(--line2); background: var(--elev); box-shadow: var(--shadow); overflow: hidden; }
.dockOpen { width: min(390px, calc(100vw - 32px)); max-height: calc(100dvh - 124px); }
.dockHeader { display: flex; align-items: center; min-height: 48px; padding: 0 8px; background: var(--surface2); gap: 4px; }
.dockHeader button { border: 0; background: transparent; color: var(--text); font: inherit; padding: 12px; cursor: pointer; }
.dockHeader button:first-child { flex: 1; text-align: left; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dock .chat { border: 0; border-radius: 0; display: flex; flex-direction: column; max-height: calc(100dvh - 174px); }
.dock .messages { flex: 1; min-height: 60px; max-height: min(40vh, 360px); padding: 14px; }
.dock .composer { padding: 12px; flex-wrap: wrap; }
.dock .composer textarea { flex-basis: 100%; }
.dockList { max-height: 50vh; overflow-y: auto; padding: 8px; }
.dockList button { display: flex; gap: 12px; align-items: center; border: 0; background: transparent; color: var(--text); padding: 12px; width: 100%; cursor: pointer; border-radius: 10px; text-align: left; font: inherit; }
.dockList button:hover { background: var(--hover); }
.dockList small { margin-left: auto; color: var(--accent2); }
.dockOpen { animation: chatReveal .22s cubic-bezier(.2,.8,.2,1); transform-origin: bottom right; }
.bubble { animation: messageIn .18s ease-out; }
.chatSearch { padding: 10px 14px; border-bottom: 1px solid var(--line); }
.chatSearch label { font-size: 11px; }
.date { align-self: center; color: var(--text3); font-size: 11px; margin: 6px 0; }
.quote { margin: 0; padding-left: 10px; border-left: 2px solid var(--accent2); color: var(--text2); font-size: 12px; max-height: 60px; overflow: hidden; }
.messageText { user-select: text; }
.messageFoot { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
.messageFoot button { font-size: 12px; min-width: 24px; }
.deleted { font-style: italic; opacity: .7; }
.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; }
.chatIdentity { flex: 1; min-width: 0; }
.chatIdentity h2 { font-size: 16px; line-height: 1.4; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chatIdentity > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.chatTools { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.iconButton { width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 12px; flex-shrink: 0; }
.backButton { display: none; }
.nowListening { display: flex; gap: 10px; align-items: center; padding: 10px 22px; background: var(--hover); color: var(--ok); flex-shrink: 0; }
.nowListening > div { min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 12px; }
.nowListening span { font-size: 11px; }
.nowListening a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.chatTabs { display: flex; gap: 16px; padding: 0 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
.chatTabs button { position: relative; display: flex; align-items: center; gap: 6px; padding: 12px 0; font-size: 12px; color: var(--text3); transition: color .16s; }
.chatTabs button[aria-pressed='true'] { color: var(--text); }
.chatTabs button[aria-pressed='true']::after { content: ''; height: 2px; position: absolute; left: 0; right: 0; bottom: -1px; border-radius: 2px; background: var(--accent2); animation: tabIn .18s ease-out; }
.chatSearch { display: flex; gap: 12px; align-items: end; padding: 12px 20px; border-bottom: 1px solid var(--line); animation: panelIn .18s ease-out; flex-shrink: 0; }
.chatSearch > div { flex: 1; }
.messages { display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; overflow-anchor: none; scrollbar-width: thin; padding: 22px 28px; }
.chatSkeleton { display: flex; flex-direction: column; gap: 20px; }
.older { align-self: center; flex-shrink: 0; }
.date { align-self: center; color: var(--text3); font-size: 11px; margin: 6px 0 10px; flex-shrink: 0; }
.newMessages { text-align: center; color: var(--accent2); font-size: 11px; border-bottom: 1px solid var(--line2); padding: 6px; }
.bubble { display: flex; flex-direction: column; gap: 7px; padding: 10px 14px 5px; border-radius: 16px 16px 16px 4px; background: var(--surface2); max-width: min(82%, 560px); min-width: 100px; flex-shrink: 0; align-self: start; overflow-wrap: anywhere; animation: messageIn .22s cubic-bezier(.2,.8,.2,1); }
.mine { align-self: end; background: var(--accent-soft); border-radius: 16px 16px 4px 16px; }
.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 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; }
.deleted { font-style: italic; color: var(--text3); }
.sending { opacity: .6; }
.reactions { display: flex; flex-wrap: wrap; gap: 5px; }
.reactions button { border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); color: var(--text); padding: 5px 9px; cursor: pointer; transition: transform .15s, background .15s; }
.reactions button { border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--text); padding: 4px 8px; font-size: 12px; cursor: pointer; transition: transform .15s, background .15s; }
.reactions button:hover { transform: translateY(-2px); }
.reactions button[aria-pressed='true'] { background: var(--accent-soft); border-color: var(--accent2); }
.typing { min-height: 19px; padding: 0 14px; color: var(--text3); font-size: 11px; }
.replyBar { display: flex; gap: 10px; align-items: center; padding: 8px 14px; background: var(--accent-soft); }
.typing { display: flex; align-items: center; gap: 8px; min-height: 24px; padding: 0 24px 4px; color: var(--text3); font-size: 11px; flex-shrink: 0; }
.typingDots { display: flex; gap: 3px; }
.typingDots i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: typingPulse 1s ease-in-out infinite alternate; }
.typingDots i:nth-child(2) { animation-delay: .15s; }
.typingDots i:nth-child(3) { animation-delay: .3s; }
.replyBar { display: flex; gap: 10px; align-items: center; padding: 10px 22px; background: var(--hover); border-top: 1px solid var(--line); flex-shrink: 0; animation: panelIn .16s ease-out; }
.replyBar > div { flex: 1; min-width: 0; }
.replyBar small { color: var(--accent2); }
.replyBar p { margin: 3px 0; font-size: 12px; max-height: 35px; overflow: hidden; }
@keyframes chatReveal { from { opacity: 0; transform: translateY(16px) scale(.97); } to { opacity: 1; transform: none; } }
@keyframes messageIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media(prefers-reduced-motion: reduce) { .dockOpen, .bubble { animation: none; } .reactions button { transition: none; } .reactions button:hover { transform: none; } }
@media(max-width: 760px) { .dock { bottom: 162px; right: 12px; width: 160px; } .dockOpen { width: calc(100vw - 24px); } .dock .messages { max-height: 32vh; } }
@media(max-width: 760px) { .dockOpen { max-height: calc(100dvh - 182px); } .dock .chat { max-height: calc(100dvh - 232px); } }
.person { padding: 16px; border: 1px solid var(--line); background: var(--surface); border-radius: 16px; display: grid; gap: 16px; overflow-wrap: anywhere; }
.identity { display: flex; gap: 12px; align-items: center; }
.avatar { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; color: var(--accent2); background: var(--accent-soft); }
.selected, .unread { border-color: var(--accent); }
.empty { color: var(--text3); padding: 36px 16px; text-align: center; line-height: 1.7; }
.chat { border: 1px solid var(--line); border-radius: 20px; background: var(--surface); overflow: hidden; min-width: 0; }
.chat header { padding: 20px; border-bottom: 1px solid var(--line); }
.chat header p { margin-bottom: 0; }
.messages { display: flex; flex-direction: column; gap: 12px; padding: 20px; max-height: 55vh; min-height: 220px; overflow: auto; }
.bubble { display: grid; gap: 8px; padding: 12px 16px; border-radius: 16px 16px 16px 4px; background: var(--surface2); max-width: 85%; align-self: start; white-space: pre-wrap; overflow-wrap: anywhere; }
.mine { align-self: end; background: var(--accent-soft); border-radius: 16px 16px 4px 16px; }
.music { color: var(--accent2); padding: 10px; border: 1px solid var(--line2); border-radius: 10px; text-decoration: none; }
.composer { display: flex; gap: 12px; padding: 16px; border-top: 1px solid var(--line); align-items: end; }
.composer textarea { resize: vertical; min-width: 0; flex: 1; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--text); padding: 12px; font: inherit; }
@media(max-width: 760px) { .layout { grid-template-columns: 1fr; } .page { padding: 20px 16px; } .composer { flex-wrap: wrap; } }
@media(min-width: 761px) and (max-width: 1023px) { .composer { flex-wrap: wrap; justify-content: end; } .composer textarea { flex-basis: 100%; } }
.replyBar p { margin: 3px 0; font-size: 12px; max-height: 35px; overflow: hidden; overflow-wrap: anywhere; }
.composer { display: flex; gap: 8px; padding: 14px 18px; border-top: 1px solid var(--line); align-items: end; flex-shrink: 0; background: var(--surface); }
.composer textarea { resize: none; min-width: 0; flex: 1; border: 1px solid transparent; border-radius: 12px; background: var(--surface2); color: var(--text); padding: 10px 14px; min-height: 42px; max-height: 144px; font: inherit; font-size: 14px; line-height: 22px; scrollbar-width: thin; transition: border-color .18s, background .18s; }
.composer textarea:focus { border-color: var(--accent); }
.composer textarea::placeholder { color: var(--text3); }
.emojiPicker { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 20px; border-top: 1px solid var(--line); flex-shrink: 0; animation: panelIn .18s ease-out; }
.emojiPicker button { width: 40px; height: 40px; border-radius: 10px; font-size: 20px; transition: background .15s, transform .15s; }
.emojiPicker button:hover { background: var(--hover); transform: translateY(-2px); }
.jumpToBottom { position: absolute; bottom: 94px; align-self: center; border: 1px solid var(--line2); background: var(--elev); box-shadow: var(--shadow); z-index: 2; animation: panelIn .18s ease-out; }
.musicCard { display: flex; align-items: center; gap: 12px; min-width: 0; width: 320px; max-width: 100%; padding: 6px 0; }
.musicInfo { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1; }
.musicInfo a { color: var(--text); font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
.musicInfo a:hover { color: var(--accent2); text-decoration: underline; }
.musicInfo > span { font-size: 11px; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.musicCover { width: 48px; height: 48px; display: grid; place-items: center; background: var(--surface3); border-radius: 8px; color: var(--text3); flex-shrink: 0; }
.musicUnavailable { opacity: .7; }
.musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; }
.musicMessages .musicCard { width: 100%; }
.attachmentCaption { font-size: 12px; color: var(--text2); white-space: pre-wrap; }
.expandText { display: block; color: var(--accent2); font-size: 12px; padding: 6px 0; }
@keyframes conversationIn { from { opacity: .4; transform: translateX(8px); } to { opacity: 1; transform: none; } }
@keyframes panelIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
@keyframes messageIn { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
@keyframes tabIn { from { opacity: 0; transform: scaleX(.5); } to { opacity: 1; transform: none; } }
@keyframes typingPulse { from { opacity: .35; transform: translateY(0); } to { opacity: 1; transform: translateY(-3px); } }
@media(max-width: 1150px) { .headerActions { gap: 0; } .pageHeader { align-items: start; } .headerActions > button { font-size: 12px; padding: 0 10px; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } }
@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .headerActions { justify-content: start; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } }
@media(max-width: 767px) {
.page { gap: 12px; } .pageHeader { padding: 0 2px; } .pageHeader p { display: none; } .eyebrow { display: none; } .page h1 { font-size: 23px; } .headerActions { width: 100%; justify-content: space-between; }
.hub { border-radius: 14px; } .tabs { flex-wrap: wrap; } .tabs > button:not(.addFriend) { flex: 1; font-size: 12px; } .tabs .addFriend { width: 100%; height: 36px; font-size: 12px; }
.workspace { display: flex; flex-direction: column; } .workspace > .peopleList { flex: 1; border: 0; } .workspace > .chat, .workspace > .welcome { display: none; }
.conversationOpen > .pageHeader, .conversationOpen > .stack, .conversationOpen .hub > .tabs, .conversationOpen .workspace > .peopleList { display: none; }
.conversationOpen .workspace > .chat { display: flex; flex: 1; } .backButton { display: inline-flex; } .chatHeader { padding: 10px 8px; gap: 6px; }
.chatHeader .avatar { width: 32px; height: 32px; font-size: 13px; } .chatIdentity h2 { font-size: 14px; } .chatIdentity > span { font-size: 11px; }
.chatTools > button, .chatTools > div > button { min-width: 36px; width: 36px; } .iconButton { min-width: 36px; width: 36px; } .chatTools > div:last-child > button { width: 32px; min-width: 32px; }
.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; }
.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; } }
+3
View File
@@ -23,6 +23,9 @@ export { default as visibilityOffIcon } from '@material-symbols/svg-400/rounded/
export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.svg';
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
export { default as arrowBackIcon } from '@material-symbols/svg-400/rounded/arrow_back.svg';
export { default as chatIcon } from '@material-symbols/svg-400/rounded/chat_bubble.svg';
export { default as smileIcon } from '@material-symbols/svg-400/rounded/sentiment_satisfied.svg';
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
+3 -1
View File
@@ -95,8 +95,10 @@ export function fakeApi(initial: FakeUser[]) {
const isBlocked = (a: string, b: string) => blocks.has(`${a}:${b}`) || blocks.has(`${b}:${a}`);
const socialPerson = (me: string, p: FakeUser): components['schemas']['SocialPerson'] => {
const f = friendships.get(pair(me, p.id));
const last = f?.accepted && !isBlocked(me, p.id) ? messages.filter(m => pair(m.sender_id, m.recipient) === pair(me, p.id)).at(-1) : undefined;
const visible = activityVisibility.get(p.id) === 'everyone' || activityVisibility.get(p.id) === 'friends' && f?.accepted;
return { id: p.id, username: p.username, display_name: p.display_name ?? p.username, ...(visible && activity.has(p.id) && !isBlocked(me,p.id) ? { activity: activity.get(p.id) } : {}),
...(last ? { last_message: { id: last.id, sender_id: last.sender_id, text: last.deleted ? '' : last.text.slice(0, 160), attachment_kind: last.attachment_kind, created_at: last.created_at, deleted: last.deleted ?? false } } : {}),
relationship: blocks.has(`${me}:${p.id}`) ? 'blocked' : f?.accepted ? 'friend' : f ? f.sender === me ? 'outgoing' : 'incoming' : 'none',
unread: messages.filter(m => m.recipient === me && m.sender_id === p.id && !m.read).length };
};
@@ -305,7 +307,7 @@ export function fakeApi(initial: FakeUser[]) {
const chatPath = /^\/api\/v1\/me\/social\/chats\/([^/]+)$/.exec(path);
if (chatPath) {
const peer=chatPath[1]!;if (!friendships.get(pair(user.id,peer))?.accepted || isBlocked(user.id,peer)) return problem(403,'social.unavailable');
if (req.method === 'GET') {const q=url.searchParams.get('q')?.toLowerCase()??'';return json(messages.filter(m => pair(m.sender_id,m.recipient)===pair(user.id,peer) && (!url.searchParams.has('before') || m.seq<Number(url.searchParams.get('before'))) && (!q||!m.deleted&&m.text.toLowerCase().includes(q))).slice(-50).map(m=>({...m,reply_preview:m.reply_to?messages.find(r=>r.id===m.reply_to)?.text:undefined})));}
if (req.method === 'GET') {const q=url.searchParams.get('q')?.toLowerCase()??'';return json(messages.filter(m => pair(m.sender_id,m.recipient)===pair(user.id,peer) && (!url.searchParams.has('before') || m.seq<Number(url.searchParams.get('before'))) && (!q||!m.deleted&&m.text.toLowerCase().includes(q)) && (url.searchParams.get('music') !== 'true' || !m.deleted && m.attachment_kind !== 'none')).slice(-50).map(m=>({...m,reply_preview:m.reply_to?messages.find(r=>r.id===m.reply_to)?.text:undefined})));}
if (req.method === 'POST' && body) {
const old=messages.find(m => m.id===body.id);if(old)return json(old);
const message={...body,recipient:peer,sender_id:user.id,seq:messages.length+1,created_at:new Date().toISOString(),read:false} as typeof messages[number];
+5
View File
@@ -257,6 +257,11 @@
animation: pipuIn 0.2s ease-out;
}
/* Communication owns its scroll areas so its composer stays above either player. */
.socialContent { display: flex; flex-direction: column; overflow: hidden; }
.socialContent > :not(.routeIn) { flex-shrink: 0; }
.socialContent .routeIn { display: flex; flex: 1; min-height: 0; }
@media (max-width: 1023px) {
.shell {
--side-w: 72px;
+1 -1
View File
@@ -311,7 +311,7 @@ function Shell() {
function ShellMain() {
const key = useRouterState({ select: (st) => st.location.pathname });
return (
<main id="content" className={s.content} tabIndex={-1}>
<main id="content" className={`${s.content} ${key === '/friends' ? s.socialContent : ''}`} tabIndex={-1}>
<DesktopOfflineBanner />
<ListeningBanner />
<div key={key} className={s.routeIn}>
+19 -2
View File
@@ -88,11 +88,28 @@ React Query; Android останавливает polling при уходе при
## Чат
Web и desktop используют выдвижную панель справа снизу, Android — общую нижнюю панель.
Web и desktop используют встроенный экран: список диалогов слева, переписка справа.
На узком экране список и переписка открываются последовательно; кнопка назад возвращает
к списку, а поле ввода остаётся над плеером. Android использует общую нижнюю панель.
Поддерживаются текст и музыка, ответы с цитатой, шесть реакций, редактирование своих
сообщений, удаление для всех, поиск по тексту, история, даты, прочтение и индикатор набора.
Удаление очищает исходный текст и музыкальное вложение. Черновики разделены по серверу,
пользователю и собеседнику. Скрытая панель не отмечает переписку прочитанной.
пользователю и собеседнику. Скрытая переписка не отмечает сообщения прочитанными.
Просмотр истории выше последних сообщений не вызывает принудительную прокрутку вниз.
Поиск открывается по кнопке. Длинные сообщения можно развернуть, HTTP(S)-ссылки
отображаются компактно без декодирования произвольного текста.
Список диалогов отсортирован по последнему сообщению; сервер отдаёт короткое превью
только для текущих друзей, с повторной проверкой блокировок. Удалённый текст очищен.
Вкладки «Друзья» и «Заявки» отделены от сообщений; приватность и настройки совместного
прослушивания открываются из компактного индикатора статуса. Удаление друга и блокировка
доступны через меню и требуют подтверждения.
Треки и плейлисты отображаются карточками с названием, обложкой и воспроизведением.
«Музыка из переписки» использует `getDirectMessages?music=true` с обычным `before`:
доступна вся история вложений, удалённые сообщения исключены, прочтение не подтверждается.
Недоступные треки и отозванные ссылки не запускаются; метаданные берутся из каталога
и публичной проекции плейлиста, а не из текста сообщения.
Анимации учитывают системное уменьшение движения. Фото, файлы и голосовые не входят в версию.
Миграции `00020`–`00022` добавляют активность, комнаты и возможности чата.
@@ -1974,6 +1974,20 @@ export interface components {
relationship: "none" | "incoming" | "outgoing" | "friend" | "blocked";
unread: number;
activity?: components["schemas"]["ListeningActivity"];
last_message?: components["schemas"]["SocialMessagePreview"];
};
/** @description Latest message in a current friendship, without marking it read. Deleted content is never returned. */
SocialMessagePreview: {
/** Format: uuid */
id: string;
/** Format: uuid */
sender_id: string;
text: string;
/** @enum {string} */
attachment_kind: "none" | "track" | "playlist";
/** Format: date-time */
created_at: string;
deleted: boolean;
};
SocialNotification: {
/** Format: uuid */
@@ -3466,6 +3480,8 @@ export interface operations {
query?: {
before?: number;
q?: string;
/** @description Only non-deleted track and playlist attachments, with the same history pagination. */
music?: boolean;
};
header?: never;
path: {
+1 -1
View File
@@ -28,7 +28,7 @@ export function socialApi(session: AuthSession) {
activityPlaylist: async (userId: string) => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })),
search: async (q: string) => unwrap(await session.client.GET('/api/v1/me/social/people', { params: { query: { q } } })),
action: async (body: SocialAction) => unwrap(await session.client.POST('/api/v1/me/social/actions', { body })),
messages: async (peerId: string, before?: number, q?: string) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before, q } } })),
messages: async (peerId: string, before?: number, q?: string, music?: boolean) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before, q, music } } })),
chatMeta: async (peerId: string) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } } })),
typing: async (peerId: string, typing: boolean) => unwrap(await session.client.PUT('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } }, body: { typing } })),
updateMessage: async (peerId: string, messageId: string, body: ChatMessageUpdate) => unwrap(await session.client.PATCH('/api/v1/me/social/chats/{peerId}/messages/{messageId}', { params: { path: { peerId, messageId } }, body })),
+30
View File
@@ -387,6 +387,36 @@
"no_results": "No playlists found"
},
"social": {
"space": "In your own rhythm",
"requests": "Requests",
"find_people": "Find friends",
"find_conversation": "Find a conversation",
"find_request": "Find a request",
"no_requests": "No requests yet. Incoming and outgoing friend requests will appear here.",
"status_hidden": "My activity is hidden",
"privacy_settings": "Privacy and social",
"blocked_people": "Blocked people",
"confirm_block": "Block {{name}}?",
"confirm_remove": "Remove {{name}} as a friend?",
"block_hint": "This ends your friendship and disables messaging and listening together. Unblocking does not automatically restore your friendship.",
"remove_hint": "Your conversation is saved. Messaging becomes available again after a new mutual friend request.",
"person_actions": "Actions for {{name}}",
"you": "You",
"shared_track": "Track",
"shared_playlist": "Playlist",
"start_conversation": "Start a conversation",
"conversation_title": "Music brings us closer",
"conversation_content": "Conversation content",
"conversation": "Conversation",
"shared_music": "Shared music",
"no_shared_music": "Shared tracks and playlists will appear here. Share music from the player or a playlist page.",
"back_to_messages": "Back to messages",
"close_search": "Close search",
"latest_messages": "Latest messages",
"attachment_unavailable": "Music is unavailable or the link was revoked",
"message_placeholder": "Message…",
"show_more": "Show full text",
"show_less": "Collapse text",
"title": "Friends & messages",
"subtitle": "Music worth sharing. Conversations at your own pace.",
"people": "Friends",
+30
View File
@@ -387,6 +387,36 @@
"no_results": "Плейлисты не найдены"
},
"social": {
"space": "В своём ритме",
"requests": "Заявки",
"find_people": "Найти друзей",
"find_conversation": "Найти диалог",
"find_request": "Найти заявку",
"no_requests": "Новых заявок пока нет. Здесь появятся входящие и отправленные приглашения в друзья.",
"status_hidden": "Мой статус скрыт",
"privacy_settings": "Приватность и общение",
"blocked_people": "Заблокированные",
"confirm_block": "Заблокировать {{name}}?",
"confirm_remove": "Удалить {{name}} из друзей?",
"block_hint": "Дружба прекратится, сообщения и совместное прослушивание станут недоступны. Разблокировка не восстановит дружбу автоматически.",
"remove_hint": "Переписка сохранится, но общение будет доступно только после новой взаимной заявки в друзья.",
"person_actions": "Действия с пользователем {{name}}",
"you": "Вы",
"shared_track": "Трек",
"shared_playlist": "Плейлист",
"start_conversation": "Начать разговор",
"conversation_title": "Музыка сближает",
"conversation_content": "Содержимое переписки",
"conversation": "Переписка",
"shared_music": "Музыка из переписки",
"no_shared_music": "Отправленные треки и плейлисты появятся здесь. Поделитесь музыкой через плеер или страницу плейлиста.",
"back_to_messages": "Назад к сообщениям",
"close_search": "Закрыть поиск",
"latest_messages": "К новым сообщениям",
"attachment_unavailable": "Музыка недоступна или ссылка отозвана",
"message_placeholder": "Сообщение…",
"show_more": "Показать полностью",
"show_less": "Свернуть текст",
"title": "Друзья и общение",
"subtitle": "Музыка, которой хочется делиться. Разговоры — в своём ритме.",
"people": "Друзья",
+416 -379
View File
@@ -237,6 +237,13 @@ const (
Unblock SocialActionAction = "unblock"
)
// Defines values for SocialMessagePreviewAttachmentKind.
const (
SocialMessagePreviewAttachmentKindNone SocialMessagePreviewAttachmentKind = "none"
SocialMessagePreviewAttachmentKindPlaylist SocialMessagePreviewAttachmentKind = "playlist"
SocialMessagePreviewAttachmentKindTrack SocialMessagePreviewAttachmentKind = "track"
)
// Defines values for SocialNotificationKind.
const (
SocialNotificationKindFriendAccepted SocialNotificationKind = "friend_accepted"
@@ -250,11 +257,11 @@ const (
// Defines values for SocialPersonRelationship.
const (
SocialPersonRelationshipBlocked SocialPersonRelationship = "blocked"
SocialPersonRelationshipFriend SocialPersonRelationship = "friend"
SocialPersonRelationshipIncoming SocialPersonRelationship = "incoming"
SocialPersonRelationshipNone SocialPersonRelationship = "none"
SocialPersonRelationshipOutgoing SocialPersonRelationship = "outgoing"
Blocked SocialPersonRelationship = "blocked"
Friend SocialPersonRelationship = "friend"
Incoming SocialPersonRelationship = "incoming"
None SocialPersonRelationship = "none"
Outgoing SocialPersonRelationship = "outgoing"
)
// Defines values for SourceStatus.
@@ -1373,6 +1380,19 @@ type SocialInbox struct {
Unread int `json:"unread"`
}
// SocialMessagePreview Latest message in a current friendship, without marking it read. Deleted content is never returned.
type SocialMessagePreview struct {
AttachmentKind SocialMessagePreviewAttachmentKind `json:"attachment_kind"`
CreatedAt time.Time `json:"created_at"`
Deleted bool `json:"deleted"`
Id openapi_types.UUID `json:"id"`
SenderId openapi_types.UUID `json:"sender_id"`
Text string `json:"text"`
}
// SocialMessagePreviewAttachmentKind defines model for SocialMessagePreview.AttachmentKind.
type SocialMessagePreviewAttachmentKind string
// SocialNotification defines model for SocialNotification.
type SocialNotification struct {
ActorId openapi_types.UUID `json:"actor_id"`
@@ -1388,9 +1408,12 @@ type SocialNotificationKind string
// SocialPerson defines model for SocialPerson.
type SocialPerson struct {
Activity *ListeningActivity `json:"activity,omitempty"`
DisplayName string `json:"display_name"`
Id openapi_types.UUID `json:"id"`
Activity *ListeningActivity `json:"activity,omitempty"`
DisplayName string `json:"display_name"`
Id openapi_types.UUID `json:"id"`
// LastMessage Latest message in a current friendship, without marking it read. Deleted content is never returned.
LastMessage *SocialMessagePreview `json:"last_message,omitempty"`
Relationship SocialPersonRelationship `json:"relationship"`
Unread int `json:"unread"`
Username string `json:"username"`
@@ -2136,6 +2159,9 @@ type SetActivityVisibilityJSONBody struct {
type GetDirectMessagesParams struct {
Before *int64 `form:"before,omitempty" json:"before,omitempty"`
Q *string `form:"q,omitempty" json:"q,omitempty"`
// Music Only non-deleted track and playlist attachments, with the same history pagination.
Music *bool `form:"music,omitempty" json:"music,omitempty"`
}
// UpdateChatMessageJSONBody defines parameters for UpdateChatMessage.
@@ -5817,6 +5843,14 @@ func (siw *ServerInterfaceWrapper) GetDirectMessages(w http.ResponseWriter, r *h
return
}
// ------------- Optional query parameter "music" -------------
err = runtime.BindQueryParameter("form", true, false, "music", r.URL.Query(), &params.Music)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "music", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetDirectMessages(w, r, peerId, params)
}))
@@ -14401,377 +14435,380 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"6hT6/dbdW3f/vwAAAP//SJiqB93gAQA=",
"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",
"AA==",
}
// GetSwagger returns the content of the embedded swagger specification file
+1 -1
View File
@@ -87,7 +87,7 @@ func (h *Handler) GetDirectMessages(ctx context.Context, r gen.GetDirectMessages
if utf8.RuneCountInString(q) > 100 {
return nil, invalid()
}
v, e := h.store.messagePage(ctx, id, r.PeerId, r.Params.Before, q)
v, e := h.store.filteredMessages(ctx, id, r.PeerId, r.Params.Before, q, r.Params.Music != nil && *r.Params.Music)
return gen.GetDirectMessages200JSONResponse(v), e
}
func validateMessage(m gen.SendDirectMessage) error {
+47
View File
@@ -0,0 +1,47 @@
package social
import (
"context"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
)
// Read all previews in one query. Recheck friendship and both block directions
// here so a removed relationship cannot expose history through the inbox.
func (s *Store) attachLastMessages(ctx context.Context, viewer uuid.UUID, people []gen.SocialPerson) error {
positions := map[uuid.UUID]int{}
ids := []uuid.UUID{}
for i, p := range people {
if p.Relationship == "friend" {
positions[p.Id] = i
ids = append(ids, p.Id)
}
}
if len(ids) == 0 {
return nil
}
rows, e := s.pool.Query(ctx, `SELECT p.peer_id,m.id,m.sender_id,
CASE WHEN m.deleted_at IS NULL THEN left(m.text,160) ELSE '' END,
m.attachment_kind,m.created_at,m.deleted_at IS NOT NULL
FROM unnest($2::uuid[]) AS p(peer_id)
JOIN friendships f ON f.low_id=LEAST($1::uuid,p.peer_id) AND f.high_id=GREATEST($1::uuid,p.peer_id) AND f.accepted
JOIN LATERAL (SELECT * FROM direct_messages
WHERE LEAST(sender_id,recipient_id)=LEAST($1::uuid,p.peer_id)
AND GREATEST(sender_id,recipient_id)=GREATEST($1::uuid,p.peer_id)
ORDER BY seq DESC LIMIT 1) m ON true
WHERE NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=p.peer_id) OR (b.peer_id=$1 AND b.user_id=p.peer_id))`, viewer, ids)
if e != nil {
return e
}
defer rows.Close()
for rows.Next() {
var peer uuid.UUID
var preview gen.SocialMessagePreview
if e = rows.Scan(&peer, &preview.Id, &preview.SenderId, &preview.Text, &preview.AttachmentKind, &preview.CreatedAt, &preview.Deleted); e != nil {
return e
}
people[positions[peer]].LastMessage = &preview
}
return rows.Err()
}
@@ -67,6 +67,9 @@ func TestSocialIsolationAndLifecycle(t *testing.T) {
require.NoError(t, e)
require.Equal(t, 1, inbox.Unread)
// Another user cannot acknowledge this user's notification.
require.NotNil(t, inbox.People[0].LastMessage)
require.Equal(t, m.Id, inbox.People[0].LastMessage.Id)
require.Equal(t, "Привет", inbox.People[0].LastMessage.Text)
require.NoError(t, s.action(ctx, c, gen.SocialAction{Action: "read_notification", TargetId: inbox.Notifications[0].Id}))
inbox, e = s.inbox(ctx, b)
require.NoError(t, e)
@@ -82,6 +85,11 @@ func TestSocialIsolationAndLifecycle(t *testing.T) {
before, e := s.messages(ctx, b, a, &second.Seq)
require.NoError(t, e)
require.Len(t, before, 1)
require.NoError(t, s.updateMessage(ctx, a, b, second.Id, gen.UpdateChatMessageJSONRequestBody{Action: "delete"}))
inbox, e = s.inbox(ctx, b)
require.NoError(t, e)
require.True(t, inbox.People[0].LastMessage.Deleted)
require.Empty(t, inbox.People[0].LastMessage.Text)
require.NoError(t, action(b, a, "block"))
require.NoError(t, action(a, b, "block"))
for _, id := range []uuid.UUID{a, b} {
@@ -89,6 +97,7 @@ func TestSocialIsolationAndLifecycle(t *testing.T) {
require.NoError(t, err)
require.Len(t, blocked.People, 1)
require.EqualValues(t, "blocked", blocked.People[0].Relationship)
require.Nil(t, blocked.People[0].LastMessage)
}
require.NoError(t, action(a, b, "unblock"))
_, e = s.send(ctx, a, b, gen.SendDirectMessage{Id: uuid.New(), Text: "blocked", AttachmentKind: "none"})
@@ -118,6 +127,20 @@ func TestSocialIsolationAndLifecycle(t *testing.T) {
attachment, e := s.send(ctx, a, c, gen.SendDirectMessage{Id: uuid.New(), AttachmentKind: "playlist", AttachmentId: &share})
require.NoError(t, e)
require.Equal(t, &share, attachment.AttachmentId)
_, e = s.send(ctx, a, c, gen.SendDirectMessage{Id: uuid.New(), Text: "After the music", AttachmentKind: "none"})
require.NoError(t, e)
music, e := s.filteredMessages(ctx, c, a, nil, "", true)
require.NoError(t, e)
require.Len(t, music, 1)
require.Equal(t, attachment.Id, music[0].Id)
require.False(t, music[0].Read, "browsing shared music must not acknowledge messages")
music, e = s.filteredMessages(ctx, c, a, &attachment.Seq, "", true)
require.NoError(t, e)
require.Empty(t, music)
require.NoError(t, s.updateMessage(ctx, a, c, attachment.Id, gen.UpdateChatMessageJSONRequestBody{Action: "delete"}))
music, e = s.filteredMessages(ctx, c, a, nil, "", true)
require.NoError(t, e)
require.Empty(t, music, "deleted music must not appear in shared attachments")
_, e = pool.Exec(ctx, `UPDATE playlists SET share_id=NULL WHERE id=$1`, playlist)
require.NoError(t, e)
_, e = s.send(ctx, a, c, gen.SendDirectMessage{Id: uuid.New(), AttachmentKind: "playlist", AttachmentId: &share})
+7 -1
View File
@@ -92,6 +92,9 @@ func (s *Store) inbox(ctx context.Context, id uuid.UUID) (gen.SocialInbox, error
if e = s.attachActivity(ctx, id, out.People); e != nil {
return out, e
}
if e = s.attachLastMessages(ctx, id, out.People); e != nil {
return out, e
}
if e = s.pool.QueryRow(ctx, `SELECT activity_visibility,friends_recommendations FROM users WHERE id=$1`, id).Scan(&out.ActivityVisibility, &out.FriendsRecommendations); e != nil {
return out, e
}
@@ -238,6 +241,9 @@ func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64)
return s.messagePage(ctx, id, peer, before, "")
}
func (s *Store) messagePage(ctx context.Context, id, peer uuid.UUID, before *int64, q string) ([]gen.DirectMessage, error) {
return s.filteredMessages(ctx, id, peer, before, q, false)
}
func (s *Store) filteredMessages(ctx context.Context, id, peer uuid.UUID, before *int64, q string, music bool) ([]gen.DirectMessage, error) {
tx, e := pairTx(ctx, s.pool, id, peer)
if e != nil {
return nil, e
@@ -246,7 +252,7 @@ func (s *Store) messagePage(ctx context.Context, id, peer uuid.UUID, before *int
if e = allowed(ctx, tx, id, peer); e != nil {
return nil, e
}
rows, e := tx.Query(ctx, `SELECT `+messageColumns+` FROM direct_messages WHERE LEAST(sender_id,recipient_id)=LEAST($1::uuid,$2::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($1::uuid,$2::uuid) AND ($3::bigint IS NULL OR seq<$3) AND ($4='' OR (deleted_at IS NULL AND strpos(lower(text),lower($4))>0)) ORDER BY seq DESC LIMIT 50`, id, peer, before, q)
rows, e := tx.Query(ctx, `SELECT `+messageColumns+` FROM direct_messages WHERE LEAST(sender_id,recipient_id)=LEAST($1::uuid,$2::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($1::uuid,$2::uuid) AND ($3::bigint IS NULL OR seq<$3) AND ($4='' OR (deleted_at IS NULL AND strpos(lower(text),lower($4))>0)) AND (NOT $5::boolean OR (deleted_at IS NULL AND attachment_kind IN ('track','playlist'))) ORDER BY seq DESC LIMIT 50`, id, peer, before, q, music)
if e != nil {
return nil, e
}