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
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:
1 parent
11a16cd023
commit
1a693848f2
23 files changed
+1145
-579
No files matched your search
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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>}</>;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user