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
@@ -194,6 +194,10 @@ paths:
|
||||
- in: query
|
||||
name: q
|
||||
schema: {type: string, maxLength: 100}
|
||||
- in: query
|
||||
name: music
|
||||
description: Only non-deleted track and playlist attachments, with the same history pagination.
|
||||
schema: {type: boolean, default: false}
|
||||
responses:
|
||||
'200':
|
||||
description: Message page
|
||||
@@ -2361,6 +2365,18 @@ components:
|
||||
relationship: {type: string, enum: [none, incoming, outgoing, friend, blocked]}
|
||||
unread: {type: integer}
|
||||
activity: {$ref: '#/components/schemas/ListeningActivity'}
|
||||
last_message: {$ref: '#/components/schemas/SocialMessagePreview'}
|
||||
SocialMessagePreview:
|
||||
type: object
|
||||
description: Latest message in a current friendship, without marking it read. Deleted content is never returned.
|
||||
required: [id, sender_id, text, attachment_kind, created_at, deleted]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
sender_id: {type: string, format: uuid}
|
||||
text: {type: string}
|
||||
attachment_kind: {type: string, enum: [none, track, playlist]}
|
||||
created_at: {type: string, format: date-time}
|
||||
deleted: {type: boolean}
|
||||
SocialNotification:
|
||||
type: object
|
||||
required: [id, actor_id, actor_name, kind, created_at, read]
|
||||
|
||||
@@ -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}>
|
||||
|
||||
+19
-2
@@ -88,11 +88,28 @@ React Query; Android останавливает polling при уходе при
|
||||
|
||||
## Чат
|
||||
|
||||
Web и desktop используют выдвижную панель справа снизу, Android — общую нижнюю панель.
|
||||
Web и desktop используют встроенный экран: список диалогов слева, переписка справа.
|
||||
На узком экране список и переписка открываются последовательно; кнопка назад возвращает
|
||||
к списку, а поле ввода остаётся над плеером. Android использует общую нижнюю панель.
|
||||
Поддерживаются текст и музыка, ответы с цитатой, шесть реакций, редактирование своих
|
||||
сообщений, удаление для всех, поиск по тексту, история, даты, прочтение и индикатор набора.
|
||||
Удаление очищает исходный текст и музыкальное вложение. Черновики разделены по серверу,
|
||||
пользователю и собеседнику. Скрытая панель не отмечает переписку прочитанной.
|
||||
пользователю и собеседнику. Скрытая переписка не отмечает сообщения прочитанными.
|
||||
Просмотр истории выше последних сообщений не вызывает принудительную прокрутку вниз.
|
||||
Поиск открывается по кнопке. Длинные сообщения можно развернуть, HTTP(S)-ссылки
|
||||
отображаются компактно без декодирования произвольного текста.
|
||||
|
||||
Список диалогов отсортирован по последнему сообщению; сервер отдаёт короткое превью
|
||||
только для текущих друзей, с повторной проверкой блокировок. Удалённый текст очищен.
|
||||
Вкладки «Друзья» и «Заявки» отделены от сообщений; приватность и настройки совместного
|
||||
прослушивания открываются из компактного индикатора статуса. Удаление друга и блокировка
|
||||
доступны через меню и требуют подтверждения.
|
||||
|
||||
Треки и плейлисты отображаются карточками с названием, обложкой и воспроизведением.
|
||||
«Музыка из переписки» использует `getDirectMessages?music=true` с обычным `before`:
|
||||
доступна вся история вложений, удалённые сообщения исключены, прочтение не подтверждается.
|
||||
Недоступные треки и отозванные ссылки не запускаются; метаданные берутся из каталога
|
||||
и публичной проекции плейлиста, а не из текста сообщения.
|
||||
Анимации учитывают системное уменьшение движения. Фото, файлы и голосовые не входят в версию.
|
||||
|
||||
Миграции `00020`–`00022` добавляют активность, комнаты и возможности чата.
|
||||
|
||||
@@ -1974,6 +1974,20 @@ export interface components {
|
||||
relationship: "none" | "incoming" | "outgoing" | "friend" | "blocked";
|
||||
unread: number;
|
||||
activity?: components["schemas"]["ListeningActivity"];
|
||||
last_message?: components["schemas"]["SocialMessagePreview"];
|
||||
};
|
||||
/** @description Latest message in a current friendship, without marking it read. Deleted content is never returned. */
|
||||
SocialMessagePreview: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
sender_id: string;
|
||||
text: string;
|
||||
/** @enum {string} */
|
||||
attachment_kind: "none" | "track" | "playlist";
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
deleted: boolean;
|
||||
};
|
||||
SocialNotification: {
|
||||
/** Format: uuid */
|
||||
@@ -3466,6 +3480,8 @@ export interface operations {
|
||||
query?: {
|
||||
before?: number;
|
||||
q?: string;
|
||||
/** @description Only non-deleted track and playlist attachments, with the same history pagination. */
|
||||
music?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
|
||||
@@ -28,7 +28,7 @@ export function socialApi(session: AuthSession) {
|
||||
activityPlaylist: async (userId: string) => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })),
|
||||
search: async (q: string) => unwrap(await session.client.GET('/api/v1/me/social/people', { params: { query: { q } } })),
|
||||
action: async (body: SocialAction) => unwrap(await session.client.POST('/api/v1/me/social/actions', { body })),
|
||||
messages: async (peerId: string, before?: number, q?: string) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before, q } } })),
|
||||
messages: async (peerId: string, before?: number, q?: string, music?: boolean) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before, q, music } } })),
|
||||
chatMeta: async (peerId: string) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } } })),
|
||||
typing: async (peerId: string, typing: boolean) => unwrap(await session.client.PUT('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } }, body: { typing } })),
|
||||
updateMessage: async (peerId: string, messageId: string, body: ChatMessageUpdate) => unwrap(await session.client.PATCH('/api/v1/me/social/chats/{peerId}/messages/{messageId}', { params: { path: { peerId, messageId } }, body })),
|
||||
|
||||
@@ -387,6 +387,36 @@
|
||||
"no_results": "No playlists found"
|
||||
},
|
||||
"social": {
|
||||
"space": "In your own rhythm",
|
||||
"requests": "Requests",
|
||||
"find_people": "Find friends",
|
||||
"find_conversation": "Find a conversation",
|
||||
"find_request": "Find a request",
|
||||
"no_requests": "No requests yet. Incoming and outgoing friend requests will appear here.",
|
||||
"status_hidden": "My activity is hidden",
|
||||
"privacy_settings": "Privacy and social",
|
||||
"blocked_people": "Blocked people",
|
||||
"confirm_block": "Block {{name}}?",
|
||||
"confirm_remove": "Remove {{name}} as a friend?",
|
||||
"block_hint": "This ends your friendship and disables messaging and listening together. Unblocking does not automatically restore your friendship.",
|
||||
"remove_hint": "Your conversation is saved. Messaging becomes available again after a new mutual friend request.",
|
||||
"person_actions": "Actions for {{name}}",
|
||||
"you": "You",
|
||||
"shared_track": "Track",
|
||||
"shared_playlist": "Playlist",
|
||||
"start_conversation": "Start a conversation",
|
||||
"conversation_title": "Music brings us closer",
|
||||
"conversation_content": "Conversation content",
|
||||
"conversation": "Conversation",
|
||||
"shared_music": "Shared music",
|
||||
"no_shared_music": "Shared tracks and playlists will appear here. Share music from the player or a playlist page.",
|
||||
"back_to_messages": "Back to messages",
|
||||
"close_search": "Close search",
|
||||
"latest_messages": "Latest messages",
|
||||
"attachment_unavailable": "Music is unavailable or the link was revoked",
|
||||
"message_placeholder": "Message…",
|
||||
"show_more": "Show full text",
|
||||
"show_less": "Collapse text",
|
||||
"title": "Friends & messages",
|
||||
"subtitle": "Music worth sharing. Conversations at your own pace.",
|
||||
"people": "Friends",
|
||||
|
||||
@@ -387,6 +387,36 @@
|
||||
"no_results": "Плейлисты не найдены"
|
||||
},
|
||||
"social": {
|
||||
"space": "В своём ритме",
|
||||
"requests": "Заявки",
|
||||
"find_people": "Найти друзей",
|
||||
"find_conversation": "Найти диалог",
|
||||
"find_request": "Найти заявку",
|
||||
"no_requests": "Новых заявок пока нет. Здесь появятся входящие и отправленные приглашения в друзья.",
|
||||
"status_hidden": "Мой статус скрыт",
|
||||
"privacy_settings": "Приватность и общение",
|
||||
"blocked_people": "Заблокированные",
|
||||
"confirm_block": "Заблокировать {{name}}?",
|
||||
"confirm_remove": "Удалить {{name}} из друзей?",
|
||||
"block_hint": "Дружба прекратится, сообщения и совместное прослушивание станут недоступны. Разблокировка не восстановит дружбу автоматически.",
|
||||
"remove_hint": "Переписка сохранится, но общение будет доступно только после новой взаимной заявки в друзья.",
|
||||
"person_actions": "Действия с пользователем {{name}}",
|
||||
"you": "Вы",
|
||||
"shared_track": "Трек",
|
||||
"shared_playlist": "Плейлист",
|
||||
"start_conversation": "Начать разговор",
|
||||
"conversation_title": "Музыка сближает",
|
||||
"conversation_content": "Содержимое переписки",
|
||||
"conversation": "Переписка",
|
||||
"shared_music": "Музыка из переписки",
|
||||
"no_shared_music": "Отправленные треки и плейлисты появятся здесь. Поделитесь музыкой через плеер или страницу плейлиста.",
|
||||
"back_to_messages": "Назад к сообщениям",
|
||||
"close_search": "Закрыть поиск",
|
||||
"latest_messages": "К новым сообщениям",
|
||||
"attachment_unavailable": "Музыка недоступна или ссылка отозвана",
|
||||
"message_placeholder": "Сообщение…",
|
||||
"show_more": "Показать полностью",
|
||||
"show_less": "Свернуть текст",
|
||||
"title": "Друзья и общение",
|
||||
"subtitle": "Музыка, которой хочется делиться. Разговоры — в своём ритме.",
|
||||
"people": "Друзья",
|
||||
|
||||
@@ -237,6 +237,13 @@ const (
|
||||
Unblock SocialActionAction = "unblock"
|
||||
)
|
||||
|
||||
// Defines values for SocialMessagePreviewAttachmentKind.
|
||||
const (
|
||||
SocialMessagePreviewAttachmentKindNone SocialMessagePreviewAttachmentKind = "none"
|
||||
SocialMessagePreviewAttachmentKindPlaylist SocialMessagePreviewAttachmentKind = "playlist"
|
||||
SocialMessagePreviewAttachmentKindTrack SocialMessagePreviewAttachmentKind = "track"
|
||||
)
|
||||
|
||||
// Defines values for SocialNotificationKind.
|
||||
const (
|
||||
SocialNotificationKindFriendAccepted SocialNotificationKind = "friend_accepted"
|
||||
@@ -250,11 +257,11 @@ const (
|
||||
|
||||
// Defines values for SocialPersonRelationship.
|
||||
const (
|
||||
SocialPersonRelationshipBlocked SocialPersonRelationship = "blocked"
|
||||
SocialPersonRelationshipFriend SocialPersonRelationship = "friend"
|
||||
SocialPersonRelationshipIncoming SocialPersonRelationship = "incoming"
|
||||
SocialPersonRelationshipNone SocialPersonRelationship = "none"
|
||||
SocialPersonRelationshipOutgoing SocialPersonRelationship = "outgoing"
|
||||
Blocked SocialPersonRelationship = "blocked"
|
||||
Friend SocialPersonRelationship = "friend"
|
||||
Incoming SocialPersonRelationship = "incoming"
|
||||
None SocialPersonRelationship = "none"
|
||||
Outgoing SocialPersonRelationship = "outgoing"
|
||||
)
|
||||
|
||||
// Defines values for SourceStatus.
|
||||
@@ -1373,6 +1380,19 @@ type SocialInbox struct {
|
||||
Unread int `json:"unread"`
|
||||
}
|
||||
|
||||
// SocialMessagePreview Latest message in a current friendship, without marking it read. Deleted content is never returned.
|
||||
type SocialMessagePreview struct {
|
||||
AttachmentKind SocialMessagePreviewAttachmentKind `json:"attachment_kind"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Deleted bool `json:"deleted"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
SenderId openapi_types.UUID `json:"sender_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// SocialMessagePreviewAttachmentKind defines model for SocialMessagePreview.AttachmentKind.
|
||||
type SocialMessagePreviewAttachmentKind string
|
||||
|
||||
// SocialNotification defines model for SocialNotification.
|
||||
type SocialNotification struct {
|
||||
ActorId openapi_types.UUID `json:"actor_id"`
|
||||
@@ -1388,9 +1408,12 @@ type SocialNotificationKind string
|
||||
|
||||
// SocialPerson defines model for SocialPerson.
|
||||
type SocialPerson struct {
|
||||
Activity *ListeningActivity `json:"activity,omitempty"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
Activity *ListeningActivity `json:"activity,omitempty"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
|
||||
// LastMessage Latest message in a current friendship, without marking it read. Deleted content is never returned.
|
||||
LastMessage *SocialMessagePreview `json:"last_message,omitempty"`
|
||||
Relationship SocialPersonRelationship `json:"relationship"`
|
||||
Unread int `json:"unread"`
|
||||
Username string `json:"username"`
|
||||
@@ -2136,6 +2159,9 @@ type SetActivityVisibilityJSONBody struct {
|
||||
type GetDirectMessagesParams struct {
|
||||
Before *int64 `form:"before,omitempty" json:"before,omitempty"`
|
||||
Q *string `form:"q,omitempty" json:"q,omitempty"`
|
||||
|
||||
// Music Only non-deleted track and playlist attachments, with the same history pagination.
|
||||
Music *bool `form:"music,omitempty" json:"music,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateChatMessageJSONBody defines parameters for UpdateChatMessage.
|
||||
@@ -5817,6 +5843,14 @@ func (siw *ServerInterfaceWrapper) GetDirectMessages(w http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
|
||||
// ------------- Optional query parameter "music" -------------
|
||||
|
||||
err = runtime.BindQueryParameter("form", true, false, "music", r.URL.Query(), ¶ms.Music)
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "music", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetDirectMessages(w, r, peerId, params)
|
||||
}))
|
||||
@@ -14401,377 +14435,380 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
"6hT6/dbdW3f/vwAAAP//SJiqB93gAQA=",
|
||||
"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",
|
||||
"AA==",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -87,7 +87,7 @@ func (h *Handler) GetDirectMessages(ctx context.Context, r gen.GetDirectMessages
|
||||
if utf8.RuneCountInString(q) > 100 {
|
||||
return nil, invalid()
|
||||
}
|
||||
v, e := h.store.messagePage(ctx, id, r.PeerId, r.Params.Before, q)
|
||||
v, e := h.store.filteredMessages(ctx, id, r.PeerId, r.Params.Before, q, r.Params.Music != nil && *r.Params.Music)
|
||||
return gen.GetDirectMessages200JSONResponse(v), e
|
||||
}
|
||||
func validateMessage(m gen.SendDirectMessage) error {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"pipu/api/internal/api/gen"
|
||||
)
|
||||
|
||||
// Read all previews in one query. Recheck friendship and both block directions
|
||||
// here so a removed relationship cannot expose history through the inbox.
|
||||
func (s *Store) attachLastMessages(ctx context.Context, viewer uuid.UUID, people []gen.SocialPerson) error {
|
||||
positions := map[uuid.UUID]int{}
|
||||
ids := []uuid.UUID{}
|
||||
for i, p := range people {
|
||||
if p.Relationship == "friend" {
|
||||
positions[p.Id] = i
|
||||
ids = append(ids, p.Id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, e := s.pool.Query(ctx, `SELECT p.peer_id,m.id,m.sender_id,
|
||||
CASE WHEN m.deleted_at IS NULL THEN left(m.text,160) ELSE '' END,
|
||||
m.attachment_kind,m.created_at,m.deleted_at IS NOT NULL
|
||||
FROM unnest($2::uuid[]) AS p(peer_id)
|
||||
JOIN friendships f ON f.low_id=LEAST($1::uuid,p.peer_id) AND f.high_id=GREATEST($1::uuid,p.peer_id) AND f.accepted
|
||||
JOIN LATERAL (SELECT * FROM direct_messages
|
||||
WHERE LEAST(sender_id,recipient_id)=LEAST($1::uuid,p.peer_id)
|
||||
AND GREATEST(sender_id,recipient_id)=GREATEST($1::uuid,p.peer_id)
|
||||
ORDER BY seq DESC LIMIT 1) m ON true
|
||||
WHERE NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=p.peer_id) OR (b.peer_id=$1 AND b.user_id=p.peer_id))`, viewer, ids)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var peer uuid.UUID
|
||||
var preview gen.SocialMessagePreview
|
||||
if e = rows.Scan(&peer, &preview.Id, &preview.SenderId, &preview.Text, &preview.AttachmentKind, &preview.CreatedAt, &preview.Deleted); e != nil {
|
||||
return e
|
||||
}
|
||||
people[positions[peer]].LastMessage = &preview
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
@@ -67,6 +67,9 @@ func TestSocialIsolationAndLifecycle(t *testing.T) {
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, 1, inbox.Unread)
|
||||
// Another user cannot acknowledge this user's notification.
|
||||
require.NotNil(t, inbox.People[0].LastMessage)
|
||||
require.Equal(t, m.Id, inbox.People[0].LastMessage.Id)
|
||||
require.Equal(t, "Привет", inbox.People[0].LastMessage.Text)
|
||||
require.NoError(t, s.action(ctx, c, gen.SocialAction{Action: "read_notification", TargetId: inbox.Notifications[0].Id}))
|
||||
inbox, e = s.inbox(ctx, b)
|
||||
require.NoError(t, e)
|
||||
@@ -82,6 +85,11 @@ func TestSocialIsolationAndLifecycle(t *testing.T) {
|
||||
before, e := s.messages(ctx, b, a, &second.Seq)
|
||||
require.NoError(t, e)
|
||||
require.Len(t, before, 1)
|
||||
require.NoError(t, s.updateMessage(ctx, a, b, second.Id, gen.UpdateChatMessageJSONRequestBody{Action: "delete"}))
|
||||
inbox, e = s.inbox(ctx, b)
|
||||
require.NoError(t, e)
|
||||
require.True(t, inbox.People[0].LastMessage.Deleted)
|
||||
require.Empty(t, inbox.People[0].LastMessage.Text)
|
||||
require.NoError(t, action(b, a, "block"))
|
||||
require.NoError(t, action(a, b, "block"))
|
||||
for _, id := range []uuid.UUID{a, b} {
|
||||
@@ -89,6 +97,7 @@ func TestSocialIsolationAndLifecycle(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, blocked.People, 1)
|
||||
require.EqualValues(t, "blocked", blocked.People[0].Relationship)
|
||||
require.Nil(t, blocked.People[0].LastMessage)
|
||||
}
|
||||
require.NoError(t, action(a, b, "unblock"))
|
||||
_, e = s.send(ctx, a, b, gen.SendDirectMessage{Id: uuid.New(), Text: "blocked", AttachmentKind: "none"})
|
||||
@@ -118,6 +127,20 @@ func TestSocialIsolationAndLifecycle(t *testing.T) {
|
||||
attachment, e := s.send(ctx, a, c, gen.SendDirectMessage{Id: uuid.New(), AttachmentKind: "playlist", AttachmentId: &share})
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, &share, attachment.AttachmentId)
|
||||
_, e = s.send(ctx, a, c, gen.SendDirectMessage{Id: uuid.New(), Text: "After the music", AttachmentKind: "none"})
|
||||
require.NoError(t, e)
|
||||
music, e := s.filteredMessages(ctx, c, a, nil, "", true)
|
||||
require.NoError(t, e)
|
||||
require.Len(t, music, 1)
|
||||
require.Equal(t, attachment.Id, music[0].Id)
|
||||
require.False(t, music[0].Read, "browsing shared music must not acknowledge messages")
|
||||
music, e = s.filteredMessages(ctx, c, a, &attachment.Seq, "", true)
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, music)
|
||||
require.NoError(t, s.updateMessage(ctx, a, c, attachment.Id, gen.UpdateChatMessageJSONRequestBody{Action: "delete"}))
|
||||
music, e = s.filteredMessages(ctx, c, a, nil, "", true)
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, music, "deleted music must not appear in shared attachments")
|
||||
_, e = pool.Exec(ctx, `UPDATE playlists SET share_id=NULL WHERE id=$1`, playlist)
|
||||
require.NoError(t, e)
|
||||
_, e = s.send(ctx, a, c, gen.SendDirectMessage{Id: uuid.New(), AttachmentKind: "playlist", AttachmentId: &share})
|
||||
|
||||
@@ -92,6 +92,9 @@ func (s *Store) inbox(ctx context.Context, id uuid.UUID) (gen.SocialInbox, error
|
||||
if e = s.attachActivity(ctx, id, out.People); e != nil {
|
||||
return out, e
|
||||
}
|
||||
if e = s.attachLastMessages(ctx, id, out.People); e != nil {
|
||||
return out, e
|
||||
}
|
||||
if e = s.pool.QueryRow(ctx, `SELECT activity_visibility,friends_recommendations FROM users WHERE id=$1`, id).Scan(&out.ActivityVisibility, &out.FriendsRecommendations); e != nil {
|
||||
return out, e
|
||||
}
|
||||
@@ -238,6 +241,9 @@ func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64)
|
||||
return s.messagePage(ctx, id, peer, before, "")
|
||||
}
|
||||
func (s *Store) messagePage(ctx context.Context, id, peer uuid.UUID, before *int64, q string) ([]gen.DirectMessage, error) {
|
||||
return s.filteredMessages(ctx, id, peer, before, q, false)
|
||||
}
|
||||
func (s *Store) filteredMessages(ctx context.Context, id, peer uuid.UUID, before *int64, q string, music bool) ([]gen.DirectMessage, error) {
|
||||
tx, e := pairTx(ctx, s.pool, id, peer)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
@@ -246,7 +252,7 @@ func (s *Store) messagePage(ctx context.Context, id, peer uuid.UUID, before *int
|
||||
if e = allowed(ctx, tx, id, peer); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
rows, e := tx.Query(ctx, `SELECT `+messageColumns+` FROM direct_messages WHERE LEAST(sender_id,recipient_id)=LEAST($1::uuid,$2::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($1::uuid,$2::uuid) AND ($3::bigint IS NULL OR seq<$3) AND ($4='' OR (deleted_at IS NULL AND strpos(lower(text),lower($4))>0)) ORDER BY seq DESC LIMIT 50`, id, peer, before, q)
|
||||
rows, e := tx.Query(ctx, `SELECT `+messageColumns+` FROM direct_messages WHERE LEAST(sender_id,recipient_id)=LEAST($1::uuid,$2::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($1::uuid,$2::uuid) AND ($3::bigint IS NULL OR seq<$3) AND ($4='' OR (deleted_at IS NULL AND strpos(lower(text),lower($4))>0)) AND (NOT $5::boolean OR (deleted_at IS NULL AND attachment_kind IN ('track','playlist'))) ORDER BY seq DESC LIMIT 50`, id, peer, before, q, music)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user