diff --git a/api/openapi.yaml b/api/openapi.yaml index 015f42d..bcb64c7 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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] diff --git a/apps/web/e2e/social.spec.ts b/apps/web/e2e/social.spec.ts index 23d013f..df24e22 100644 --- a/apps/web/e2e/social.spec.ts +++ b/apps/web/e2e/social.spec.ts @@ -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(); }); } diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 01b9c13..0ab0b7c 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -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: 'Отменить заявку' }); diff --git a/apps/web/src/features/social/ChatConversation.tsx b/apps/web/src/features/social/ChatConversation.tsx index 7df44df..9cec588 100644 --- a/apps/web/src/features/social/ChatConversation.tsx +++ b/apps/web/src/features/social/ChatConversation.tsx @@ -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; 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; 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(null); const [editing, setEditing] = useState(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(null); + const [editing, setEditing] = useState(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(null); + const scroll = useRef(null); + const previousLast = useRef(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(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
- {!compact &&
{person.display_name}

{t('social.chat_hint')}

} -
+
+ {onBack &&
+ {person.activity &&
{t('social.listening')}{person.activity.track_title}{person.activity.artist ? ` · ${person.activity.artist}` : ''}{person.activity.context === 'playlist' && person.activity.playlist_id && {t('social.from_playlist', { name: person.activity.playlist_name ?? '' })}}
} + + {searchOpen &&
} {messages.error || send.error || update.error ? errorText(messages.error ?? send.error ?? update.error) : null} -
- {messages.hasNextPage && } - {messages.isPending &&

{t('social.loading')}

} - {!list.length && messages.isSuccess &&

{t(q ? 'social.chat_no_results' : 'social.first_message')}

} +
{ const el = e.currentTarget; setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 64); }}> + {messages.hasNextPage && } + {messages.isPending &&
} + {!list.length && messages.isSuccess && {t(q ? 'social.chat_no_results' : music ? 'social.no_shared_music' : 'social.first_message')}} {list.map((m, i) => {(i === 0 || new Date(list[i - 1]!.created_at).toDateString() !== new Date(m.created_at).toDateString()) && } - {m.id === firstUnread && !q &&
{t('social.new_messages')}
} -
- {m.reply_to &&
{m.reply_preview || t('social.message_deleted')}
} -
{m.deleted ? t('social.message_deleted') : m.text}
- {!m.deleted && attachmentPath(m) && {t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} ↗} + {m.id === firstUnread && !q && !music &&
{t('social.new_messages')}
} +
+ {m.reply_to && !music &&
{m.reply_preview || t('social.message_deleted')}
} + {(m.deleted || m.text && m.attachment_kind === 'none') &&
{m.deleted ? t('social.message_deleted') : }
} + {!m.deleted && m.attachment_id && m.attachment_kind !== 'none' && } {!m.deleted && !!m.reactions?.length &&
{m.reactions.map(r => )}
}
{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')}` : ''} - {!m.deleted && { if (editing) cancelEdit(); setReply(m); } }, + {!m.deleted && } 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' }) }] : []), ]} />}
)} - {send.isPending && !editing && !list.some(m => m.id === pending.current?.id) &&
{text} · …
} -
+ {send.isPending && !!text.trim() && !editing && !music && !list.some(m => m.id === pending.current?.id) &&
{text} · …
}
-
{active && meta.data?.typing ? `${person.display_name} ${t('social.typing')}` : ''}
- {(reply || editing) &&
{t(editing ? 'social.editing' : 'social.replying')}

{(editing ?? reply)?.text}

} -
{ e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}> -