feat(web): чат Фазы 2 — лента, композер, реакции, ответы, поиск и вложения

Полноценный текстовый чат в веб-клиенте:

- стор сообщений по комнатам (кэш 200, оптимистичная отправка с nonce,
  применение всех событий Gateway, пагинация курсором, непрочитанные и
  упоминания, сброс при логауте и INVALID_SESSION);
- лента: группировка подряд идущих сообщений одного автора, разделители дат,
  разделитель «новые сообщения», подгрузка истории вверх, пустые состояния;
- разметка: жирный/курсив/код/блок кода/зачёркнутый/цитата/ссылки (только
  http(s)) и упоминания <@id> — безопасный рендер React-узлами без
  dangerouslySetInnerHTML;
- реакции чипами с подсветкой своих, быстрые эмодзи и пикер, панель действий и
  контекстное меню (ответ, реакция, закрепление, правка, удаление, копирование);
- ответы с переходом к оригиналу, inline-правка, «печатает…» с POST /typing раз
  в 3 с, пины в модалке, поиск по комнате с подсветкой и переходом;
- композер: автовысота, Enter/Shift+Enter, лимит 4000, drag&drop и скрепка
  (POST /channels/{id}/files → attachment_ids), обработка 429 с таймером и
  perm.denied с причиной, read-only без SEND_MESSAGES;
- бейджи непрочитанных и упоминаний в сайдбаре, авто-POST /ack при активном окне;
- 176 тестов Vitest (было 100), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 00:15:18 +03:00
parent b22db8f048
commit b01eb1f34c
34 changed files with 6417 additions and 52 deletions
+25
View File
@@ -40,6 +40,31 @@ export function apiErrorCode(error: unknown): string | null {
return isApiErrorPayload(payload) ? payload.code : null;
}
/**
* Дополнительное поле конверта ошибки (например, `retry_after_ms` у 429).
*
* Сервер кладёт детали рядом с `code`/`message`, а не во вложенный `details`,
* поэтому ищем значение в исходном конверте; `details` остаётся запасным
* вариантом для ошибок, собранных вручную.
*/
export function apiErrorField(error: unknown, key: string): unknown {
if (error === null || typeof error !== 'object') {
return undefined;
}
const record = error as { apiError?: unknown; details?: unknown };
if (isApiErrorPayload(record.apiError)) {
const value = (record.apiError as unknown as Record<string, unknown>)[key];
if (value !== undefined) {
return value;
}
}
const details = record.details;
if (typeof details === 'object' && details !== null) {
return (details as Record<string, unknown>)[key];
}
return undefined;
}
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
+65
View File
@@ -0,0 +1,65 @@
import { ApiError, isApiErrorPayload, parsePayload } from './client';
/**
* Загрузка файлов в комнату (AGENT.md §7.7).
*
* `request()` из `client.ts` умеет только JSON, а здесь тело — multipart,
* поэтому запрос собирается напрямую; разбор ошибок остаётся общим
* (`ApiError` + конверт `{"error":{"code","message"}}`).
*/
export interface UploadedFile {
file_id: string;
filename: string;
content_type: string;
size_bytes: number;
/** URL из ответа сервера (может вести на files.<домен>). */
url: string;
}
const API_PREFIX = '/api/v1';
export async function uploadChannelFile(
channelId: string,
file: File,
signal?: AbortSignal,
): Promise<UploadedFile> {
const form = new FormData();
form.append('file', file);
const response = await fetch(`${API_PREFIX}/channels/${encodeURIComponent(channelId)}/files`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json' },
body: form,
...(signal === undefined ? {} : { signal }),
});
const text = await response.text();
const payload = parsePayload(text, response.headers.get('Content-Type'));
if (!response.ok) {
const envelope =
typeof payload === 'object' && payload !== null
? (payload as { error?: unknown }).error
: undefined;
if (isApiErrorPayload(envelope)) {
throw new ApiError(response.status, envelope.code, envelope.message, envelope.details);
}
throw new ApiError(response.status, 'internal.error', 'unexpected response');
}
const uploaded = (payload as { file?: UploadedFile } | null)?.file;
if (uploaded === undefined || typeof uploaded.file_id !== 'string') {
throw new ApiError(response.status, 'internal.error', 'unexpected response');
}
return uploaded;
}
/**
* Ссылка на содержимое файла. Файловый сервис отдаётся тем же origin, а URL
* из ответа может указывать на отдельный поддомен, поэтому предпочитаем
* относительный путь — он работает и в dev-прокси, и на стенде.
*/
export function fileContentUrl(fileId: string): string {
return `/files/${encodeURIComponent(fileId)}`;
}
+194
View File
@@ -1,5 +1,6 @@
import { userStatuses, type Channel, type UserStatus } from './types';
import type { GatewayUser } from './gateway';
import type { Message, MessageAttachment, MessageReaction } from './messages';
/**
* Разбор payload'ов событий шлюза (AGENT.md §8.3).
@@ -227,3 +228,196 @@ export function parseChannelPayload(value: unknown): Channel | null {
}
return channel;
}
/* ------------------------------------------------------------------ */
/* События сообщений Фазы 2 (AGENT.md §8.3) */
/* ------------------------------------------------------------------ */
export interface MessageDeleteEvent {
id: string;
channel_id: string;
}
export interface ReactionEvent {
channel_id: string;
message_id: string;
user_id: string;
emoji: string;
}
export interface PinsUpdateEvent {
channel_id: string;
message_id: string;
pinned: boolean;
}
export interface TypingStartEvent {
channel_id: string;
user_id: string;
}
export interface ReadStateEvent {
channel_id: string;
last_message_id?: string;
mention_count: number;
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function parseAttachment(value: unknown): MessageAttachment | null {
const record = asRecord(value);
const fileId = asString(record?.['file_id']);
const filename = asString(record?.['filename']);
if (record === null || fileId === undefined || filename === undefined) {
return null;
}
const attachment: MessageAttachment = { file_id: fileId, filename };
const contentType = asString(record['content_type']);
const sizeBytes = asNumber(record['size_bytes']);
const width = asNumber(record['width']);
const height = asNumber(record['height']);
if (contentType !== undefined) {
attachment.content_type = contentType;
}
if (sizeBytes !== undefined) {
attachment.size_bytes = sizeBytes;
}
if (width !== undefined) {
attachment.width = width;
}
if (height !== undefined) {
attachment.height = height;
}
return attachment;
}
function parseReaction(value: unknown): MessageReaction | null {
const record = asRecord(value);
const emoji = asString(record?.['emoji']);
if (record === null || emoji === undefined) {
return null;
}
const reaction: MessageReaction = {
emoji,
count: asNumber(record['count']) ?? 0,
me: record['me'] === true,
};
const userIds = asStringArray(record['user_ids']);
if (userIds !== undefined) {
reaction.user_ids = userIds;
}
return reaction;
}
/** Сообщение — та же форма, что в REST (payload `MESSAGE_CREATE`/`MESSAGE_UPDATE`). */
export function parseMessagePayload(value: unknown): Message | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const channelId = asString(record['channel_id']);
const createdAt = asString(record['created_at']);
if (id === undefined || channelId === undefined || createdAt === undefined) {
return null;
}
const message: Message = {
id,
channel_id: channelId,
content: typeof record['content'] === 'string' ? record['content'] : '',
type: asString(record['type']) ?? 'default',
pinned: record['pinned'] === true,
attachments: Array.isArray(record['attachments'])
? record['attachments']
.map(parseAttachment)
.filter((item): item is MessageAttachment => item !== null)
: [],
mentions: asStringArray(record['mentions']) ?? [],
reactions: Array.isArray(record['reactions'])
? record['reactions']
.map(parseReaction)
.filter((item): item is MessageReaction => item !== null)
: [],
created_at: createdAt,
};
const authorId = asString(record['author_id']);
const replyToId = asString(record['reply_to_id']);
const editedAt = asString(record['edited_at']);
if (authorId !== undefined) {
message.author_id = authorId;
}
if (replyToId !== undefined) {
message.reply_to_id = replyToId;
}
if (editedAt !== undefined) {
message.edited_at = editedAt;
}
return message;
}
export function parseMessageDelete(value: unknown): MessageDeleteEvent | null {
const record = asRecord(value);
const id = asString(record?.['id']);
const channelId = asString(record?.['channel_id']);
if (record === null || id === undefined || channelId === undefined) {
return null;
}
return { id, channel_id: channelId };
}
export function parseReactionEvent(value: unknown): ReactionEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
const messageId = asString(record?.['message_id']);
const userId = asString(record?.['user_id']);
const emoji = asString(record?.['emoji']);
if (
record === null ||
channelId === undefined ||
messageId === undefined ||
userId === undefined ||
emoji === undefined
) {
return null;
}
return { channel_id: channelId, message_id: messageId, user_id: userId, emoji };
}
export function parsePinsUpdate(value: unknown): PinsUpdateEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
const messageId = asString(record?.['message_id']);
if (record === null || channelId === undefined || messageId === undefined) {
return null;
}
return { channel_id: channelId, message_id: messageId, pinned: record['pinned'] === true };
}
export function parseTypingStart(value: unknown): TypingStartEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
const userId = asString(record?.['user_id']);
if (record === null || channelId === undefined || userId === undefined) {
return null;
}
return { channel_id: channelId, user_id: userId };
}
export function parseReadStateUpdate(value: unknown): ReadStateEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
if (record === null || channelId === undefined) {
return null;
}
const event: ReadStateEvent = {
channel_id: channelId,
mention_count: asNumber(record['mention_count']) ?? 0,
};
const lastMessageId = asString(record['last_message_id']);
if (lastMessageId !== undefined) {
event.last_message_id = lastMessageId;
}
return event;
}
+238
View File
@@ -0,0 +1,238 @@
import { apiErrorField, request } from './client';
/**
* Ручки сообщений Фазы 2 (AGENT.md §7.6, §7.16).
*
* Сообщения приходят с сервера от новых к старым (`GET /messages`) — порядок
* нормализует стор (`stores/messages.ts`), здесь только транспорт и типы.
*/
export const MESSAGE_PAGE_SIZE = 50;
/** Лимит поиска из контракта (`limit=25`). */
export const MESSAGE_SEARCH_LIMIT = 25;
/** Предельная длина сообщения (совпадает с `maxLength` сервера). */
export const MESSAGE_MAX_LENGTH = 4000;
export interface MessageAttachment {
file_id: string;
filename: string;
content_type?: string;
size_bytes?: number;
width?: number;
height?: number;
}
export interface MessageReaction {
emoji: string;
count: number;
/** Поставил ли реакцию текущий пользователь. */
me: boolean;
user_ids?: string[];
}
export interface Message {
id: string;
channel_id: string;
author_id?: string;
content: string;
reply_to_id?: string;
/** `default` | `system` | `whisper` — контракт допускает расширение. */
type: string;
edited_at?: string;
pinned: boolean;
attachments: MessageAttachment[];
mentions: string[];
reactions: MessageReaction[];
created_at: string;
}
/** Оптимистичное сообщение: сервер ещё не подтвердил отправку. */
export interface PendingMessage extends Message {
pending: true;
nonce: string;
}
export function isPendingMessage(message: Message): message is PendingMessage {
return (message as { pending?: unknown }).pending === true;
}
export interface SendMessageInput {
content: string;
reply_to_id?: string;
attachment_ids?: string[];
/** Клиентский идентификатор для сопоставления оптимистичного сообщения. */
nonce?: string;
}
export interface FetchMessagesOptions {
/** Курсор истории: id самого старого загруженного сообщения. */
before?: string;
limit?: number;
}
function messagesOf(payload: { messages?: Message[] }): Message[] {
return Array.isArray(payload.messages) ? payload.messages : [];
}
/** История комнаты: от новых к старым, `before` — курсор вверх. */
export async function fetchChannelMessages(
channelId: string,
options: FetchMessagesOptions = {},
signal?: AbortSignal,
): Promise<Message[]> {
const params = new URLSearchParams();
params.set('limit', String(options.limit ?? MESSAGE_PAGE_SIZE));
if (options.before !== undefined && options.before !== '') {
params.set('before', options.before);
}
const payload = await request<{ messages: Message[] }>(
`/channels/${encodeURIComponent(channelId)}/messages?${params.toString()}`,
signal === undefined ? {} : { signal },
);
return messagesOf(payload);
}
export async function sendChannelMessage(
channelId: string,
input: SendMessageInput,
): Promise<Message> {
const payload = await request<{ message: Message }>(
`/channels/${encodeURIComponent(channelId)}/messages`,
{ method: 'POST', body: input },
);
return payload.message;
}
export async function searchChannelMessages(
channelId: string,
query: string,
limit = MESSAGE_SEARCH_LIMIT,
signal?: AbortSignal,
): Promise<Message[]> {
const params = new URLSearchParams();
params.set('q', query);
params.set('limit', String(limit));
const payload = await request<{ messages: Message[] }>(
`/channels/${encodeURIComponent(channelId)}/messages/search?${params.toString()}`,
signal === undefined ? {} : { signal },
);
return messagesOf(payload);
}
export async function editChannelMessage(
channelId: string,
messageId: string,
content: string,
): Promise<Message> {
const payload = await request<{ message: Message }>(
`/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`,
{ method: 'PATCH', body: { content } },
);
return payload.message;
}
export async function deleteChannelMessage(channelId: string, messageId: string): Promise<void> {
await request<{ ok: true }>(
`/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`,
{ method: 'DELETE' },
);
}
/** Реакция: эмодзи едет в пути, поэтому его нужно кодировать. */
export async function addMessageReaction(
channelId: string,
messageId: string,
emoji: string,
): Promise<void> {
await request<{ ok: true }>(reactionPath(channelId, messageId, emoji), { method: 'PUT' });
}
export async function removeMessageReaction(
channelId: string,
messageId: string,
emoji: string,
): Promise<void> {
await request<{ ok: true }>(reactionPath(channelId, messageId, emoji), { method: 'DELETE' });
}
function reactionPath(channelId: string, messageId: string, emoji: string): string {
return `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(
messageId,
)}/reactions/${encodeURIComponent(emoji)}`;
}
export async function fetchChannelPins(
channelId: string,
signal?: AbortSignal,
): Promise<Message[]> {
const payload = await request<{ messages: Message[] }>(
`/channels/${encodeURIComponent(channelId)}/pins`,
signal === undefined ? {} : { signal },
);
return messagesOf(payload);
}
export async function pinChannelMessage(channelId: string, messageId: string): Promise<void> {
await request<{ ok: true }>(
`/channels/${encodeURIComponent(channelId)}/pins/${encodeURIComponent(messageId)}`,
{ method: 'PUT' },
);
}
export async function unpinChannelMessage(channelId: string, messageId: string): Promise<void> {
await request<{ ok: true }>(
`/channels/${encodeURIComponent(channelId)}/pins/${encodeURIComponent(messageId)}`,
{ method: 'DELETE' },
);
}
/** «Печатает…»: клиент шлёт не чаще раза в ~3 с, пока идёт ввод. */
export async function sendTypingIndicator(channelId: string): Promise<void> {
await request<{ ok: true }>(`/channels/${encodeURIComponent(channelId)}/typing`, {
method: 'POST',
});
}
/** Отметка прочтения комнаты (без `last_message_id` — «прочитано всё»). */
export async function acknowledgeChannel(channelId: string, lastMessageId?: string): Promise<void> {
await request<{ ok: true }>(`/channels/${encodeURIComponent(channelId)}/ack`, {
method: 'POST',
body: lastMessageId === undefined ? {} : { last_message_id: lastMessageId },
});
}
/**
* Пауза из ответа 429. Сервер кладёт `retry_after_ms` рядом с `code`/`message`
* в конверте ошибки, поэтому читаем поле через `apiErrorField`.
*/
export function retryAfterMs(error: unknown): number | null {
const value = apiErrorField(error, 'retry_after_ms');
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.round(value);
}
return null;
}
/** Идентификатор оптимистичного сообщения до подтверждения сервером. */
export function createNonce(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
try {
return crypto.randomUUID();
} catch {
// Некоторые окружения запрещают randomUUID вне secure context.
}
}
return `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
/** Упоминания вида `<@123>`: сервер извлекает их тем же шаблоном. */
export function extractMentions(content: string): string[] {
const found = new Set<string>();
for (const match of content.matchAll(/<@(\d+)>/gu)) {
const id = match[1];
if (id !== undefined) {
found.add(id);
}
}
return [...found];
}
+42
View File
@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next';
import { useChannelUnread } from '@/components/chat/useChannelUnread';
interface ChannelBadgeProps {
channelId: string;
/** Активная комната не показывает счётчик: она уже прочитана. */
selected: boolean;
}
/** Бейдж комнаты в сайдбаре: упоминания важнее непрочитанных. */
export function ChannelBadge({ channelId, selected }: ChannelBadgeProps) {
const { t } = useTranslation();
const { unread, mentions } = useChannelUnread(channelId);
if (selected) {
return null;
}
if (mentions > 0) {
return (
<span
data-testid={`channel-mentions-${channelId}`}
title={t('chat.mentions', { count: mentions })}
className="ml-auto rounded-full bg-danger px-1.5 text-[10px] font-semibold leading-4 text-white"
>
{mentions}
</span>
);
}
if (unread > 0) {
return (
<span
data-testid={`channel-unread-${channelId}`}
title={t('chat.unread', { count: unread })}
className="ml-auto rounded-full bg-surface-3 px-1.5 text-[10px] font-semibold leading-4 text-fg"
>
{unread > 99 ? '99+' : unread}
</span>
);
}
return null;
}
+222
View File
@@ -0,0 +1,222 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { isPendingMessage, type Message } from '@/api/messages';
import type { Channel } from '@/api/types';
import { Composer } from '@/components/chat/Composer';
import { MessageList } from '@/components/chat/MessageList';
import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { useWindowActive } from '@/lib/hooks';
import {
PERMISSION_ATTACH_FILES,
canManageMessages as canManageMessagesPermission,
canSendMessages,
hasPermission,
} from '@/lib/identity';
import { useSessionStore } from '@/stores/session';
import { channelState, useMessagesStore } from '@/stores/messages';
interface ChatViewProps {
channel: Channel;
/** Сервер комнаты: нужен для участников и ролей (цвет ника). */
guildId: string | null;
/** Сообщение, к которому нужно перейти (из поиска или панели пинов). */
jumpTo?: { id: string; token: number } | null;
onJumpHandled?: () => void;
}
/** Задержка перед `POST /ack`: успевает собрать пачку новых сообщений. */
const ACK_DEBOUNCE_MS = 500;
/** Сколько держится подсветка сообщения, к которому перешли. */
const HIGHLIGHT_MS = 2000;
/** Предел подгрузки истории при переходе к сообщению из поиска или пинов. */
const MAX_JUMP_PAGES = 5;
/**
* Чат комнаты: лента, «печатает…», композер и отметка прочтения.
*
* Загрузку истории и события шлюза ведёт `stores/messages.ts`; компонент
* подписывается на запись комнаты и передаёт действия вниз.
*/
export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: ChatViewProps) {
const { t } = useTranslation();
const channelId = channel.id;
const sessionUser = useSessionStore((state) => state.user);
const guild = useSessionStore(
useCallback(
(state) =>
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
[guildId],
),
);
const record = useMessagesStore(
useCallback((state) => channelState(state, channelId), [channelId]),
);
const snapshotApplied = useMessagesStore((state) => state.snapshotApplied);
const [replyTo, setReplyTo] = useState<Message | null>(null);
const [highlightId, setHighlightId] = useState<string | null>(null);
/**
* Разделитель «новые сообщения» фиксируем при первой загрузке комнаты:
* после автоматического ack отметка прочтения сдвигается, а линия должна
* остаться на месте. `null` — отметка ещё не зафиксирована.
*/
const [unreadAnchor, setUnreadAnchor] = useState<{
channelId: string;
messageId: string | null;
} | null>(null);
const handledJump = useRef<number | null>(null);
/** Размонтирован ли компонент: незавершённый переход не должен трогать стор. */
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const messages = useMemo(
() => [...record.messages, ...record.pending],
[record.messages, record.pending],
);
const authorIds = useMemo(() => {
const ids = new Set<string>();
for (const message of messages) {
if (message.author_id !== undefined) {
ids.add(message.author_id);
}
}
for (const entry of record.typing) {
ids.add(entry.user_id);
}
return [...ids];
}, [messages, record.typing]);
const authors = useAuthorDirectory(guildId, authorIds);
const currentUserId = sessionUser?.id ?? null;
const permissions = guild?.my_permissions ?? [];
const isAdmin = sessionUser?.is_instance_admin === true;
const canManage = canManageMessagesPermission(permissions, isAdmin);
const canSend = canSendMessages(permissions, isAdmin, channel.can_send);
const canAttach = canSend && (isAdmin || hasPermission(permissions, PERMISSION_ATTACH_FILES));
const disabledReason = canSend ? null : t('chat.composer.readOnly');
const windowActive = useWindowActive();
const newest = messages.at(-1) ?? null;
const newestId = newest?.id ?? null;
const newestPending = newest !== null && isPendingMessage(newest);
// Первичная загрузка истории при открытии комнаты.
useEffect(() => {
void useMessagesStore.getState().ensureChannel(channelId);
}, [channelId]);
// Отметка прочтения на момент открытия комнаты (для разделителя).
// Подстройка состояния во время рендера — документированный приём React:
// эффект дал бы лишний кадр без разделителя.
// Ждём, пока станет известна отметка прочтения: либо пришла запись
// read state, либо применён снапшот READY (значит, записи для комнаты нет).
// Иначе разделитель «новые сообщения» терялся бы при быстрой загрузке истории.
const readStateKnown = record.readStateKnown || snapshotApplied;
if (unreadAnchor === null && record.status === 'ready' && readStateKnown) {
setUnreadAnchor({ channelId, messageId: record.lastReadMessageId });
}
const unreadAnchorId =
unreadAnchor !== null && unreadAnchor.channelId === channelId ? unreadAnchor.messageId : null;
// Отметка прочтения: комната открыта и окно активно.
useEffect(() => {
if (newestId === null || newestPending) {
return;
}
if (record.lastReadMessageId === newestId) {
return;
}
if (!windowActive || !readStateKnown) {
return;
}
const timer = window.setTimeout(() => {
void useMessagesStore.getState().acknowledge(channelId, newestId);
}, ACK_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
};
}, [channelId, newestId, newestPending, record.lastReadMessageId, windowActive, readStateKnown]);
// Подсветка перехода гаснет сама: иначе следующее новое сообщение снова
// прокручивало бы ленту к старому месту.
useEffect(() => {
if (highlightId === null) {
return;
}
const timer = window.setTimeout(() => setHighlightId(null), HIGHLIGHT_MS);
return () => {
window.clearTimeout(timer);
};
}, [highlightId]);
// Переход к сообщению: если его нет в загруженной истории — подгружаем вверх.
useEffect(() => {
if (jumpTo === null || handledJump.current === jumpTo.token) {
return;
}
handledJump.current = jumpTo.token;
setHighlightId(jumpTo.id);
const seek = async (attempt: number): Promise<void> => {
if (!mounted.current) {
return;
}
const state = useMessagesStore.getState();
const current = channelState(state, channelId);
if (current.messages.some((message) => message.id === jumpTo.id)) {
setHighlightId(jumpTo.id);
return;
}
if (!current.hasMore || attempt >= MAX_JUMP_PAGES) {
return;
}
await state.loadMore(channelId);
await seek(attempt + 1);
};
// Цель может быть глубже одной страницы — подгружаем историю до неё
// (не больше MAX_JUMP_PAGES страниц за один переход).
void seek(0).finally(() => onJumpHandled?.());
}, [jumpTo, channelId, onJumpHandled]);
return (
<div className="flex min-h-0 flex-1 flex-col">
<MessageList
channelId={channelId}
channelName={channel.name}
messages={messages}
status={record.status}
error={record.error}
hasMore={record.hasMore}
loadingMore={record.loadingMore}
unreadAnchorId={unreadAnchorId}
highlightId={highlightId}
authors={authors}
currentUserId={currentUserId}
canManageMessages={canManage}
canSend={canSend}
onReload={() => {
void useMessagesStore.getState().reloadChannel(channelId);
}}
onLoadMore={() => {
void useMessagesStore.getState().loadMore(channelId);
}}
onReply={setReplyTo}
/>
<TypingIndicator channelId={channelId} typing={record.typing} authors={authors} />
<Composer
channel={channel}
authors={authors}
replyTo={replyTo}
onCancelReply={() => setReplyTo(null)}
disabledReason={disabledReason}
canAttach={canAttach}
/>
</div>
);
}
+349
View File
@@ -0,0 +1,349 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fileContentUrl, uploadChannelFile, type UploadedFile } from '@/api/files';
import { fetchMeta, metaQueryKey } from '@/api/meta';
import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages';
import type { Channel } from '@/api/types';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { formatBytes, errorCode } from '@/lib/format';
import { useMessagesStore } from '@/stores/messages';
interface ComposerProps {
channel: Channel;
authors: AuthorDirectory;
/** Сообщение, на которое отвечаем (плашка над полем ввода). */
replyTo: Message | null;
onCancelReply: () => void;
/** Причина, по которой отправка запрещена (нет прав, комната только для чтения). */
disabledReason: string | null;
canAttach: boolean;
}
/** Максимальная высота автовысоты textarea (~8 строк). */
const MAX_TEXTAREA_HEIGHT_PX = 176;
const LINE_HEIGHT_PX = 22;
/**
* Композер сообщения: автовысота, Enter/Shift+Enter, счётчик символов,
* вложения (скрепка и drag&drop), плашка ответа и обработка ошибок отправки
* (лимит частоты 429 с таймером, отсутствие прав).
*/
export function Composer({
channel,
authors,
replyTo,
onCancelReply,
disabledReason,
canAttach,
}: ComposerProps) {
const { t, i18n } = useTranslation();
const [value, setValue] = useState('');
const [files, setFiles] = useState<UploadedFile[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<unknown>(null);
const [notice, setNotice] = useState<string | null>(null);
const [sendError, setSendError] = useState<unknown>(null);
const [sending, setSending] = useState(false);
const [denied, setDenied] = useState<string | null>(null);
const [retryAt, setRetryAt] = useState<number | null>(null);
/** Тикающее «сейчас»: по нему считается остаток лимита частоты. */
const [now, setNow] = useState(() => Date.now());
const [dragging, setDragging] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Предельный размер файла знает сервер (`GET /meta`); если он неизвестен,
// ограничение проверяет сам сервер, а мы показываем его ошибку.
const meta = useQuery({
queryKey: metaQueryKey,
queryFn: ({ signal }) => fetchMeta(signal),
staleTime: 300_000,
retry: 0,
});
const maxUploadSize = meta.data?.max_upload_size;
// Автовысота: от одной строки до ~8.
useEffect(() => {
const element = textareaRef.current;
if (element === null) {
return;
}
element.style.height = 'auto';
const max = Math.min(MAX_TEXTAREA_HEIGHT_PX, LINE_HEIGHT_PX * 8);
element.style.height = `${String(Math.min(element.scrollHeight, max))}px`;
}, [value]);
// Таймер лимита частоты: пока он идёт, отправка заблокирована.
// Когда пауза истекла, снимаем `retryAt` — иначе интервал тикал бы вечно.
useEffect(() => {
if (retryAt === null) {
return;
}
const tick = (): void => {
const current = Date.now();
setNow(current);
if (current >= retryAt) {
setRetryAt(null);
}
};
const timer = window.setInterval(tick, 500);
return () => {
window.clearInterval(timer);
};
}, [retryAt]);
const secondsLeft = retryAt === null ? 0 : Math.max(0, Math.ceil((retryAt - now) / 1000));
const blockedReason = disabledReason ?? denied;
const rateLimited = secondsLeft > 0;
const canSubmit =
blockedReason === null &&
!rateLimited &&
!sending &&
!uploading &&
(value.trim() !== '' || files.length > 0);
const uploadFiles = useCallback(
async (incoming: File[]) => {
if (!canAttach || incoming.length === 0) {
return;
}
setUploadError(null);
setNotice(null);
const tooLarge =
maxUploadSize === undefined
? undefined
: incoming.find((file) => file.size > maxUploadSize);
if (tooLarge !== undefined) {
setNotice(
t('chat.composer.tooLarge', {
name: tooLarge.name,
limit: formatBytes(maxUploadSize ?? 0, i18n.language),
}),
);
return;
}
setUploading(true);
try {
const uploaded: UploadedFile[] = [];
for (const file of incoming) {
uploaded.push(await uploadChannelFile(channel.id, file));
}
setFiles((current) => [...current, ...uploaded]);
} catch (error) {
setUploadError(error);
} finally {
setUploading(false);
}
},
[canAttach, channel.id, i18n.language, maxUploadSize, t],
);
const submit = async (): Promise<void> => {
const content = value.trim();
if (!canSubmit || (content === '' && files.length === 0)) {
return;
}
setSending(true);
setSendError(null);
try {
await useMessagesStore.getState().send({
channelId: channel.id,
content,
...(replyTo === null ? {} : { replyToId: replyTo.id }),
...(files.length === 0 ? {} : { files }),
});
setValue('');
setFiles([]);
setNotice(null);
// Отправка прошла — лимит частоты больше не держим.
setRetryAt(null);
onCancelReply();
} catch (error) {
// Текст и вложения остаются в композере — пользователь может повторить.
setSendError(error);
const code = errorCode(error);
if (code === 'perm.denied') {
setDenied(t('chat.composer.denied'));
}
const retry = retryAfterMs(error);
if (retry !== null) {
setNow(Date.now());
setRetryAt(Date.now() + retry);
}
} finally {
setSending(false);
}
};
const counter = value.length;
const nearLimit = counter >= MESSAGE_MAX_LENGTH - 200;
return (
<div
className="border-t border-border/40 px-3 pb-3 pt-2"
data-testid="composer"
onDragOver={(event) => {
if (!canAttach) {
return;
}
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
if (!canAttach) {
return;
}
event.preventDefault();
setDragging(false);
void uploadFiles([...event.dataTransfer.files]);
}}
>
{replyTo === null ? null : (
<div className="mb-2 flex items-center justify-between gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1 text-xs text-fg-muted">
<span className="truncate">
{t('chat.reply.composer', { name: authors.resolve(replyTo.author_id).name })}
</span>
<button
type="button"
aria-label={t('chat.reply.cancel')}
className="rounded-[var(--radius-sm)] px-1 text-sm leading-none text-fg-muted hover:text-fg"
onClick={onCancelReply}
>
×
</button>
</div>
)}
{files.length === 0 ? null : (
<ul className="mb-2 flex flex-wrap gap-2" aria-label={t('chat.composer.attachments')}>
{files.map((file) => {
const url = fileContentUrl(file.file_id);
const isImage = file.content_type.startsWith('image/');
return (
<li
key={file.file_id}
className="flex items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1 text-xs"
>
{isImage ? (
<img src={url} alt="" className="h-8 w-8 rounded object-cover" />
) : (
<span aria-hidden="true">📎</span>
)}
<span className="max-w-40 truncate">{file.filename}</span>
<span className="text-fg-muted">
{formatBytes(file.size_bytes ?? 0, i18n.language)}
</span>
<button
type="button"
aria-label={t('chat.composer.removeAttachment', { name: file.filename })}
className="rounded-[var(--radius-sm)] px-1 leading-none text-fg-muted hover:text-danger"
onClick={() => {
setFiles((current) => current.filter((item) => item.file_id !== file.file_id));
}}
>
×
</button>
</li>
);
})}
</ul>
)}
{notice === null ? null : (
<p role="status" className="mb-1 text-xs text-warning">
{notice}
</p>
)}
<div
className={`flex items-end gap-2 rounded-[var(--radius-md)] border bg-surface-2 px-2 py-1 ${
dragging ? 'border-accent' : 'border-border/60'
}`}
>
<button
type="button"
aria-label={t('chat.composer.attach')}
title={t('chat.composer.attach')}
disabled={!canAttach || uploading || blockedReason !== null}
className="rounded-[var(--radius-sm)] px-1 pb-1 text-lg leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => fileInputRef.current?.click()}
>
📎
</button>
<input
ref={fileInputRef}
type="file"
multiple
hidden
aria-label={t('chat.composer.fileInput')}
onChange={(event) => {
const list = event.target.files;
if (list !== null) {
void uploadFiles([...list]);
}
event.target.value = '';
}}
/>
<textarea
ref={textareaRef}
aria-label={t('chat.composer.label', { name: channel.name })}
placeholder={t('chat.composer.placeholder', { name: channel.name })}
className="max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent py-1 text-sm text-fg placeholder:text-fg-muted/70 focus:outline-none disabled:opacity-60"
rows={1}
value={value}
maxLength={MESSAGE_MAX_LENGTH}
disabled={blockedReason !== null}
onChange={(event) => {
setValue(event.target.value);
useMessagesStore.getState().notifyTyping(channel.id);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void submit();
}
if (event.key === 'Escape' && replyTo !== null) {
onCancelReply();
}
}}
/>
<button
type="button"
aria-label={t('chat.composer.send')}
title={t('chat.composer.send')}
disabled={!canSubmit || rateLimited}
className="rounded-[var(--radius-sm)] px-2 pb-1 text-lg leading-none text-accent transition-transform duration-[var(--duration-fast)] hover:opacity-90 active:scale-95 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => void submit()}
>
➤
</button>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-fg-muted">
<span>
{blockedReason === null
? uploading
? t('chat.composer.uploading')
: t('chat.composer.hint')
: blockedReason}
</span>
<span className={nearLimit ? 'text-warning' : undefined}>
{t('chat.composer.counter', { count: counter, max: MESSAGE_MAX_LENGTH })}
</span>
</div>
{rateLimited ? (
<p role="alert" className="mt-1 text-xs text-warning">
{t('chat.composer.rateLimited', { seconds: secondsLeft })}
</p>
) : null}
{uploadError === null ? null : <ErrorNotice className="mt-1" error={uploadError} />}
{sendError === null ? null : <ErrorNotice className="mt-1" error={sendError} />}
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { POPULAR_EMOJIS, QUICK_REACTIONS } from '@/lib/chat';
interface EmojiPickerProps {
open: boolean;
onClose: () => void;
onPick: (emoji: string) => void;
/** Якорь: клик по нему не считается кликом «вне пикера». */
anchorRef?: RefObject<HTMLElement | null>;
/** Куда раскрывается панель: под якорем или над ним. */
placement?: 'top' | 'bottom';
className?: string;
}
/**
* Выпадающий пикер эмодзи: ряд быстрых реакций и сетка популярных.
* Закрывается по Escape и клику вне панели; фокус остаётся в пределах
* tab-порядка страницы.
*/
export function EmojiPicker({
open,
onClose,
onPick,
anchorRef,
placement = 'bottom',
className = '',
}: EmojiPickerProps) {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement | null>(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
if (!open) {
return;
}
const onPointerDown = (event: Event): void => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (panelRef.current?.contains(target) === true) {
return;
}
if (anchorRef?.current?.contains(target) === true) {
return;
}
onCloseRef.current();
};
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onCloseRef.current();
}
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open, anchorRef]);
if (!open) {
return null;
}
return (
<div
ref={panelRef}
role="dialog"
aria-label={t('chat.emoji.title')}
className={`absolute right-0 z-40 w-64 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 shadow-[var(--shadow-3)] ${
placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2'
} ${className}`}
>
<div className="flex flex-wrap gap-1" role="group" aria-label={t('chat.emoji.quick')}>
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={t('chat.emoji.pick', { emoji })}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none transition-transform duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-90"
onClick={() => onPick(emoji)}
>
{emoji}
</button>
))}
</div>
<div
role="group"
aria-label={t('chat.emoji.popular')}
className="mt-2 grid max-h-40 grid-cols-6 gap-1 overflow-y-auto border-t border-border/40 pt-2"
>
{POPULAR_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={t('chat.emoji.pick', { emoji })}
className="rounded-[var(--radius-sm)] py-0.5 text-lg leading-none transition-transform duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-90"
onClick={() => onPick(emoji)}
>
{emoji}
</button>
))}
</div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { Fragment, type ReactNode } from 'react';
/**
* Лёгкий безопасный рендер разметки сообщения.
*
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
* `*курсив*`, `` `код` ``, ```блок кода```, `~~зачёркнутый~~`, `> цитата`,
* ссылки `http(s)://…` и упоминания `<@id>`.
*/
export interface MessageContentProps {
content: string;
/** Как показать упоминание: имя и подсветка «меня». */
resolveMention?: (userId: string) => { name: string; isMe: boolean } | null;
className?: string;
}
const INLINE_PATTERN =
/(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)|(~~[^~\n]+~~)|(<@\d+>)|(https?:\/\/[^\s<>()]+)/gu;
/** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */
function splitLink(raw: string): { href: string; trailing: string } {
let href = raw;
let trailing = '';
while (href.length > 0 && '.,!?;:)]}»"'.includes(href[href.length - 1] ?? '')) {
trailing = (href[href.length - 1] ?? '') + trailing;
href = href.slice(0, -1);
}
return { href, trailing };
}
type MentionResolver = MessageContentProps['resolveMention'];
function renderInline(
text: string,
resolveMention: MentionResolver,
keyPrefix: string,
): ReactNode[] {
const nodes: ReactNode[] = [];
let lastIndex = 0;
let index = 0;
for (const match of text.matchAll(INLINE_PATTERN)) {
const start = match.index ?? 0;
if (start > lastIndex) {
nodes.push(text.slice(lastIndex, start));
}
const token = match[0];
const key = `${keyPrefix}-${String(index)}`;
index += 1;
if (token.startsWith('`')) {
nodes.push(
<code
key={key}
className="rounded-[var(--radius-sm)] bg-surface-3 px-1 py-0.5 font-mono text-[0.85em]"
>
{token.slice(1, -1)}
</code>,
);
} else if (token.startsWith('**')) {
nodes.push(
<strong key={key} className="font-semibold">
{token.slice(2, -2)}
</strong>,
);
} else if (token.startsWith('~~')) {
nodes.push(
<s key={key} className="opacity-70">
{token.slice(2, -2)}
</s>,
);
} else if (token.startsWith('*')) {
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
} else if (token.startsWith('<@')) {
const userId = token.slice(2, -1);
const mention = resolveMention?.(userId) ?? null;
nodes.push(
<span
key={key}
className={`rounded-[var(--radius-sm)] px-1 font-medium ${
mention?.isMe === true ? 'bg-accent/30 text-fg' : 'bg-accent/15 text-accent'
}`}
>
@{mention?.name ?? userId}
</span>,
);
} else {
const { href, trailing } = splitLink(token);
nodes.push(
<Fragment key={key}>
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent"
>
{href}
</a>
{trailing}
</Fragment>,
);
}
lastIndex = start + token.length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
interface Block {
kind: 'text' | 'quote' | 'code';
lines: string[];
}
/** Разбиение на блоки: цитаты, огороженный код и обычный текст. */
function toBlocks(content: string): Block[] {
const lines = content.split('\n');
const blocks: Block[] = [];
let fence: Block | null = null;
for (const line of lines) {
const fenceMatch = /^```(\w*)\s*$/u.exec(line.trim());
if (fence !== null) {
if (fenceMatch !== null) {
blocks.push(fence);
fence = null;
} else {
fence.lines.push(line);
}
continue;
}
if (fenceMatch !== null) {
fence = { kind: 'code', lines: [] };
continue;
}
if (line.startsWith('> ')) {
const previous = blocks[blocks.length - 1];
if (previous?.kind === 'quote') {
previous.lines.push(line.slice(2));
} else {
blocks.push({ kind: 'quote', lines: [line.slice(2)] });
}
continue;
}
const previous = blocks[blocks.length - 1];
if (previous !== undefined && previous.kind === 'text') {
previous.lines.push(line);
} else {
blocks.push({ kind: 'text', lines: [line] });
}
}
if (fence !== null) {
// Незакрытый блок кода — показываем как код, а не теряем текст.
blocks.push(fence);
}
return blocks;
}
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
const blocks = toBlocks(content);
return (
<div className={`whitespace-pre-wrap break-words ${className}`}>
{blocks.map((block, blockIndex) => {
const key = `block-${String(blockIndex)}`;
if (block.kind === 'code') {
return (
<pre
key={key}
className="my-1 overflow-x-auto rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 font-mono text-xs"
>
<code>{block.lines.join('\n')}</code>
</pre>
);
}
if (block.kind === 'quote') {
return (
<blockquote key={key} className="my-1 border-l-2 border-border pl-2 text-fg-muted">
{renderInline(block.lines.join('\n'), resolveMention, key)}
</blockquote>
);
}
return (
<p key={key} className="min-w-0">
{renderInline(block.lines.join('\n'), resolveMention, key)}
</p>
);
})}
</div>
);
}
+436
View File
@@ -0,0 +1,436 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { fileContentUrl } from '@/api/files';
import { MESSAGE_MAX_LENGTH, type Message } from '@/api/messages';
import { EmojiPicker } from '@/components/chat/EmojiPicker';
import { MessageContent } from '@/components/chat/MessageContent';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { QUICK_REACTIONS, copyText, formatMessageDateTime, formatMessageTime } from '@/lib/chat';
import { formatBytes } from '@/lib/format';
import { useMessagesStore } from '@/stores/messages';
interface MessageItemProps {
message: Message;
channelId: string;
channelName: string;
authors: AuthorDirectory;
currentUserId: string | null;
canManageMessages: boolean;
canSend: boolean;
/** Не первое сообщение группы: время показываем только при наведении. */
compact: boolean;
/** Оптимистичное сообщение ещё не подтверждено сервером. */
pending?: boolean;
/** Автор сообщения, на которое отвечают (если он есть в загруженной истории). */
replyTarget?: Message | undefined;
highlight?: boolean;
onReply: (message: Message) => void;
onJumpToReply: (messageId: string) => void;
}
const actionButtonClass =
'rounded-[var(--radius-sm)] px-1.5 py-0.5 text-sm leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95';
/**
* Сообщение ленты: текст, вложения, реакции, ответы и действия.
*
* Действия доступны по hover (быстрые реакции, ответ, меню) и из контекстного
* меню; редактирование — inline, удаление — с подтверждением.
*/
export function MessageItem({
message,
channelId,
channelName,
authors,
currentUserId,
canManageMessages,
canSend,
compact,
pending = false,
replyTarget,
highlight = false,
onReply,
onJumpToReply,
}: MessageItemProps) {
const { t, i18n } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(message.content);
const [actionError, setActionError] = useState<unknown>(null);
const [copied, setCopied] = useState(false);
/** Свободного места снизу мало — меню и пикер раскрываем вверх. */
const [dropUp, setDropUp] = useState(false);
const menuAnchor = useRef<HTMLButtonElement | null>(null);
const editorRef = useRef<HTMLTextAreaElement | null>(null);
const author = authors.resolve(message.author_id);
const isMine = currentUserId !== null && message.author_id === currentUserId;
const canEdit = isMine && canSend && !pending;
const canDelete = !pending && (isMine || canManageMessages);
const edited = message.edited_at !== undefined;
useEffect(() => {
if (editing) {
editorRef.current?.focus();
const length = editorRef.current?.value.length ?? 0;
editorRef.current?.setSelectionRange(length, length);
}
}, [editing]);
useEffect(() => {
if (!copied) {
return;
}
const timer = window.setTimeout(() => setCopied(false), 1500);
return () => window.clearTimeout(timer);
}, [copied]);
/** Направление поповеров считаем по положению кнопки в окне. */
const updateDropDirection = (): void => {
const rect = menuAnchor.current?.getBoundingClientRect();
const spaceBelow = window.innerHeight - (rect?.bottom ?? 0);
setDropUp(spaceBelow < 320);
};
const react = (emoji: string): void => {
setPickerOpen(false);
setMenuOpen(false);
void useMessagesStore
.getState()
.toggleReaction(channelId, message.id, emoji)
.catch((error: unknown) => setActionError(error));
};
const saveEdit = (): void => {
const content = draft.trim();
if (content === '' || content === message.content) {
setEditing(false);
return;
}
void useMessagesStore
.getState()
.edit(channelId, message.id, content)
.then(() => {
setEditing(false);
setActionError(null);
})
.catch((error: unknown) => setActionError(error));
};
const remove = (): void => {
setMenuOpen(false);
if (!window.confirm(t('chat.actions.deleteConfirm'))) {
return;
}
void useMessagesStore
.getState()
.remove(channelId, message.id)
.catch((error: unknown) => setActionError(error));
};
const togglePin = (): void => {
setMenuOpen(false);
void useMessagesStore
.getState()
.setPinned(channelId, message.id, !message.pinned)
.catch((error: unknown) => setActionError(error));
};
return (
<article
data-message-id={message.id}
data-testid={`message-${message.id}`}
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
pending ? 'opacity-70' : ''
} ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`}
>
{message.reply_to_id === undefined ? null : (
<button
type="button"
className="flex max-w-full items-center gap-1 truncate text-left text-xs text-fg-muted transition-colors duration-[var(--duration-fast)] hover:text-fg"
onClick={() => onJumpToReply(message.reply_to_id ?? '')}
>
<span aria-hidden="true">↩</span>
<span className="truncate">
{t('chat.reply.to', {
name:
replyTarget === undefined
? t('chat.reply.missing')
: authors.resolve(replyTarget.author_id).name,
})}
</span>
</button>
)}
{editing ? (
<div className="flex flex-col gap-1">
<textarea
ref={editorRef}
aria-label={t('chat.edit.label')}
className="w-full resize-none rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1 text-fg"
value={draft}
maxLength={MESSAGE_MAX_LENGTH}
rows={Math.min(8, draft.split('\n').length)}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
saveEdit();
}
if (event.key === 'Escape') {
setEditing(false);
setDraft(message.content);
}
}}
/>
<p className="text-xs text-fg-muted">{t('chat.edit.hint')}</p>
<div className="flex gap-2">
<button type="button" className={actionButtonClass} onClick={saveEdit}>
{t('common.save')}
</button>
<button
type="button"
className={actionButtonClass}
onClick={() => {
setEditing(false);
setDraft(message.content);
}}
>
{t('common.cancel')}
</button>
</div>
</div>
) : (
<div className="flex min-w-0 flex-col gap-1">
{message.content === '' ? null : (
<MessageContent
content={message.content}
resolveMention={authors.resolveMention}
className="text-sm text-fg"
/>
)}
{message.attachments.length === 0 ? null : (
<ul className="flex flex-col gap-2">
{message.attachments.map((attachment) => {
const url = fileContentUrl(attachment.file_id);
const isImage = (attachment.content_type ?? '').startsWith('image/');
return (
<li key={attachment.file_id}>
{isImage ? (
<a href={url} target="_blank" rel="noreferrer noopener">
<img
src={url}
alt={attachment.filename}
loading="lazy"
className="max-h-80 max-w-full rounded-[var(--radius-md)] border border-border/40"
/>
</a>
) : (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg transition-colors duration-[var(--duration-fast)] hover:bg-surface-3"
>
<span aria-hidden="true">📎</span>
<span className="truncate">{attachment.filename}</span>
<span className="text-xs text-fg-muted">
{formatBytes(attachment.size_bytes ?? 0, i18n.language)}
</span>
</a>
)}
</li>
);
})}
</ul>
)}
{message.reactions.length === 0 ? null : (
<ul className="flex flex-wrap gap-1" aria-label={t('chat.reactions.title')}>
{message.reactions.map((reaction) => (
<li key={reaction.emoji}>
<button
type="button"
aria-pressed={reaction.me}
aria-label={t('chat.reactions.toggle', {
emoji: reaction.emoji,
count: reaction.count,
})}
onClick={() => react(reaction.emoji)}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors duration-[var(--duration-fast)] ${
reaction.me
? 'border-accent/60 bg-accent/15 text-fg'
: 'border-border/60 bg-surface-2 text-fg-muted hover:bg-surface-3'
}`}
>
<span aria-hidden="true">{reaction.emoji}</span>
<span>{reaction.count}</span>
</button>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2 text-xs text-fg-muted">
{compact ? (
<time
dateTime={message.created_at}
className="opacity-0 group-hover/message:opacity-100"
>
{formatMessageTime(message.created_at, i18n.language)}
</time>
) : null}
{edited ? <span>{t('chat.edited')}</span> : null}
{pending ? <span role="status">{t('chat.pending')}</span> : null}
{copied ? <span role="status">{t('common.copied')}</span> : null}
</div>
</div>
)}
{actionError === null ? null : <ErrorNotice error={actionError} className="mt-1" />}
<div
hidden={pending}
className={`absolute -top-3 right-2 z-10 flex items-center gap-0.5 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 px-1 py-0.5 shadow-[var(--shadow-2)] transition-opacity duration-[var(--duration-fast)] ${
menuOpen || pickerOpen
? 'opacity-100'
: 'opacity-0 group-hover/message:opacity-100 focus-within:opacity-100'
}`}
>
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={t('chat.reactions.add', { emoji })}
className={actionButtonClass}
onClick={() => react(emoji)}
>
{emoji}
</button>
))}
<button
type="button"
aria-label={t('chat.actions.reply')}
title={t('chat.actions.reply')}
className={actionButtonClass}
disabled={!canSend}
onClick={() => onReply(message)}
>
↩
</button>
<button
ref={menuAnchor}
type="button"
aria-label={t('chat.actions.menu')}
aria-haspopup="menu"
aria-expanded={menuOpen}
className={actionButtonClass}
onClick={() => {
updateDropDirection();
setPickerOpen(false);
setMenuOpen((open) => !open);
}}
>
⋯
</button>
<EmojiPicker
open={pickerOpen}
anchorRef={menuAnchor}
onClose={() => setPickerOpen(false)}
onPick={react}
placement={dropUp ? 'top' : 'bottom'}
/>
<Menu
open={menuOpen}
label={t('chat.actions.menu')}
anchorRef={menuAnchor}
placement={dropUp ? 'top' : 'bottom'}
align="right"
onClose={() => setMenuOpen(false)}
>
{/* Быстрые реакции прямо в контекстном меню. */}
<div className="flex items-center justify-between gap-0.5 px-1 pb-1">
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
role="menuitem"
tabIndex={-1}
aria-label={t('chat.reactions.add', { emoji })}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none transition-transform duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-90"
onClick={() => react(emoji)}
>
{emoji}
</button>
))}
</div>
<MenuSeparator />
<MenuItem
icon="↩"
onClick={() => {
setMenuOpen(false);
onReply(message);
}}
>
{t('chat.actions.reply')}
</MenuItem>
<MenuItem
icon="😀"
onClick={() => {
updateDropDirection();
setMenuOpen(false);
setPickerOpen(true);
}}
>
{t('chat.actions.react')}
</MenuItem>
<MenuItem
icon="📋"
onClick={() => {
setMenuOpen(false);
void copyText(message.content).then((ok) => setCopied(ok));
}}
>
{t('chat.actions.copy')}
</MenuItem>
{canManageMessages ? (
<MenuItem icon="📌" onClick={togglePin}>
{t(message.pinned ? 'chat.actions.unpin' : 'chat.actions.pin')}
</MenuItem>
) : null}
{canEdit ? (
<MenuItem
icon="✏"
onClick={() => {
setMenuOpen(false);
setDraft(message.content);
setEditing(true);
}}
>
{t('chat.actions.edit')}
</MenuItem>
) : null}
{canDelete ? (
<>
<MenuSeparator />
<MenuItem icon="🗑" danger onClick={remove}>
{t('chat.actions.delete')}
</MenuItem>
</>
) : null}
</Menu>
</div>
<span className="sr-only">
{t('chat.message.by', { name: author.name, channel: channelName })}
{' · '}
{formatMessageDateTime(message.created_at, i18n.language)}
</span>
</article>
);
}
+287
View File
@@ -0,0 +1,287 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { isPendingMessage, type Message } from '@/api/messages';
import { MessageItem } from '@/components/chat/MessageItem';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { Avatar } from '@/components/ui/Avatar';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button, Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import {
buildChatRows,
dayLabel,
escapeSelector,
formatMessageDate,
formatMessageTime,
} from '@/lib/chat';
interface MessageListProps {
channelId: string;
channelName: string;
/** Лента с оптимистичными сообщениями в конце. */
messages: Message[];
status: 'idle' | 'loading' | 'ready' | 'error';
error: unknown;
hasMore: boolean;
loadingMore: boolean;
/** id последнего прочитанного сообщения на момент открытия комнаты. */
unreadAnchorId: string | null;
highlightId: string | null;
authors: AuthorDirectory;
currentUserId: string | null;
canManageMessages: boolean;
canSend: boolean;
onReload: () => void;
onLoadMore: () => void;
onReply: (message: Message) => void;
}
/** Расстояние до низа, при котором автопрокрутка считается уместной. */
const STICK_THRESHOLD_PX = 120;
/**
* Лента сообщений: разделители дат, группировка подряд идущих сообщений
* одного автора, разделитель «новые сообщения», подгрузка истории вверх
* и автопрокрутка к свежим сообщениям.
*/
export function MessageList({
channelId,
channelName,
messages,
status,
error,
hasMore,
loadingMore,
unreadAnchorId,
highlightId,
authors,
currentUserId,
canManageMessages,
canSend,
onReload,
onLoadMore,
onReply,
}: MessageListProps) {
const { t, i18n } = useTranslation();
const containerRef = useRef<HTMLDivElement | null>(null);
const stickToBottom = useRef(true);
const previousCount = useRef(messages.length);
const messageById = useMemo(() => {
const map = new Map<string, Message>();
for (const message of messages) {
map.set(message.id, message);
}
return map;
}, [messages]);
const rows = useMemo(
() => buildChatRows(messages, { unreadAnchorId }),
[messages, unreadAnchorId],
);
// Прокрутка вниз при новых сообщениях, если пользователь был у нижнего края.
useEffect(() => {
const previous = previousCount.current;
previousCount.current = messages.length;
const container = containerRef.current;
if (container === null) {
return;
}
const isFirstRender = previous === 0;
if (isFirstRender || stickToBottom.current) {
container.scrollTop = container.scrollHeight;
}
}, [messages.length, channelId]);
// Переход к сообщению (ответ, поиск, пины).
useEffect(() => {
if (highlightId === null) {
return;
}
const container = containerRef.current;
const target = container?.querySelector(`[data-message-id="${escapeSelector(highlightId)}"]`);
if (target instanceof HTMLElement) {
target.scrollIntoView?.({ block: 'center' });
}
}, [highlightId, messages.length]);
const onScroll = useCallback(() => {
const container = containerRef.current;
if (container === null) {
return;
}
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
stickToBottom.current = distance <= STICK_THRESHOLD_PX;
if (container.scrollTop <= 80 && hasMore && !loadingMore) {
onLoadMore();
}
}, [hasMore, loadingMore, onLoadMore]);
if (status === 'loading' || status === 'idle') {
return (
<div className="flex-1 px-4 py-4">
<SkeletonLines rows={6} />
</div>
);
}
if (status === 'error') {
return (
<div className="flex-1 p-4">
<ErrorNotice error={error} onRetry={onReload} />
</div>
);
}
return (
<div
ref={containerRef}
onScroll={onScroll}
role="log"
aria-label={t('chat.log', { name: channelName })}
data-testid="message-list"
className="min-h-0 flex-1 overflow-y-auto px-3 py-3"
>
{hasMore ? (
<div className="flex justify-center pb-2">
<Button variant="ghost" disabled={loadingMore} onClick={onLoadMore}>
{t(loadingMore ? 'chat.loadingMore' : 'chat.loadMore')}
</Button>
</div>
) : null}
{error !== null && status === 'ready' ? (
<ErrorNotice className="mb-2" error={error} onRetry={onLoadMore} />
) : null}
{messages.length === 0 ? (
<Card className="mx-auto mt-6 max-w-md text-center" data-testid="chat-start">
<h3 className="text-base font-semibold">
{t('chat.start.title', { name: channelName })}
</h3>
<p className="mt-2 text-sm text-fg-muted">{t('chat.start.body')}</p>
</Card>
) : (
<ol className="flex flex-col">
{rows.map((row) => {
if (row.kind === 'date') {
const label = dayLabel(row.iso);
return (
<li
key={row.key}
className="my-2 flex items-center gap-3 text-xs text-fg-muted"
data-testid="date-separator"
>
<span aria-hidden="true" className="h-px flex-1 bg-border/60" />
<time dateTime={row.iso}>
{label === 'today'
? t('chat.today')
: label === 'yesterday'
? t('chat.yesterday')
: formatMessageDate(row.iso, i18n.language)}
</time>
<span aria-hidden="true" className="h-px flex-1 bg-border/60" />
</li>
);
}
if (row.kind === 'unread') {
return (
<li
key={row.key}
role="separator"
aria-label={t('chat.newMessages')}
data-testid="unread-separator"
className="my-1 flex items-center gap-2 text-xs font-medium text-danger"
>
<span aria-hidden="true" className="h-px flex-1 bg-danger/60" />
{t('chat.newMessages')}
<span aria-hidden="true" className="h-px flex-1 bg-danger/60" />
</li>
);
}
const first = row.group.messages[0];
if (first === undefined) {
return null;
}
const author = authors.resolve(row.group.authorId ?? undefined);
return (
<li
key={row.key}
data-testid="message-group"
className="group/msg rounded-[var(--radius-md)] px-2 py-1 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2/40"
>
<div className="flex gap-3">
<div className="w-9 shrink-0 pt-0.5">
<Avatar
name={author.name}
seed={author.id === '' ? row.key : author.id}
fileId={author.avatarFileId}
size="md"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex flex-wrap items-baseline gap-2">
<span
className="text-sm font-semibold"
style={author.colorHex === null ? undefined : { color: author.colorHex }}
>
{author.name}
</span>
{author.isInstanceAdmin ? (
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent">
{t('chat.adminBadge')}
</span>
) : null}
<time
dateTime={first.created_at}
className="text-xs text-fg-muted"
title={formatMessageDate(first.created_at, i18n.language)}
>
{formatMessageTime(first.created_at, i18n.language)}
</time>
</header>
<div className="flex min-w-0 flex-col">
{row.group.messages.map((message, index) => (
<MessageItem
key={message.id}
message={message}
channelId={channelId}
channelName={channelName}
authors={authors}
currentUserId={currentUserId}
canManageMessages={canManageMessages}
canSend={canSend}
compact={index > 0}
{...(isPendingMessage(message) ? { pending: true } : {})}
replyTarget={
message.reply_to_id === undefined
? undefined
: messageById.get(message.reply_to_id)
}
highlight={message.id === highlightId}
onReply={onReply}
onJumpToReply={(messageId) => {
const container = containerRef.current;
const target = container?.querySelector(
`[data-message-id="${escapeSelector(messageId)}"]`,
);
if (target instanceof HTMLElement) {
target.scrollIntoView?.({ block: 'center' });
}
}}
/>
))}
</div>
</div>
</div>
</li>
);
})}
</ol>
)}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { Message } from '@/api/messages';
import { MessageContent } from '@/components/chat/MessageContent';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { formatMessageDateTime } from '@/lib/chat';
import { useMessagesStore } from '@/stores/messages';
interface PinsPanelProps {
channelId: string;
channelName: string;
open: boolean;
onClose: () => void;
authors: AuthorDirectory;
canManageMessages: boolean;
onJump: (messageId: string) => void;
}
/** Панель закреплённых сообщений комнаты (`GET /channels/{id}/pins`). */
export function PinsPanel({
channelId,
channelName,
open,
onClose,
authors,
canManageMessages,
onJump,
}: PinsPanelProps) {
const { t, i18n } = useTranslation();
const [actionError, setActionError] = useState<unknown>(null);
const record = useMessagesStore((state) => state.channels[channelId]);
const pins = record?.pins ?? [];
const loaded = record?.pinsLoaded ?? false;
const error = record?.pinsError ?? null;
useEffect(() => {
if (open) {
void useMessagesStore.getState().loadPins(channelId);
}
}, [open, channelId]);
const unpin = (message: Message): void => {
setActionError(null);
void useMessagesStore
.getState()
.setPinned(channelId, message.id, false)
.catch((error: unknown) => setActionError(error));
};
return (
<Modal
open={open}
title={t('chat.pins.title', { name: channelName })}
onClose={onClose}
footer={
<Button variant="ghost" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{error === null ? null : <ErrorNotice className="mb-2" error={error} />}
{actionError === null ? null : <ErrorNotice className="mb-2" error={actionError} />}
{!loaded && error === null ? (
<SkeletonLines rows={3} />
) : pins.length === 0 ? (
<p className="text-sm text-fg-muted">{t('chat.pins.empty')}</p>
) : (
<ul className="flex max-h-80 flex-col gap-2 overflow-y-auto">
{pins.map((message) => (
<li
key={message.id}
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2"
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium">
{authors.resolve(message.author_id).name}
</span>
<time dateTime={message.created_at} className="text-xs text-fg-muted">
{formatMessageDateTime(message.created_at, i18n.language)}
</time>
</div>
<MessageContent
content={message.content}
resolveMention={authors.resolveMention}
className="mt-1 line-clamp-3 text-sm"
/>
<div className="mt-1 flex gap-2">
<Button variant="ghost" onClick={() => onJump(message.id)}>
{t('chat.pins.jump')}
</Button>
{canManageMessages ? (
<Button variant="ghost" onClick={() => unpin(message)}>
{t('chat.actions.unpin')}
</Button>
) : null}
</div>
</li>
))}
</ul>
)}
</Modal>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { formatMessageDateTime } from '@/lib/chat';
import { useMessagesStore } from '@/stores/messages';
interface SearchPanelProps {
channelId: string;
open: boolean;
onClose: () => void;
authors: AuthorDirectory;
onJump: (messageId: string) => void;
}
/** Пауза перед запросом к FTS5: на сервере лимит 10 запросов в минуту. */
const SEARCH_DEBOUNCE_MS = 400;
/**
* Поиск по комнате (`GET /channels/{id}/messages/search`): строка ввода,
* результаты с подсветкой совпадений и переходом к сообщению.
*/
export function SearchPanel({ channelId, open, onClose, authors, onJump }: SearchPanelProps) {
const { t, i18n } = useTranslation();
const [query, setQuery] = useState('');
const inputRef = useRef<HTMLInputElement | null>(null);
const record = useMessagesStore((state) => state.channels[channelId]);
const status = record?.searchStatus ?? 'idle';
const results = record?.searchResults ?? [];
const error = record?.searchError ?? null;
useEffect(() => {
if (open) {
inputRef.current?.focus();
}
}, [open]);
// Дебаунс: сервер ограничивает поиск 10 запросами в минуту.
useEffect(() => {
if (!open) {
return;
}
const timer = window.setTimeout(() => {
void useMessagesStore.getState().search(channelId, query);
}, SEARCH_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
};
}, [open, channelId, query]);
useEffect(() => {
if (!open) {
useMessagesStore.getState().clearSearch(channelId);
}
}, [open, channelId]);
if (!open) {
return null;
}
const terms = query
.split(/\s+/u)
.map((term) => term.trim())
.filter((term) => term.length > 1);
return (
<section
aria-label={t('chat.search.title')}
data-testid="search-panel"
className="border-b border-border/40 bg-surface-1 px-3 py-2"
>
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="search"
value={query}
aria-label={t('chat.search.label')}
placeholder={t('chat.search.placeholder')}
className="w-full rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-fg-muted/70"
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
onClose();
}
}}
/>
<button
type="button"
aria-label={t('common.close')}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted hover:text-fg"
onClick={onClose}
>
×
</button>
</div>
<div className="mt-2 max-h-72 overflow-y-auto">
{error !== null ? (
<ErrorNotice error={error} />
) : status === 'searching' ? (
<SkeletonLines rows={2} />
) : query.trim() === '' ? (
<p className="text-xs text-fg-muted">{t('chat.search.hint')}</p>
) : status === 'ready' && results.length === 0 ? (
<p className="text-xs text-fg-muted">{t('chat.search.empty')}</p>
) : (
<ul className="flex flex-col gap-1" data-testid="search-results">
{results.map((message) => (
<li key={message.id}>
<button
type="button"
className="w-full rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 text-left transition-colors duration-[var(--duration-fast)] hover:bg-surface-3"
onClick={() => {
onJump(message.id);
onClose();
}}
>
<span className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium">
{authors.resolve(message.author_id).name}
</span>
<time dateTime={message.created_at} className="text-xs text-fg-muted">
{formatMessageDateTime(message.created_at, i18n.language)}
</time>
</span>
<span className="mt-1 block text-sm text-fg-muted">
{highlight(message.content, terms)}
</span>
</button>
</li>
))}
</ul>
)}
</div>
</section>
);
}
/** Подсветка совпадений в результатах поиска (без HTML-инъекций). */
function highlight(content: string, terms: string[]): ReactNode {
if (terms.length === 0) {
return content;
}
const pattern = new RegExp(`(${terms.map(escapeRegExp).join('|')})`, 'giu');
const parts = content.split(pattern);
return parts.map((part, index) =>
terms.some((term) => term.toLowerCase() === part.toLowerCase()) ? (
<mark key={`${String(index)}-${part}`} className="bg-accent/30 text-fg">
{part}
</mark>
) : (
<span key={`${String(index)}-${part}`}>{part}</span>
),
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
}
@@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { useMessagesStore, type TypingEntry } from '@/stores/messages';
interface TypingIndicatorProps {
channelId: string;
typing: TypingEntry[];
authors: AuthorDirectory;
}
/** Как часто проверяем, не погасли ли события `TYPING_START`. */
const PRUNE_INTERVAL_MS = 1000;
/**
* Строка «печатает…» под лентой. Живёт 6 секунд с последнего события
* `TYPING_START` (событие приходит раз в ~3 с, пока пользователь пишет).
*/
export function TypingIndicator({ channelId, typing, authors }: TypingIndicatorProps) {
const { t } = useTranslation();
useEffect(() => {
if (typing.length === 0) {
return;
}
const timer = window.setInterval(() => {
useMessagesStore.getState().pruneTyping();
}, PRUNE_INTERVAL_MS);
return () => {
window.clearInterval(timer);
};
}, [typing.length, channelId]);
if (typing.length === 0) {
return null;
}
const names = typing
.map((entry) => authors.resolve(entry.user_id).name)
.filter((name) => name !== '');
return (
<p role="status" data-testid="typing-indicator" className="px-4 pb-1 text-xs text-fg-muted">
{t('chat.typing', { count: names.length, names: names.join(', ') })}
</p>
);
}
@@ -0,0 +1,145 @@
import { useMemo } from 'react';
import { useQueries, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchMembers, fetchRoles, guildMembersQueryKey, guildRolesQueryKey } from '@/api/guilds';
import { fetchUser, userQueryKey } from '@/api/users';
import type { GuildMember } from '@/api/types';
import { memberDisplayName, roleColorForMember } from '@/lib/chat';
import { useSessionStore } from '@/stores/session';
/**
* Справочник авторов сообщений.
*
* Имя и цвет берём из участников сервера (ник + высшая роль), для авторов вне
* списка участников — из `GET /users/{id}`; если и там пусто, показываем
* «неизвестный» (автор мог выйти с сервера).
*/
export interface ChatAuthor {
id: string;
name: string;
avatarFileId: string | undefined;
colorHex: string | null;
isInstanceAdmin: boolean;
isMember: boolean;
}
export interface AuthorDirectory {
resolve: (userId: string | undefined) => ChatAuthor;
resolveMention: (userId: string) => { name: string; isMe: boolean };
/** Загружены ли участники: до этого момента имена могут быть неполными. */
loaded: boolean;
}
export function useAuthorDirectory(guildId: string | null, userIds: string[]): AuthorDirectory {
const { t } = useTranslation();
const currentUserId = useSessionStore((state) => state.user?.id ?? null);
const members = useQuery({
queryKey: guildMembersQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchMembers(guildId ?? '', signal),
enabled: guildId !== null,
retry: 0,
staleTime: 60_000,
});
const roles = useQuery({
queryKey: guildRolesQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchRoles(guildId ?? '', signal),
enabled: guildId !== null,
retry: 0,
staleTime: 60_000,
});
const membersLoaded = members.isSuccess;
const membersData = members.data;
const rolesData = roles.data;
const memberMap = useMemo(() => {
const map = new Map<string, GuildMember>();
for (const member of membersData ?? []) {
map.set(member.user_id, member);
}
return map;
}, [membersData]);
const unique = useMemo(() => [...new Set(userIds)].filter((id) => id !== ''), [userIds]);
const missing = useMemo(
() => (membersLoaded ? unique.filter((id) => !memberMap.has(id)) : []),
[unique, memberMap, membersLoaded],
);
const users = useQueries({
queries: missing.map((userId) => ({
queryKey: userQueryKey(userId),
queryFn: ({ signal }: { signal: AbortSignal }) => fetchUser(userId, signal),
staleTime: 300_000,
retry: 0,
})),
});
// Ключ из уже загруженных имён: сам массив запросов каждый рендер новый,
// поэтому в зависимостях используем стабильную строку.
const namesKey = missing
.map((userId, index) => {
const data = users[index]?.data;
return data === undefined
? ''
: `${userId}=${data.display_name !== '' ? data.display_name : data.username}`;
})
.join('|');
const userNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const entry of namesKey.split('|')) {
const separator = entry.indexOf('=');
if (separator > 0) {
map.set(entry.slice(0, separator), entry.slice(separator + 1));
}
}
return map;
}, [namesKey]);
return useMemo<AuthorDirectory>(() => {
const resolve = (userId: string | undefined): ChatAuthor => {
if (userId === undefined || userId === '') {
return {
id: '',
name: t('chat.unknownAuthor'),
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: false,
};
}
const member = memberMap.get(userId);
if (member !== undefined) {
return {
id: userId,
name: memberDisplayName(member) ?? t('chat.unknownAuthor'),
avatarFileId: member.avatar_file_id,
colorHex: roleColorForMember(member, rolesData ?? []),
isInstanceAdmin: member.is_instance_admin,
isMember: true,
};
}
const fallbackName = userNameMap.get(userId);
return {
id: userId,
name: fallbackName ?? t('chat.unknownAuthor'),
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: false,
};
};
return {
resolve,
resolveMention: (userId) => {
const author = resolve(userId);
return { name: author.name, isMe: userId === currentUserId };
},
loaded: membersLoaded,
};
}, [memberMap, rolesData, userNameMap, currentUserId, membersLoaded, t]);
}
@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { unreadCount, useMessagesStore, type ChannelMessagesState } from '@/stores/messages';
/**
* Непрочитанные сообщения и упоминания комнаты.
*
* Счётчик непрочитанных считается по загруженной истории относительно отметки
* прочтения (`READ_STATE_UPDATE`/`ack`), упоминания приходят с сервера готовым
* числом. Для комнат, которые ещё не открывали, история неизвестна — тогда
* показываем только упоминания.
*/
export function useChannelUnread(channelId: string): { unread: number; mentions: number } {
const selector = useCallback(
(state: { channels: Record<string, ChannelMessagesState> }) => state.channels[channelId],
[channelId],
);
const record = useMessagesStore(selector);
return {
unread: record === undefined ? 0 : unreadCount(record),
mentions: record?.mentionCount ?? 0,
};
}
+23 -4
View File
@@ -18,6 +18,13 @@ interface MenuProps {
* закрывалось бы и тут же открывалось заново), ему же возвращается фокус.
*/
anchorRef?: RefObject<HTMLElement | null>;
/**
* Куда раскрывается панель: вверх (`top`, по умолчанию — меню над панелью
* пользователя) или вниз (`bottom` — меню действий у сообщения).
*/
placement?: 'top' | 'bottom';
/** Горизонтальное выравнивание панели относительно якоря. */
align?: 'left' | 'right';
className?: string;
}
@@ -29,10 +36,20 @@ const ITEM_SELECTOR =
* открывается с фокусом на первом пункте, стрелки/Home/End перемещают фокус,
* Tab закрывает меню и возвращает управление странице.
*
* Позиционирование задаёт родитель: панель абсолютная и растёт вверх
* (`.gl-popover`), поэтому меню удобно вешать над нижней панелью пользователя.
* Позиционирование задаёт родитель: панель абсолютная и по умолчанию растёт
* вверх (`.gl-popover`) — так меню удобно вешать над нижней панелью
* пользователя; `placement="bottom"` раскрывает его вниз (меню сообщения).
*/
export function Menu({ open, label, onClose, children, anchorRef, className = '' }: MenuProps) {
export function Menu({
open,
label,
onClose,
children,
anchorRef,
placement = 'top',
align = 'left',
className = '',
}: MenuProps) {
const panelRef = useRef<HTMLDivElement | null>(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
@@ -136,7 +153,9 @@ export function Menu({ open, label, onClose, children, anchorRef, className = ''
role="menu"
aria-label={label}
onKeyDown={onKeyDown}
className={`gl-popover absolute bottom-full left-0 z-40 mb-2 min-w-56 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-1 shadow-[var(--shadow-3)] ${className}`}
className={`absolute z-40 min-w-56 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-1 shadow-[var(--shadow-3)] ${
placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2'
} ${align === 'right' ? 'right-0' : 'left-0'} ${className}`}
>
{children}
</div>
+101 -2
View File
@@ -50,7 +50,13 @@
"guild.member_limit_reached": "This server has reached its member limit.",
"channel.not_found": "Channel not found.",
"validation.failed": "Check the fields you filled in.",
"rate.limited": "Too many requests. Please wait a moment."
"rate.limited": "Too many requests. Please wait a moment.",
"perm.denied": "You do not have permission to do that.",
"rate_limited": "Too many requests. Please wait a moment.",
"message.edit_window_expired": "This message can no longer be edited: more than 24 hours passed.",
"file.too_large": "The file exceeds the allowed size.",
"request.bad": "Malformed request.",
"file.not_found": "File not found."
},
"theme": {
"switchToLight": "Light theme",
@@ -210,7 +216,100 @@
"notInGuild": {
"title": "Server unavailable",
"body": "Looks like you are not a member of this server."
}
},
"selectUnread": "Open channel {{name}}, {{count}} unread",
"selectMentions": "Open channel {{name}}, {{count}} mentions",
"slowmode": "slowmode {{seconds}}s"
},
"chat": {
"log": "Message log of {{name}}",
"loadMore": "Load more",
"loadingMore": "Loading…",
"today": "Today",
"yesterday": "Yesterday",
"edited": "(edited)",
"pending": "sending…",
"newMessages": "New messages",
"adminBadge": "admin",
"unknownAuthor": "unknown",
"start": {
"title": "This is the start of #{{name}}",
"body": "There are no messages here yet. Say something — everyone in the channel will see it."
},
"message": {
"by": "Message from {{name}} in {{channel}}",
"sentAt": "Sent {{time}}"
},
"reply": {
"to": "reply to {{name}}",
"missing": "deleted message",
"composer": "Replying to {{name}}",
"cancel": "Cancel reply"
},
"actions": {
"menu": "Message actions",
"reply": "Reply",
"react": "Add reaction",
"pin": "Pin",
"unpin": "Unpin",
"edit": "Edit",
"delete": "Delete",
"deleteConfirm": "Delete this message?",
"copy": "Copy text"
},
"reactions": {
"title": "Reactions",
"add": "React with {{emoji}}",
"toggle": "Reaction {{emoji}}: {{count}}"
},
"emoji": {
"title": "Emoji picker",
"quick": "Quick reactions",
"popular": "Popular emoji",
"pick": "React with {{emoji}}"
},
"edit": {
"label": "Edit message",
"hint": "Enter to save, Escape to cancel",
"save": "Save",
"cancel": "Cancel"
},
"composer": {
"label": "Message to {{name}}",
"placeholder": "Message #{{name}}",
"send": "Send",
"hint": "Enter to send, Shift+Enter for a new line",
"attach": "Attach a file",
"fileInput": "Choose files",
"uploading": "Uploading attachments…",
"attachments": "Message attachments",
"removeAttachment": "Remove attachment {{name}}",
"counter": "{{count}}/{{max}}",
"tooLarge": "File “{{name}}” exceeds the {{limit}} limit",
"rateLimited": "Too many messages. You can send again in {{seconds}} s.",
"denied": "You are not allowed to send messages in this channel.",
"readOnly": "This channel is read-only for you."
},
"typing_one": "{{names}} is typing…",
"typing_few": "{{names}} are typing…",
"typing_many": "{{names}} are typing…",
"typing_other": "{{names}} are typing…",
"pins": {
"open": "Pinned messages",
"title": "Pinned in #{{name}}",
"empty": "No pinned messages in this channel yet.",
"jump": "Jump"
},
"search": {
"open": "Search this channel",
"title": "Message search",
"label": "Search field",
"placeholder": "Search messages",
"hint": "Type at least two characters.",
"empty": "Nothing found."
},
"unread": "Unread messages: {{count}}",
"mentions": "Mentions: {{count}}"
},
"user": {
"panelLabel": "User panel",
+101 -2
View File
@@ -50,7 +50,13 @@
"guild.member_limit_reached": "На сервере достигнут лимит участников.",
"channel.not_found": "Комната не найдена.",
"validation.failed": "Проверьте заполненные поля.",
"rate.limited": "Слишком много запросов. Подождите немного."
"rate.limited": "Слишком много запросов. Подождите немного.",
"perm.denied": "Недостаточно прав для этого действия.",
"rate_limited": "Слишком много запросов. Подождите немного.",
"message.edit_window_expired": "Сообщение нельзя изменить: прошло больше 24 часов.",
"file.too_large": "Файл больше допустимого размера.",
"request.bad": "Некорректный запрос.",
"file.not_found": "Файл не найден."
},
"theme": {
"switchToLight": "Светлая тема",
@@ -210,7 +216,100 @@
"notInGuild": {
"title": "Сервер недоступен",
"body": "Похоже, вы не состоите в этом сервере."
}
},
"selectUnread": "Открыть комнату {{name}}, непрочитанных: {{count}}",
"selectMentions": "Открыть комнату {{name}}, упоминаний: {{count}}",
"slowmode": "медленный режим {{seconds}} с"
},
"chat": {
"log": "Лента сообщений комнаты {{name}}",
"loadMore": "Загрузить ещё",
"loadingMore": "Загружаем…",
"today": "Сегодня",
"yesterday": "Вчера",
"edited": "(изменено)",
"pending": "отправляется…",
"newMessages": "Новые сообщения",
"adminBadge": "админ",
"unknownAuthor": "неизвестный",
"start": {
"title": "Это начало комнаты #{{name}}",
"body": "Здесь пока нет сообщений. Напишите первым — сообщение увидят все участники комнаты."
},
"message": {
"by": "Сообщение от {{name}} в комнате {{channel}}",
"sentAt": "Отправлено {{time}}"
},
"reply": {
"to": "ответ {{name}}",
"missing": "сообщение удалено",
"composer": "Ответ {{name}}",
"cancel": "Отменить ответ"
},
"actions": {
"menu": "Действия с сообщением",
"reply": "Ответить",
"react": "Реакция",
"pin": "Закрепить",
"unpin": "Открепить",
"edit": "Изменить",
"delete": "Удалить",
"deleteConfirm": "Удалить это сообщение?",
"copy": "Копировать текст"
},
"reactions": {
"title": "Реакции",
"add": "Поставить реакцию {{emoji}}",
"toggle": "Реакция {{emoji}}: {{count}}"
},
"emoji": {
"title": "Выбор эмодзи",
"quick": "Быстрые реакции",
"popular": "Популярные эмодзи",
"pick": "Поставить реакцию {{emoji}}"
},
"edit": {
"label": "Редактирование сообщения",
"hint": "Enter — сохранить, Escape — отменить",
"save": "Сохранить",
"cancel": "Отмена"
},
"composer": {
"label": "Сообщение в комнату {{name}}",
"placeholder": "Написать в #{{name}}",
"send": "Отправить",
"hint": "Enter — отправить, Shift+Enter — новая строка",
"attach": "Прикрепить файл",
"fileInput": "Выбрать файлы",
"uploading": "Загружаем вложения…",
"attachments": "Вложения к сообщению",
"removeAttachment": "Убрать вложение {{name}}",
"counter": "{{count}}/{{max}}",
"tooLarge": "Файл «{{name}}» больше допустимых {{limit}}",
"rateLimited": "Слишком часто. Отправить можно будет через {{seconds}} с.",
"denied": "Нет права отправлять сообщения в эту комнату.",
"readOnly": "В эту комнату нельзя писать."
},
"typing_one": "{{names}} печатает…",
"typing_few": "{{names}} печатают…",
"typing_many": "{{names}} печатают…",
"typing_other": "{{names}} печатают…",
"pins": {
"open": "Закреплённые сообщения",
"title": "Закреплённые в #{{name}}",
"empty": "В этой комнате пока нет закреплённых сообщений.",
"jump": "Перейти"
},
"search": {
"open": "Поиск по комнате",
"title": "Поиск по сообщениям",
"label": "Строка поиска",
"placeholder": "Поиск по сообщениям",
"hint": "Введите не меньше двух символов.",
"empty": "Ничего не найдено."
},
"unread": "Непрочитанных сообщений: {{count}}",
"mentions": "Упоминаний: {{count}}"
},
"user": {
"panelLabel": "Панель пользователя",
+31
View File
@@ -173,6 +173,37 @@ body {
animation: gl-popover-in var(--duration-fast) var(--ease-out) backwards;
}
/* То же меню, но раскрывающееся вниз (действия у сообщения). */
@keyframes gl-popover-down-in {
from {
opacity: 0;
transform: translateY(-6px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
.gl-popover-down {
transform-origin: top;
animation: gl-popover-down-in var(--duration-fast) var(--ease-out) backwards;
}
/* Подсветка сообщения, к которому перешли из поиска или от ответа. */
@keyframes gl-highlight-fade {
from {
background-color: color-mix(in oklch, var(--color-accent) 28%, transparent);
}
to {
background-color: transparent;
}
}
.gl-highlight {
animation: gl-highlight-fade 1.6s var(--ease-out) backwards;
}
/*
* Каскадное появление списка. Задержка растёт только у первых восьми
* элементов — дальше список появляется без ступенек, чтобы длинные
+286
View File
@@ -0,0 +1,286 @@
import type { GuildMember, Role } from '@/api/types';
import { roleColorHex } from '@/lib/format';
/**
* Чистые помощники чата: группировка сообщений, разделители дат, форматирование
* времени и цвета автора. Вынесены из компонентов, чтобы их можно было
* проверить юнит-тестами без DOM.
*/
/** Подряд идущие сообщения одного автора объединяются в пределах 5 минут. */
export const GROUP_WINDOW_MS = 5 * 60 * 1000;
/** Быстрые реакции в панели действий над сообщением. */
export const QUICK_REACTIONS = ['👍', '🔥', '😂', '❤️', '🎉'] as const;
/** Набор популярных эмодзи для выпадающего пикера. */
export const POPULAR_EMOJIS = [
'👍',
'👎',
'🔥',
'🎉',
'😂',
'😅',
'😍',
'🤔',
'😮',
'😢',
'😡',
'🙏',
'👏',
'💪',
'❤️',
'💔',
'✅',
'❌',
'⚠️',
'❓',
'💡',
'🚀',
'🐛',
'☕',
'🍕',
'🎯',
'📌',
'👀',
'🤝',
'🫡',
] as const;
/** Сравнение идентификаторов сообщений: снежинки — возрастающие числа. */
export function compareMessageIds(left: string, right: string): number {
try {
const a = BigInt(left);
const b = BigInt(right);
return a === b ? 0 : a < b ? -1 : 1;
} catch {
return left === right ? 0 : left < right ? -1 : 1;
}
}
/** Минимум полей, нужный для построения строк ленты. */
export interface MessageLike {
id: string;
author_id?: string | undefined;
created_at: string;
reply_to_id?: string | undefined;
type?: string | undefined;
}
export interface ChatGroup<M extends MessageLike> {
key: string;
authorId: string | null;
messages: M[];
}
export type ChatRow<M extends MessageLike> =
| { kind: 'date'; key: string; iso: string }
| { kind: 'unread'; key: string }
| { kind: 'group'; key: string; group: ChatGroup<M> };
export interface BuildRowsOptions {
/**
* id последнего прочитанного сообщения: разделитель «новые сообщения»
* встаёт перед следующим за ним сообщением.
*/
unreadAnchorId?: string | null | undefined;
}
export function parseDate(iso: string): Date | null {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? null : date;
}
export function sameDay(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
/** Ключ локального дня (`YYYY-MM-DD`) — для разделителей и группировки. */
export function dayKey(iso: string): string {
const date = parseDate(iso);
if (date === null) {
return iso;
}
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${String(date.getFullYear())}-${month}-${day}`;
}
/** «Сегодня» / «Вчера» / конкретная дата — метка разделителя дня. */
export type DayLabel = 'today' | 'yesterday' | 'date';
export function dayLabel(iso: string, now: Date = new Date()): DayLabel {
const date = parseDate(iso);
if (date === null) {
return 'date';
}
if (sameDay(date, now)) {
return 'today';
}
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
return sameDay(date, yesterday) ? 'yesterday' : 'date';
}
/** Могут ли два соседних сообщения стоять в одной группе. */
export function canGroup(previous: MessageLike, next: MessageLike): boolean {
if (previous.author_id === undefined || previous.author_id !== next.author_id) {
return false;
}
if (next.reply_to_id !== undefined) {
return false;
}
if ((next.type ?? 'default') !== 'default') {
return false;
}
const left = parseDate(previous.created_at);
const right = parseDate(next.created_at);
if (left === null || right === null || !sameDay(left, right)) {
return false;
}
const delta = right.getTime() - left.getTime();
return delta >= 0 && delta <= GROUP_WINDOW_MS;
}
/**
* Строки ленты: разделитель дня, необязательный разделитель непрочитанного
* и группы сообщений. Порядок входа — от старых к новым.
*/
export function buildChatRows<M extends MessageLike>(
messages: M[],
options: BuildRowsOptions = {},
): ChatRow<M>[] {
const rows: ChatRow<M>[] = [];
const anchor = options.unreadAnchorId ?? null;
// «0» — комната никогда не читалась: разделитель встаёт перед первым сообщением.
const neverRead = anchor === '0';
const anchorIndex =
anchor === null || neverRead ? -1 : messages.findIndex((message) => message.id === anchor);
const first = messages[0];
// Отметка прочтения старше загруженного окна — непрочитано всё, что загружено
// (так же считает `unreadCount`), поэтому линия встаёт перед первым сообщением.
const anchorIsOlder =
anchor !== null &&
!neverRead &&
anchorIndex < 0 &&
first !== undefined &&
compareMessageIds(first.id, anchor) > 0;
const unreadBefore = neverRead
? messages[0]
: anchorIndex >= 0
? messages[anchorIndex + 1]
: anchorIsOlder
? messages[0]
: undefined;
let currentDay: string | null = null;
let group: ChatGroup<M> | null = null;
for (const message of messages) {
const day = dayKey(message.created_at);
if (day !== currentDay) {
currentDay = day;
group = null;
rows.push({ kind: 'date', key: `date:${day}`, iso: message.created_at });
}
if (unreadBefore !== undefined && unreadBefore.id === message.id) {
group = null;
rows.push({ kind: 'unread', key: 'unread' });
}
const last = group?.messages[group.messages.length - 1];
if (group !== null && last !== undefined && canGroup(last, message)) {
group.messages.push(message);
continue;
}
group = {
key: `group:${message.id}`,
authorId: message.author_id ?? null,
messages: [message],
};
rows.push({ kind: 'group', key: group.key, group });
}
return rows;
}
/** Время сообщения в формате локали (`12:34`). */
export function formatMessageTime(iso: string, locale: string): string {
const date = parseDate(iso);
if (date === null) {
return '';
}
return new Intl.DateTimeFormat(locale, { timeStyle: 'short' }).format(date);
}
/** Дата разделителя (`19 сентября 2026 г.`). */
export function formatMessageDate(iso: string, locale: string): string {
const date = parseDate(iso);
if (date === null) {
return iso;
}
return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(date);
}
/** Полная дата и время — для подсказки `title` у времени сообщения. */
export function formatMessageDateTime(iso: string, locale: string): string {
const date = parseDate(iso);
if (date === null) {
return iso;
}
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(date);
}
/**
* Цвет автора — цвет самой высокой роли участника с заданным цветом.
* Роли без цвета (0) пропускаем: у них нейтральный вид.
*/
export function roleColorForMember(
member: GuildMember | undefined,
roles: readonly Role[],
): string | null {
if (member === undefined || member.role_ids.length === 0) {
return null;
}
const colors = member.role_ids
.map((roleId) => roles.find((role) => role.id === roleId))
.filter((role): role is Role => role !== undefined && role.color > 0)
.sort((left, right) => right.position - left.position);
const top = colors[0];
return top === undefined ? null : roleColorHex(top.color);
}
/** Имя автора: ник на сервере важнее отображаемого имени. */
export function memberDisplayName(member: GuildMember | undefined): string | null {
if (member === undefined) {
return null;
}
const nickname = member.nickname;
if (nickname !== undefined && nickname.trim() !== '') {
return nickname;
}
return member.display_name !== '' ? member.display_name : member.username;
}
/** Копирование текста с мягкой деградацией (в тестах clipboard нет). */
export async function copyText(text: string): Promise<boolean> {
if (typeof navigator === 'undefined' || navigator.clipboard === undefined) {
return false;
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
/** Экранирование значения для CSS-селектора (в jsdom `CSS.escape` может не быть). */
export function escapeSelector(value: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
return value.replace(/[^a-zA-Z0-9_-]/gu, (char) => `\\${char}`);
}
+29
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { fetchInstance, instanceQueryKey } from '@/api/instance';
@@ -21,3 +22,31 @@ export function useInstance(): UseQueryResult<InstanceInfo> {
staleTime: 60_000,
});
}
/**
* Активно ли окно: вкладка видима и окно в фокусе.
*
* Нужен для отметки прочтения — `POST /ack` уходит только когда пользователь
* действительно смотрит на комнату.
*/
export function useWindowActive(): boolean {
const [active, setActive] = useState(
() => typeof document === 'undefined' || document.visibilityState !== 'hidden',
);
useEffect(() => {
const onFocus = (): void => setActive(true);
const onBlur = (): void => setActive(false);
const onVisibility = (): void => setActive(document.visibilityState !== 'hidden');
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
return active;
}
+44 -2
View File
@@ -39,6 +39,10 @@ export function avatarUrl(fileId: string | undefined): string | null {
export const PERMISSION_ADMINISTRATOR = 'ADMINISTRATOR';
export const PERMISSION_MANAGE_GUILD = 'MANAGE_GUILD';
export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS';
export const PERMISSION_MANAGE_MESSAGES = 'MANAGE_MESSAGES';
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
export const PERMISSION_ADD_REACTIONS = 'ADD_REACTIONS';
/** Есть ли у пользователя право с указанным именем. */
export function hasPermission(permissions: readonly string[], name: string): boolean {
@@ -81,8 +85,46 @@ export function canManageGuildSettings(
);
}
/** Человекочитаемая дата/время для списков (сессии, аудит). */
export function formatDateTime(value: string, locale: string): string {
/**
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
* проверит права в конкретной комнате.
*/
export function canManageMessages(
permissions: readonly string[],
isInstanceAdmin = false,
): boolean {
if (isInstanceAdmin) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_MANAGE_MESSAGES)
);
}
/** Может ли пользователь отправлять сообщения в комнату. */
export function canSendMessages(
permissions: readonly string[],
isInstanceAdmin = false,
channelCanSend?: boolean,
): boolean {
if (channelCanSend === false) {
return false;
}
if (isInstanceAdmin) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_SEND_MESSAGES)
);
}
/** Человекочитаемая дата/время для списков (сессии, аудит). */ export function formatDateTime(
value: string,
locale: string,
): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
+6 -2
View File
@@ -156,8 +156,12 @@ export default function AppLayout() {
/>
<ChannelSidebar guildId={guildId} channelId={channelId} />
<div className="flex min-w-0 flex-1 flex-col">
{/* Ключ по пути: смена комнаты/сервера появляется с анимацией. */}
<div key={location.pathname} className="gl-page min-h-0 flex-1 overflow-auto">
{/*
Ключ по пути: смена комнаты/сервера появляется с анимацией.
`relative` нужен чату: его область позиционируется absolute inset-0
и скроллится изнутри, а не всей страницей.
*/}
<div key={location.pathname} className="gl-page relative min-h-0 flex-1 overflow-auto">
<Outlet />
</div>
<UserPanel />
+52 -14
View File
@@ -4,6 +4,9 @@ import { useTranslation } from 'react-i18next';
import { Link, NavLink, useNavigate } from 'react-router';
import { createChannel, fetchChannels, guildChannelsQueryKey, leaveGuild } from '@/api/guilds';
import type { Channel } from '@/api/types';
import { ChannelBadge } from '@/components/chat/ChannelBadge';
import { useChannelUnread } from '@/components/chat/useChannelUnread';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField } from '@/components/ui/Field';
@@ -20,6 +23,50 @@ interface ChannelSidebarProps {
channelId: string | null;
}
/**
* Ссылка на комнату с бейджем непрочитанного. Отдельный компонент нужен,
* чтобы подписка на стор сообщений жила на каждую комнату, а не на весь
* сайдбар (иначе любое новое сообщение перерисовывало бы список целиком).
*/
function ChannelLink({
guildId,
channel,
selected,
}: {
guildId: string | null;
channel: Channel;
selected: boolean;
}) {
const { t } = useTranslation();
const { unread, mentions } = useChannelUnread(channel.id);
const highlighted = !selected && (unread > 0 || mentions > 0);
// aria-label ссылки перекрывает её содержимое, поэтому счётчики озвучиваем здесь.
const label =
mentions > 0
? t('channels.selectMentions', { name: channel.name, count: mentions })
: unread > 0
? t('channels.selectUnread', { name: channel.name, count: unread })
: t('channels.select', { name: channel.name });
return (
<NavLink
to={`/app/${guildId ?? ''}/${channel.id}`}
aria-current={selected ? 'page' : undefined}
aria-label={label}
title={channel.name}
className={`relative flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
selected
? 'gl-indicator bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
} ${highlighted ? 'font-semibold text-fg' : ''}`}
>
<span aria-hidden="true">{channel.type === 'voice' ? '🔊' : '#'}</span>
<span className="truncate">{channel.name}</span>
<ChannelBadge channelId={channel.id} selected={selected} />
</NavLink>
);
}
/** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */
export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
const { t } = useTranslation();
@@ -158,20 +205,11 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
<ul className="gl-stagger flex flex-col gap-0.5">
{group.channels.map((channel) => (
<li key={channel.id}>
<NavLink
to={`/app/${guildId}/${channel.id}`}
aria-current={channel.id === channelId ? 'page' : undefined}
aria-label={t('channels.select', { name: channel.name })}
title={channel.name}
className={`relative flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
channel.id === channelId
? 'gl-indicator bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
}`}
>
<span aria-hidden="true">{channel.type === 'voice' ? '🔊' : '#'}</span>
<span className="truncate">{channel.name}</span>
</NavLink>
<ChannelLink
guildId={guildId}
channel={channel}
selected={channel.id === channelId}
/>
</li>
))}
</ul>
+117 -17
View File
@@ -1,12 +1,20 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import type { Message } from '@/api/messages';
import { ChatView } from '@/components/chat/ChatView';
import { PinsPanel } from '@/components/chat/PinsPanel';
import { SearchPanel } from '@/components/chat/SearchPanel';
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { Card } from '@/components/ui/primitives';
import { canManageMessages, canSendMessages } from '@/lib/identity';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
/**
* Область контента комнаты. В Фазе 1 — заглушка: сообщения, голос и видео
* появятся в следующих фазах, но маршрутизация и выбор комнаты уже работают.
* Область контента комнаты: для текстовых комнат — чат (лента, поиск, пины),
* для голосовых сохраняется заглушка до следующих фаз.
*/
export default function GuildView() {
const { t } = useTranslation();
@@ -16,11 +24,42 @@ export default function GuildView() {
? null
: (state.guilds.find((item) => item.id === params.guildId) ?? null),
);
const sessionUser = useSessionStore((state) => state.user);
const channel = guild?.channels.find((item) => item.id === params.channelId) ?? null;
const channelId = channel?.id ?? null;
const [searchOpen, setSearchOpen] = useState(false);
const [pinsOpen, setPinsOpen] = useState(false);
const [jump, setJump] = useState<{ id: string; token: number } | null>(null);
const messages = useMessagesStore((state) =>
channelId === null ? undefined : state.channels[channelId]?.messages,
);
const pins = useMessagesStore((state) =>
channelId === null ? undefined : state.channels[channelId]?.pins,
);
const pinsLoaded = useMessagesStore(
(state) => channelId !== null && state.channels[channelId]?.pinsLoaded === true,
);
// Справочник авторов для поиска и пинов: id берём из загруженной ленты.
const authorIds = useMemo(
() =>
(messages ?? [])
.map((message: Message) => message.author_id)
.filter((id): id is string => id !== undefined),
[messages],
);
const authors = useAuthorDirectory(guild?.id ?? null, authorIds);
const onJumpHandled = useCallback(() => setJump(null), []);
const goToMessage = useCallback((id: string) => {
setJump({ id, token: Date.now() });
}, []);
if (params.channelId === undefined || channel === null) {
return (
<section id="app-content" className="flex h-full items-center justify-center p-6">
<section id="app-content" className="absolute inset-0 flex items-center justify-center p-6">
<Card className="max-w-md text-center">
<h2 className="text-lg font-semibold">{t('channels.noSelection.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('channels.noSelection.body')}</p>
@@ -30,30 +69,91 @@ export default function GuildView() {
}
const isVoice = channel.type === 'voice';
const permissions = guild?.my_permissions ?? [];
const isAdmin = sessionUser?.is_instance_admin === true;
const canPin = canManageMessages(permissions, isAdmin) && canSendMessages(permissions, isAdmin);
// Счётчик пинов: пока панель не открывали, ориентируемся на загруженную ленту.
const pinCount = pinsLoaded
? (pins?.length ?? 0)
: (messages ?? []).filter((message: Message) => message.pinned).length;
return (
<section id="app-content" className="flex h-full flex-col">
<section id="app-content" className="absolute inset-0 flex flex-col">
<header className="flex items-center gap-2 border-b border-border/40 px-4 py-3">
<span aria-hidden="true" className="text-fg-muted">
{isVoice ? '🔊' : '#'}
</span>
<h2 className="text-sm font-semibold">{channel.name}</h2>
{channel.slowmode_seconds === undefined || channel.slowmode_seconds === 0 ? null : (
<span className="text-xs text-fg-muted">slowmode {channel.slowmode_seconds}s</span>
<span className="text-xs text-fg-muted">
{t('channels.slowmode', { seconds: channel.slowmode_seconds })}
</span>
)}
{isVoice ? null : (
<div className="ml-auto flex items-center gap-1">
<button
type="button"
aria-label={t('chat.search.open')}
title={t('chat.search.open')}
aria-expanded={searchOpen}
className="rounded-[var(--radius-sm)] px-1.5 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => setSearchOpen((open) => !open)}
>
🔍
</button>
<button
type="button"
aria-label={t('chat.pins.open')}
title={t('chat.pins.open')}
className="flex items-center gap-1 rounded-[var(--radius-sm)] px-1.5 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => setPinsOpen(true)}
>
📌
{pinCount === 0 ? null : <span className="text-xs font-medium">{pinCount}</span>}
</button>
</div>
)}
</header>
<div className="flex flex-1 items-center justify-center p-6">
<Card className="max-w-md text-center">
<h3 className="text-lg font-semibold">
{t(isVoice ? 'channels.voicePlaceholder.title' : 'channels.chatPlaceholder.title')}
</h3>
<p className="mt-2 text-sm text-fg-muted">
{t(isVoice ? 'channels.voicePlaceholder.body' : 'channels.chatPlaceholder.body', {
name: channel.name,
})}
</p>
</Card>
</div>
{isVoice ? (
<div className="flex flex-1 items-center justify-center p-6">
<Card className="max-w-md text-center">
<h3 className="text-lg font-semibold">{t('channels.voicePlaceholder.title')}</h3>
<p className="mt-2 text-sm text-fg-muted">
{t('channels.voicePlaceholder.body', { name: channel.name })}
</p>
</Card>
</div>
) : (
<>
<SearchPanel
channelId={channel.id}
open={searchOpen}
onClose={() => setSearchOpen(false)}
authors={authors}
onJump={goToMessage}
/>
<ChatView
key={channel.id}
channel={channel}
guildId={guild?.id ?? null}
jumpTo={jump}
onJumpHandled={onJumpHandled}
/>
<PinsPanel
channelId={channel.id}
channelName={channel.name}
open={pinsOpen}
onClose={() => setPinsOpen(false)}
authors={authors}
canManageMessages={canPin}
onJump={(id) => {
setPinsOpen(false);
goToMessage(id);
}}
/>
</>
)}
</section>
);
}
+3
View File
@@ -11,6 +11,7 @@ import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/M
import { useCurrentUser } from '@/lib/hooks';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { useGatewayStore } from '@/stores/gateway';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
/** Статусы, которые можно выставить из меню: «не в сети» сервер выводит сам. */
@@ -48,6 +49,8 @@ export function UserPanel() {
// Сессия в cookie уже погашена — сбрасываем локальное состояние.
disconnect();
useSessionStore.getState().reset();
// Сообщения тоже привязаны к пользователю: чужой истории в памяти быть не должно.
useMessagesStore.getState().reset();
queryClient.clear();
void navigate('/login', { replace: true });
},
+77 -1
View File
@@ -13,9 +13,16 @@ import {
parseChannelPayload,
parseGuildId,
parseGuildUpdate,
parseMessageDelete,
parseMessagePayload,
parsePinsUpdate,
parseReactionEvent,
parseReadStateUpdate,
parseRoleEvent,
parseTypingStart,
parseUserUpdate,
} from '@/api/gatewayEvents';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
/**
@@ -63,6 +70,8 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
onInvalidSession: () => {
set({ invalidated: true, status: 'disconnected' });
useSessionStore.getState().reset();
// Сессия мертва: лента сообщений тоже больше не наша.
useMessagesStore.getState().reset();
void invalidateCurrentUser();
},
onResumeIncomplete: () => {
@@ -87,9 +96,10 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
},
}));
/** Разбор события DISPATCH: в Фазе 1 обрабатываем READY/RESUMED, остальные — в диспетчер. */
/** Разбор события DISPATCH: READY/RESUMED, гильдии/каналы и Фаза 2 (сообщения). */
export function dispatchGatewayEvent(event: GatewayDispatch): void {
const session = useSessionStore.getState();
const messages = useMessagesStore.getState();
switch (event.t) {
case 'READY': {
const snapshot = parseGatewaySnapshot(event.d);
@@ -97,6 +107,15 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
return;
}
session.applyReady(snapshot);
// Снапшот несёт отметки прочтения — переносим их в стор сообщений.
for (const raw of snapshot.read_states) {
const readState = parseReadStateUpdate(raw);
if (readState !== null) {
messages.applyReadState(readState);
}
}
// После этого отсутствие записи для комнаты означает «не читалась».
messages.markSnapshotApplied();
useGatewayStore.setState({
status: 'connected',
heartbeatIntervalMs: snapshot.heartbeat_interval_ms,
@@ -163,9 +182,66 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
return;
}
session.removeChannel(deleted.guild_id, deleted.channel_id);
messages.dropChannel(deleted.channel_id);
syncUrlWithSelection();
return;
}
case 'MESSAGE_CREATE': {
const message = parseMessagePayload(event.d);
if (message !== null) {
messages.applyMessageCreate(message);
}
return;
}
case 'MESSAGE_UPDATE': {
const message = parseMessagePayload(event.d);
if (message !== null) {
messages.applyMessageUpdate(message);
}
return;
}
case 'MESSAGE_DELETE': {
const deleted = parseMessageDelete(event.d);
if (deleted !== null) {
messages.applyMessageDelete(deleted.channel_id, deleted.id);
}
return;
}
case 'MESSAGE_REACTION_ADD':
case 'MESSAGE_REACTION_REMOVE': {
const reaction = parseReactionEvent(event.d);
if (reaction !== null) {
messages.applyReaction(
reaction.channel_id,
reaction.message_id,
reaction.emoji,
reaction.user_id,
event.t === 'MESSAGE_REACTION_ADD',
);
}
return;
}
case 'CHANNEL_PINS_UPDATE': {
const update = parsePinsUpdate(event.d);
if (update !== null) {
messages.applyPinsUpdate(update.channel_id, update.message_id, update.pinned);
}
return;
}
case 'TYPING_START': {
const typing = parseTypingStart(event.d);
if (typing !== null) {
messages.applyTypingStart(typing.channel_id, typing.user_id);
}
return;
}
case 'READ_STATE_UPDATE': {
const readState = parseReadStateUpdate(event.d);
if (readState !== null) {
messages.applyReadState(readState);
}
return;
}
case 'MEMBER_UPDATE':
case 'MEMBER_REMOVE': {
// Роли участника влияют на права: перечитываем участников и карточку сервера.
+823
View File
@@ -0,0 +1,823 @@
import { create } from 'zustand';
import {
acknowledgeChannel,
addMessageReaction,
createNonce,
deleteChannelMessage,
editChannelMessage,
extractMentions,
fetchChannelMessages,
fetchChannelPins,
pinChannelMessage,
removeMessageReaction,
searchChannelMessages,
sendChannelMessage,
sendTypingIndicator,
unpinChannelMessage,
MESSAGE_PAGE_SIZE,
MESSAGE_SEARCH_LIMIT,
type Message,
type MessageReaction,
type PendingMessage,
} from '@/api/messages';
import type { UploadedFile } from '@/api/files';
import { useSessionStore } from '@/stores/session';
/**
* Сообщения комнат (Фаза 2).
*
* Стор — единственный источник правды по ленте: REST-ответы и события шлюза
* складываются в один список на комнату (старые → новые), поэтому рендер не
* зависит от порядка, в котором пришли данные. Оптимистичные сообщения живут
* в отдельном массиве `pending` и исчезают, как только сервер подтвердил
* отправку (или вернул ошибку).
*/
/** Сколько сообщений храним на комнату, чтобы память не росла бесконечно. */
export const MESSAGE_CACHE_LIMIT = 200;
/** Через сколько гаснет «печатает…» без новых событий. */
export const TYPING_TTL_MS = 6_000;
/** Как часто клиент шлёт `POST /typing`, пока идёт ввод. */
export const TYPING_THROTTLE_MS = 3_000;
export type { PendingMessage };
export interface TypingEntry {
user_id: string;
at: number;
}
export type ChannelStatus = 'idle' | 'loading' | 'ready' | 'error';
export type SearchStatus = 'idle' | 'searching' | 'ready' | 'error';
export interface ChannelMessagesState {
/** История: старые → новые, не больше `MESSAGE_CACHE_LIMIT`. */
messages: Message[];
/** Оптимистичные сообщения, ещё не подтверждённые сервером. */
pending: PendingMessage[];
status: ChannelStatus;
/** Ошибка первичной загрузки либо подгрузки истории. */
error: unknown;
loadingMore: boolean;
hasMore: boolean;
/**
* Курсор подгрузки вверх: id самого старого сообщения последней страницы.
* Хранится отдельно от `messages`, потому что кэш ограничен и «самое старое»
* в нём перестаёт двигаться, когда окно упирается в лимит.
*/
pagingCursor: string | null;
pins: Message[];
pinsLoaded: boolean;
pinsError: unknown;
searchQuery: string;
searchStatus: SearchStatus;
searchResults: Message[];
searchError: unknown;
/** id последнего прочитанного сообщения (read state). */
lastReadMessageId: string | null;
/** Пришла ли отметка прочтения (из снапшота READY или событием). */
readStateKnown: boolean;
mentionCount: number;
typing: TypingEntry[];
}
export interface SendMessageInput {
channelId: string;
content: string;
replyToId?: string;
/** Уже загруженные вложения (`POST /channels/{id}/files`). */
files?: UploadedFile[];
nonce?: string;
}
interface MessagesState {
channels: Record<string, ChannelMessagesState>;
/** Применён ли снапшот READY (после него отсутствие read state — это факт). */
snapshotApplied: boolean;
/** Первичная загрузка (идемпотентно). */
ensureChannel: (channelId: string) => Promise<void>;
/** Принудительная перезагрузка последней страницы истории. */
reloadChannel: (channelId: string) => Promise<void>;
/** Подгрузка вверх: `before` = id самого старого сообщения. */
loadMore: (channelId: string) => Promise<void>;
loadPins: (channelId: string) => Promise<void>;
search: (channelId: string, query: string) => Promise<void>;
clearSearch: (channelId: string) => void;
send: (input: SendMessageInput) => Promise<Message>;
edit: (channelId: string, messageId: string, content: string) => Promise<void>;
remove: (channelId: string, messageId: string) => Promise<void>;
toggleReaction: (channelId: string, messageId: string, emoji: string) => Promise<void>;
setPinned: (channelId: string, messageId: string, pinned: boolean) => Promise<void>;
notifyTyping: (channelId: string) => void;
/** Отметка прочтения: локально сразу, на сервере — фоном. */
acknowledge: (channelId: string, lastMessageId?: string) => Promise<void>;
applyMessageCreate: (message: Message) => void;
applyMessageUpdate: (message: Message) => void;
applyMessageDelete: (channelId: string, messageId: string) => void;
applyReaction: (
channelId: string,
messageId: string,
emoji: string,
userId: string,
added: boolean,
) => void;
applyPinsUpdate: (channelId: string, messageId: string, pinned: boolean) => void;
applyTypingStart: (channelId: string, userId: string) => void;
applyReadState: (event: {
channel_id: string;
last_message_id?: string;
mention_count: number;
}) => void;
/** Комната удалена — выбрасываем её состояние. */
dropChannel: (channelId: string) => void;
/** Снапшот READY применён: отметки прочтения для комнат уже известны. */
markSnapshotApplied: () => void;
pruneTyping: (now?: number) => void;
reset: () => void;
}
function emptyChannel(): ChannelMessagesState {
return {
messages: [],
pending: [],
status: 'idle',
error: null,
loadingMore: false,
hasMore: false,
pagingCursor: null,
pins: [],
pinsLoaded: false,
pinsError: null,
searchQuery: '',
searchStatus: 'idle',
searchResults: [],
searchError: null,
lastReadMessageId: null,
readStateKnown: false,
mentionCount: 0,
typing: [],
};
}
/**
* Заглушка для незагруженных комнат. Объект один на всех: селектор не должен
* возвращать новую ссылку на каждом вызове, иначе React перерисовывается зря.
*/
const EMPTY_CHANNEL: ChannelMessagesState = emptyChannel();
export function channelState(state: MessagesState, channelId: string): ChannelMessagesState {
return state.channels[channelId] ?? EMPTY_CHANNEL;
}
/** Сравнение идентификаторов: снежинки — возрастающие числа в виде строк. */
function compareIds(left: string, right: string): number {
try {
const a = BigInt(left);
const b = BigInt(right);
return a === b ? 0 : a < b ? -1 : 1;
} catch {
// Оптимистичные id (`pending:…`) не числа — сравниваем как строки.
return left === right ? 0 : left < right ? -1 : 1;
}
}
/** Порядок ленты: по времени создания, при равенстве — по id. */
export function compareMessages(left: Message, right: Message): number {
const a = Date.parse(left.created_at);
const b = Date.parse(right.created_at);
if (Number.isFinite(a) && Number.isFinite(b) && a !== b) {
return a < b ? -1 : 1;
}
return compareIds(left.id, right.id);
}
function sortMessages(messages: Message[]): Message[] {
return [...messages].sort(compareMessages);
}
function capMessages(messages: Message[]): Message[] {
return messages.length <= MESSAGE_CACHE_LIMIT
? messages
: messages.slice(messages.length - MESSAGE_CACHE_LIMIT);
}
function upsertMessage(messages: Message[], message: Message): Message[] {
const index = messages.findIndex((item) => item.id === message.id);
if (index === -1) {
return capMessages(sortMessages([...messages, message]));
}
const next = [...messages];
next[index] = message;
return next;
}
function removeMessage(messages: Message[], messageId: string): Message[] {
return messages.filter((item) => item.id !== messageId);
}
/**
* Слияние страницы истории с текущим списком.
*
* `replace` — первичная загрузка: выборка заменяет историю, но сообщения,
* пришедшие событиями шлюза во время запроса (они новее выборки), остаются.
* `prepend` — подгрузка вверх: старые добавляются перед текущими.
*/
function mergeHistory(
current: ChannelMessagesState,
page: Message[],
mode: 'replace' | 'prepend',
): Partial<ChannelMessagesState> {
const pageIds = new Set(page.map((message) => message.id));
const kept = current.messages.filter((message) => !pageIds.has(message.id));
let merged: Message[];
if (mode === 'replace') {
const newest = page.reduce<Message | null>(
(acc, message) => (acc === null || compareMessages(message, acc) > 0 ? message : acc),
null,
);
// Пустая выборка — сервер действительно не отдал сообщений (всё удалено).
// Совсем свежие записи всё же оставляем: они могли прийти событием шлюза
// ровно в момент запроса.
const fresh =
newest === null
? kept.filter((message) => {
const created = Date.parse(message.created_at);
return Number.isFinite(created) && Date.now() - created < 60_000;
})
: kept.filter((message) => compareMessages(message, newest) > 0);
merged = [...page, ...fresh];
} else {
merged = [...page, ...kept];
}
const sorted = sortMessages(merged);
// Кэш ограничен: при подгрузке вверх вытесняем самые новые сообщения,
// иначе только что загруженная страница была бы сразу отброшена и
// «Загрузить ещё» перестало бы двигать историю.
const capped =
sorted.length <= MESSAGE_CACHE_LIMIT
? sorted
: mode === 'prepend'
? sorted.slice(0, MESSAGE_CACHE_LIMIT)
: capMessages(sorted);
// Страница приходит от новых к старым, поэтому курсор — самый старый её элемент
// (порядок не полагаем на удачу: берём минимум по компаратору).
const oldest =
mode === 'prepend' && page.length > 0
? page.reduce((acc, message) => (compareMessages(message, acc) < 0 ? message : acc))
: undefined;
return {
messages: capped,
status: 'ready',
error: null,
loadingMore: false,
hasMore: page.length >= MESSAGE_PAGE_SIZE,
...(oldest === undefined ? {} : { pagingCursor: oldest.id }),
};
}
/**
* Локальное изменение реакций: `actorId` — кто поставил/снял реакцию,
* `meId` — текущий пользователь (от него зависит подсветка «моя реакция»).
*/
function applyReactionLocally(
reactions: MessageReaction[],
emoji: string,
actorId: string | null,
meId: string | null,
added: boolean,
): MessageReaction[] {
const index = reactions.findIndex((reaction) => reaction.emoji === emoji);
const current = index === -1 ? null : (reactions[index] ?? null);
const isMine = actorId !== null && actorId === meId;
const alreadyListed =
actorId !== null && current?.user_ids !== undefined && current.user_ids.includes(actorId);
if (added) {
if (current === null) {
const created: MessageReaction = { emoji, count: 1, me: isMine };
if (actorId !== null) {
created.user_ids = [actorId];
}
return [...reactions, created];
}
// Дубликат события (свой же PUT и эхо шлюза): счётчик не растёт.
// Когда `user_ids` сервер не прислал, ориентируемся на локальный флаг `me`.
if (alreadyListed || (isMine && current.me && current.user_ids === undefined)) {
return reactions;
}
const next: MessageReaction = {
...current,
count: current.count + 1,
me: current.me || isMine,
};
if (actorId !== null && current.user_ids !== undefined) {
next.user_ids = [...current.user_ids, actorId];
}
const result = [...reactions];
result[index] = next;
return result;
}
if (current === null) {
return reactions;
}
if (actorId !== null && current.user_ids !== undefined && !alreadyListed) {
return reactions;
}
// Своё эхо на уже снятую реакцию (см. выше про отсутствие `user_ids`).
if (current.user_ids === undefined && isMine && !current.me) {
return reactions;
}
const count = Math.max(0, current.count - 1);
if (count === 0) {
return reactions.filter((reaction) => reaction.emoji !== emoji);
}
const next: MessageReaction = {
...current,
count,
me: isMine ? false : current.me,
};
if (actorId !== null && current.user_ids !== undefined) {
next.user_ids = current.user_ids.filter((id) => id !== actorId);
}
const result = [...reactions];
result[index] = next;
return result;
}
function toAttachment(file: UploadedFile) {
return {
file_id: file.file_id,
filename: file.filename,
...(file.content_type === '' ? {} : { content_type: file.content_type }),
...(file.size_bytes === undefined ? {} : { size_bytes: file.size_bytes }),
};
}
/** Последняя отметка отправки `POST /typing` по комнате (троттлинг 3 с). */
const typingSentAt = new Map<string, number>();
export const useMessagesStore = create<MessagesState>((set, get) => {
const patch = (
channelId: string,
updater: (current: ChannelMessagesState) => Partial<ChannelMessagesState>,
): void => {
const current = get().channels[channelId] ?? emptyChannel();
set({ channels: { ...get().channels, [channelId]: { ...current, ...updater(current) } } });
};
return {
channels: {},
snapshotApplied: false,
ensureChannel: async (channelId) => {
const current = get().channels[channelId] ?? EMPTY_CHANNEL;
if (current.status === 'loading' || current.status === 'ready') {
return;
}
await get().reloadChannel(channelId);
},
reloadChannel: async (channelId) => {
patch(channelId, () => ({ status: 'loading', error: null, loadingMore: false }));
try {
const page = await fetchChannelMessages(channelId, { limit: MESSAGE_PAGE_SIZE });
patch(channelId, (current) => mergeHistory(current, page, 'replace'));
} catch (error) {
patch(channelId, () => ({ status: 'error', error, loadingMore: false }));
}
},
loadMore: async (channelId) => {
const current = get().channels[channelId] ?? EMPTY_CHANNEL;
// Курсор берём из состояния: массив сообщений ограничен 200 записями.
const cursor = current.pagingCursor ?? current.messages[0]?.id;
if (current.loadingMore || !current.hasMore || cursor === undefined) {
return;
}
patch(channelId, () => ({ loadingMore: true, error: null }));
try {
const page = await fetchChannelMessages(channelId, {
before: cursor,
limit: MESSAGE_PAGE_SIZE,
});
patch(channelId, (state) => mergeHistory(state, page, 'prepend'));
} catch (error) {
patch(channelId, () => ({ loadingMore: false, error }));
}
},
loadPins: async (channelId) => {
patch(channelId, () => ({ pinsError: null }));
try {
const pins = await fetchChannelPins(channelId);
patch(channelId, () => ({ pins, pinsLoaded: true }));
} catch (error) {
patch(channelId, () => ({ pinsError: error, pinsLoaded: true }));
}
},
search: async (channelId, query) => {
const trimmed = query.trim();
if (trimmed === '') {
patch(channelId, () => ({
searchQuery: '',
searchStatus: 'idle',
searchResults: [],
searchError: null,
}));
return;
}
patch(channelId, () => ({
searchQuery: trimmed,
searchStatus: 'searching',
searchError: null,
}));
try {
const results = await searchChannelMessages(channelId, trimmed, MESSAGE_SEARCH_LIMIT);
// Пока запрос летел, пользователь мог изменить строку поиска.
if ((get().channels[channelId] ?? EMPTY_CHANNEL).searchQuery !== trimmed) {
return;
}
patch(channelId, () => ({ searchStatus: 'ready', searchResults: results }));
} catch (error) {
if ((get().channels[channelId] ?? EMPTY_CHANNEL).searchQuery !== trimmed) {
return;
}
patch(channelId, () => ({ searchStatus: 'error', searchError: error }));
}
},
clearSearch: (channelId) => {
patch(channelId, () => ({
searchQuery: '',
searchStatus: 'idle',
searchResults: [],
searchError: null,
}));
},
send: async (input) => {
const { channelId } = input;
const content = input.content;
const nonce = input.nonce ?? createNonce();
const files = input.files ?? [];
const author = useSessionStore.getState().user;
const optimistic: PendingMessage = {
id: `pending:${nonce}`,
channel_id: channelId,
content,
...(author === null ? {} : { author_id: author.id }),
...(input.replyToId === undefined ? {} : { reply_to_id: input.replyToId }),
type: 'default',
pinned: false,
attachments: files.map(toAttachment),
mentions: extractMentions(content),
reactions: [],
created_at: new Date().toISOString(),
pending: true,
nonce,
};
patch(channelId, (current) => ({
pending: [...current.pending, optimistic].slice(-MESSAGE_CACHE_LIMIT),
error: null,
}));
try {
const message = await sendChannelMessage(channelId, {
content,
...(input.replyToId === undefined ? {} : { reply_to_id: input.replyToId }),
...(files.length === 0 ? {} : { attachment_ids: files.map((file) => file.file_id) }),
nonce,
});
patch(channelId, (current) => ({
// Событие шлюза могло прийти раньше ответа REST — тогда id уже в ленте.
pending: current.pending.filter((item) => item.nonce !== nonce),
messages: upsertMessage(current.messages, message),
}));
return message;
} catch (error) {
// Текст не теряем: композер вернёт его пользователю сам.
patch(channelId, (current) => ({
pending: current.pending.filter((item) => item.nonce !== nonce),
}));
throw error;
}
},
edit: async (channelId, messageId, content) => {
const before = (get().channels[channelId] ?? EMPTY_CHANNEL).messages.find(
(message) => message.id === messageId,
);
patch(channelId, (current) => ({
messages: current.messages.map((message) =>
message.id === messageId ? { ...message, content } : message,
),
}));
try {
const updated = await editChannelMessage(channelId, messageId, content);
patch(channelId, (current) => ({
messages: upsertMessage(current.messages, updated),
pins: current.pins.map((item) => (item.id === messageId ? updated : item)),
}));
} catch (error) {
if (before !== undefined) {
patch(channelId, (current) => ({
messages: upsertMessage(current.messages, before),
}));
}
throw error;
}
},
remove: async (channelId, messageId) => {
const before = (get().channels[channelId] ?? EMPTY_CHANNEL).messages.find(
(message) => message.id === messageId,
);
patch(channelId, (current) => ({
messages: removeMessage(current.messages, messageId),
pins: current.pins.filter((item) => item.id !== messageId),
}));
try {
await deleteChannelMessage(channelId, messageId);
} catch (error) {
if (before !== undefined) {
patch(channelId, (current) => ({
messages: upsertMessage(current.messages, before),
}));
}
throw error;
}
},
toggleReaction: async (channelId, messageId, emoji) => {
const current = (get().channels[channelId] ?? EMPTY_CHANNEL).messages.find(
(message) => message.id === messageId,
);
if (current === undefined) {
return;
}
const mine = current.reactions.find((reaction) => reaction.emoji === emoji)?.me === true;
const me = useSessionStore.getState().user?.id ?? null;
patch(channelId, (state) => ({
messages: state.messages.map((message) =>
message.id === messageId
? {
...message,
reactions: applyReactionLocally(message.reactions, emoji, me, me, !mine),
}
: message,
),
}));
try {
if (mine) {
await removeMessageReaction(channelId, messageId, emoji);
} else {
await addMessageReaction(channelId, messageId, emoji);
}
} catch (error) {
patch(channelId, (state) => ({
messages: state.messages.map((message) =>
message.id === messageId ? { ...message, reactions: current.reactions } : message,
),
}));
throw error;
}
},
setPinned: async (channelId, messageId, pinned) => {
const current = (get().channels[channelId] ?? EMPTY_CHANNEL).messages.find(
(message) => message.id === messageId,
);
const previousPins = (get().channels[channelId] ?? EMPTY_CHANNEL).pins;
patch(channelId, (state) => ({
messages: state.messages.map((message) =>
message.id === messageId ? { ...message, pinned } : message,
),
pins:
current === undefined
? state.pins
: pinned
? upsertMessage(state.pins, { ...current, pinned: true })
: state.pins.filter((item) => item.id !== messageId),
}));
try {
if (pinned) {
await pinChannelMessage(channelId, messageId);
} else {
await unpinChannelMessage(channelId, messageId);
}
} catch (error) {
patch(channelId, (state) => ({
messages: state.messages.map((message) =>
message.id === messageId && current !== undefined
? { ...message, pinned: current.pinned }
: message,
),
pins: previousPins,
}));
throw error;
}
},
notifyTyping: (channelId) => {
const now = Date.now();
const last = typingSentAt.get(channelId) ?? 0;
if (now - last < TYPING_THROTTLE_MS) {
return;
}
typingSentAt.set(channelId, now);
// Ошибку «печатает…» показывать не нужно: это необязательный сигнал.
void sendTypingIndicator(channelId).catch(() => undefined);
},
acknowledge: async (channelId, lastMessageId) => {
patch(channelId, () => ({
...(lastMessageId === undefined ? {} : { lastReadMessageId: lastMessageId }),
mentionCount: 0,
}));
try {
await acknowledgeChannel(channelId, lastMessageId);
} catch {
// Отметка прочтения не критична: следующая попытка придёт с новым сообщением.
}
},
applyMessageCreate: (message) => {
if (message.channel_id === '') {
return;
}
const me = useSessionStore.getState().user?.id ?? null;
patch(message.channel_id, (current) => {
// Своё сообщение могло прийти событием раньше ответа REST: снимаем
// соответствующее оптимистичное, чтобы лента не показала дубль.
const echo =
me !== null && message.author_id === me
? current.pending.find(
(item) =>
item.content === message.content &&
item.attachments.length === message.attachments.length,
)
: undefined;
return {
messages: upsertMessage(current.messages, message),
pending:
echo === undefined
? current.pending
: current.pending.filter((item) => item.nonce !== echo.nonce),
};
});
},
applyMessageUpdate: (message) => {
patch(message.channel_id, (current) => {
// Payload события собран для автора правки, поэтому чужой флаг `me`
// не должен затирать локальный: иначе «моя реакция» переедет на другое
// устройство вместе с событием.
const previous = current.messages.find((item) => item.id === message.id);
const merged =
previous === undefined || previous.reactions.length === 0
? message
: {
...message,
reactions: message.reactions.map((reaction) => {
const local = previous.reactions.find((item) => item.emoji === reaction.emoji);
return local === undefined ? reaction : { ...reaction, me: local.me };
}),
};
return {
messages: upsertMessage(current.messages, merged),
pins: current.pins.map((item) => (item.id === message.id ? merged : item)),
};
});
},
applyMessageDelete: (channelId, messageId) => {
patch(channelId, (current) => ({
messages: removeMessage(current.messages, messageId),
pins: current.pins.filter((item) => item.id !== messageId),
pending: current.pending.filter((item) => item.id !== messageId),
}));
},
applyReaction: (channelId, messageId, emoji, userId, added) => {
const me = useSessionStore.getState().user?.id ?? null;
patch(channelId, (current) => ({
messages: current.messages.map((message) =>
message.id === messageId
? {
...message,
reactions: applyReactionLocally(message.reactions, emoji, userId, me, added),
}
: message,
),
}));
},
applyPinsUpdate: (channelId, messageId, pinned) => {
patch(channelId, (current) => {
const target = current.messages.find((message) => message.id === messageId);
return {
messages: current.messages.map((message) =>
message.id === messageId ? { ...message, pinned } : message,
),
pins: pinned
? target === undefined
? // Сообщение вне загруженного окна: добавить его в список нечем,
// но и удалять уже закреплённое нельзя.
current.pins
: upsertMessage(current.pins, { ...target, pinned: true })
: current.pins.filter((item) => item.id !== messageId),
};
});
},
applyTypingStart: (channelId, userId) => {
const me = useSessionStore.getState().user?.id ?? null;
if (userId === me) {
return;
}
const now = Date.now();
patch(channelId, (current) => ({
typing: [
...current.typing.filter((entry) => entry.user_id !== userId),
{
user_id: userId,
at: now,
},
],
}));
},
applyReadState: (event) => {
patch(event.channel_id, () => ({
...(event.last_message_id === undefined
? {}
: { lastReadMessageId: event.last_message_id }),
readStateKnown: true,
mentionCount: event.mention_count,
}));
},
dropChannel: (channelId) => {
const channels = { ...get().channels };
delete channels[channelId];
set({ channels });
},
markSnapshotApplied: () => {
set({ snapshotApplied: true });
},
pruneTyping: (now = Date.now()) => {
const channels = get().channels;
let changed = false;
const next: Record<string, ChannelMessagesState> = {};
for (const [channelId, channel] of Object.entries(channels)) {
if (channel.typing.length === 0) {
next[channelId] = channel;
continue;
}
const fresh = channel.typing.filter((entry) => now - entry.at < TYPING_TTL_MS);
if (fresh.length === channel.typing.length) {
next[channelId] = channel;
continue;
}
changed = true;
next[channelId] = { ...channel, typing: fresh };
}
if (changed) {
set({ channels: next });
}
},
reset: () => {
typingSentAt.clear();
set({ channels: {}, snapshotApplied: false });
},
};
});
/** Сколько сообщений комнаты ещё не прочитано (по отметке read state). */
export function unreadCount(channel: ChannelMessagesState): number {
const marker = channel.lastReadMessageId;
if (marker === null) {
return 0;
}
const index = channel.messages.findIndex((message) => message.id === marker);
if (index >= 0) {
return channel.messages.length - index - 1;
}
// Отметка не попала в загруженную историю — считаем по порядку снежинок.
const firstUnread = channel.messages.findIndex((message) => compareIds(message.id, marker) > 0);
return firstUnread < 0 ? 0 : channel.messages.length - firstUnread;
}
/** Селектор записи комнаты (стабильная заглушка для незагруженных). */
export function selectChannelState(channelId: string) {
return (state: MessagesState): ChannelMessagesState => channelState(state, channelId);
}
File diff suppressed because it is too large Load Diff
+210
View File
@@ -0,0 +1,210 @@
import { describe, expect, it } from 'vitest';
import type { Message } from '@/api/messages';
import type { GuildMember, Role } from '@/api/types';
import {
GROUP_WINDOW_MS,
buildChatRows,
canGroup,
compareMessageIds,
copyText,
dayKey,
dayLabel,
escapeSelector,
formatMessageTime,
memberDisplayName,
roleColorForMember,
} from '@/lib/chat';
const NOW = new Date(2026, 8, 19, 15, 0, 0);
function at(day: number, hour: number, minute: number): string {
return new Date(2026, 8, day, hour, minute, 0).toISOString();
}
function message(overrides: Partial<Message> & { id: string }): Message {
return {
channel_id: 'c-1',
content: 'текст',
type: 'default',
pinned: false,
attachments: [],
mentions: [],
reactions: [],
created_at: at(19, 12, 0),
...overrides,
};
}
describe('группировка сообщений', () => {
it('объединяет сообщения одного автора в пределах 5 минут', () => {
const first = message({ id: 'm-1', author_id: 'user-1', created_at: at(19, 12, 0) });
const second = message({ id: 'm-2', author_id: 'user-1', created_at: at(19, 12, 4) });
expect(canGroup(first, second)).toBe(true);
expect(
canGroup(first, message({ id: 'm-3', author_id: 'user-1', created_at: at(19, 12, 6) })),
).toBe(false);
});
it('разрывает группу при смене автора, ответе и системном сообщении', () => {
const first = message({ id: 'm-1', author_id: 'user-1', created_at: at(19, 12, 0) });
expect(canGroup(first, message({ id: 'm-2', author_id: 'user-2' }))).toBe(false);
expect(canGroup(first, message({ id: 'm-2', author_id: 'user-1', reply_to_id: 'm-9' }))).toBe(
false,
);
expect(canGroup(first, message({ id: 'm-2', author_id: 'user-1', type: 'system' }))).toBe(
false,
);
});
it('строит строки ленты с разделителями дат', () => {
const rows = buildChatRows(
[
message({ id: 'm-1', author_id: 'user-1', created_at: at(18, 22, 0) }),
message({ id: 'm-2', author_id: 'user-1', created_at: at(19, 10, 0) }),
message({ id: 'm-3', author_id: 'user-1', created_at: at(19, 10, 2) }),
],
{ unreadAnchorId: null },
);
expect(rows.map((row) => row.kind)).toEqual(['date', 'group', 'date', 'group']);
const second = rows[3];
expect(second?.kind === 'group' ? second.group.messages.map((item) => item.id) : []).toEqual([
'm-2',
'm-3',
]);
});
it('ставит разделитель «новые сообщения» перед первым непрочитанным', () => {
const rows = buildChatRows(
[
message({ id: 'm-1', author_id: 'user-1' }),
message({ id: 'm-2', author_id: 'user-1' }),
message({ id: 'm-3', author_id: 'user-2' }),
],
{ unreadAnchorId: 'm-1' },
);
// Разделитель разрывает группу: m-2 попадает в новую группу после линии.
expect(rows.map((row) => row.kind)).toEqual(['date', 'group', 'unread', 'group', 'group']);
const unread = rows[3];
expect(unread?.kind === 'group' ? unread.group.messages[0]?.id : null).toBe('m-2');
});
it('считает «0» отметкой «комната не читалась»', () => {
const rows = buildChatRows([message({ id: 'm-1', author_id: 'user-1' })], {
unreadAnchorId: '0',
});
expect(rows.map((row) => row.kind)).toEqual(['date', 'unread', 'group']);
});
it('не ставит разделитель, если отметка прочтения неизвестна', () => {
const rows = buildChatRows([message({ id: 'm-1', author_id: 'user-1' })], {
unreadAnchorId: 'm-404',
});
expect(rows.map((row) => row.kind)).toEqual(['date', 'group']);
});
it('ставит разделитель перед окном, если отметка прочтения старше загруженного', () => {
const rows = buildChatRows(
[message({ id: '200', author_id: 'user-1' }), message({ id: '201', author_id: 'user-1' })],
{ unreadAnchorId: '100' },
);
expect(rows.map((row) => row.kind)).toEqual(['date', 'unread', 'group']);
});
it('сравнивает идентификаторы как снежинки', () => {
expect(compareMessageIds('9', '10')).toBe(-1);
expect(compareMessageIds('10', '10')).toBe(0);
expect(compareMessageIds('pending:x', '10')).toBe(1);
});
});
describe('даты сообщений', () => {
it('различает сегодня, вчера и дату', () => {
expect(dayLabel(at(19, 9, 0), NOW)).toBe('today');
expect(dayLabel(at(18, 23, 0), NOW)).toBe('yesterday');
expect(dayLabel(at(1, 12, 0), NOW)).toBe('date');
});
it('даёт ключ локального дня', () => {
expect(dayKey(at(19, 12, 0))).toBe('2026-09-19');
});
it('форматирует время сообщения по локали', () => {
expect(formatMessageTime(at(19, 9, 5), 'ru')).toMatch(/\d{1,2}:\d{2}/u);
expect(formatMessageTime('не дата', 'ru')).toBe('');
});
it('хранит окно группировки в 5 минут', () => {
expect(GROUP_WINDOW_MS).toBe(300_000);
});
});
describe('авторы сообщений', () => {
const roles: Role[] = [
{
id: 'r-low',
name: 'Низкая',
color: 0x00ff00,
position: 1,
permissions: '0',
is_default: true,
hoist: false,
mentionable: false,
},
{
id: 'r-high',
name: 'Высокая',
color: 0xff0000,
position: 9,
permissions: '0',
is_default: false,
hoist: true,
mentionable: true,
},
];
const member: GuildMember = {
user_id: 'user-1',
username: 'alice',
display_name: 'Alice',
nickname: 'Аля',
status: 'online',
is_instance_admin: false,
joined_at: '2026-09-01T10:00:00Z',
role_ids: ['r-low', 'r-high'],
};
it('берёт цвет самой высокой роли', () => {
expect(roleColorForMember(member, roles)).toBe('#ff0000');
});
it('игнорирует роли без цвета', () => {
const colorless: Role[] = [roles[0] as Role, { ...(roles[1] as Role), color: 0 }];
expect(roleColorForMember(member, colorless)).toBe('#00ff00');
});
it('предпочитает ник участника', () => {
expect(memberDisplayName(member)).toBe('Аля');
expect(memberDisplayName({ ...member, nickname: '' })).toBe('Alice');
expect(memberDisplayName(undefined)).toBeNull();
});
});
describe('утилиты чата', () => {
it('экранирует селекторы и не падает без CSS.escape', () => {
const original = globalThis.CSS;
// @ts-expect-error — проверяем запасную ветку без CSS.escape
delete globalThis.CSS;
try {
expect(escapeSelector('pending:abc')).toBe('pending\\:abc');
} finally {
globalThis.CSS = original;
}
});
it('копирование без clipboard не падает', async () => {
await expect(copyText('текст')).resolves.toBe(false);
});
});
+169 -6
View File
@@ -3,9 +3,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { vi } from 'vitest';
import { GatewayOp } from '@/api/gateway';
import type { Message } from '@/api/messages';
import { routes } from '@/router';
import { setQueryClient } from '@/lib/queryClient';
import { useGatewayStore } from '@/stores/gateway';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
import type { User } from '@/api/types';
@@ -110,13 +113,10 @@ export function findRequest(
);
}
/** Рендер приложения на заданном маршруте с изолированными сторами. */
export function renderApp(initialPath = '/') {
const queryClient = createTestQueryClient();
// Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша),
// поэтому подменяем его на тестовый.
setQueryClient(queryClient);
/** Сброс всех сторов к исходному состоянию (изоляция между тестами). */
export function resetStores(): void {
useSessionStore.getState().reset();
useMessagesStore.getState().reset();
useGatewayStore.setState({
status: 'idle',
heartbeatIntervalMs: null,
@@ -124,6 +124,15 @@ export function renderApp(initialPath = '/') {
attempts: 0,
invalidated: false,
});
}
/** Рендер приложения на заданном маршруте с изолированными сторами. */
export function renderApp(initialPath = '/') {
const queryClient = createTestQueryClient();
// Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша),
// поэтому подменяем его на тестовый.
setQueryClient(queryClient);
resetStores();
const router = createMemoryRouter(routes, { initialEntries: [initialPath] });
const view = render(
@@ -225,3 +234,157 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
heartbeat_interval_ms: 45_000,
};
}
/* ------------------------------------------------------------------ */
/* Шлюз и сообщения Фазы 2 */
/* ------------------------------------------------------------------ */
/** Подставной WebSocket: рукопожатие и события под контролем теста. */
class FakeGatewaySocket {
static instances: FakeGatewaySocket[] = [];
readyState = 0;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
/** Кадры, отправленные клиентом (IDENTIFY, HEARTBEAT, RESUME). */
readonly sent: { op: number; d: unknown }[] = [];
constructor(readonly url: string) {
FakeGatewaySocket.instances.push(this);
}
send(raw: string): void {
this.sent.push(JSON.parse(raw) as { op: number; d: unknown });
}
close(): void {
this.readyState = 3;
}
open(): void {
this.readyState = 1;
this.onopen?.(new Event('open'));
}
emit(packet: unknown): void {
this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent);
}
dispatch(type: string, payload: unknown, seq = 1): void {
this.emit({ op: GatewayOp.DISPATCH, t: type, s: seq, d: payload });
}
}
export interface GatewayHarness {
/** Открывает сокет, отвечает HELLO и присылает READY со снапшотом. */
greet: () => Promise<void>;
/** Событие DISPATCH от сервера. */
dispatch: (type: string, payload: unknown) => void;
/** Кадры, отправленные клиентом. */
sent: () => { op: number; d: unknown }[];
}
/**
* Подменяет WebSocket подставным и отдаёт управление рукопожатием.
* Снапшот READY уходит после `greet()`: до этого момента шлюз «подключается».
*/
export function installGatewaySocket(snapshot: unknown): GatewayHarness {
FakeGatewaySocket.instances = [];
vi.stubGlobal('WebSocket', FakeGatewaySocket);
const current = (): FakeGatewaySocket => {
const socket = FakeGatewaySocket.instances[0];
if (socket === undefined) {
throw new Error('шлюз не подключился');
}
return socket;
};
return {
async greet() {
await waitFor(() => {
expect(FakeGatewaySocket.instances.length).toBeGreaterThan(0);
});
const socket = current();
socket.open();
socket.emit({
op: GatewayOp.HELLO,
d: { heartbeat_interval_ms: 45_000, session_id: 'session-1' },
});
socket.dispatch('READY', snapshot);
},
dispatch(type, payload) {
current().dispatch(type, payload);
},
sent() {
return current().sent;
},
};
}
/** Отложенный ответ: тест сам решает, когда сервер «ответит». */
export interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
export function deferred<T>(): Deferred<T> {
let settle!: (value: T) => void;
const promise = new Promise<T>((resolve) => {
settle = resolve;
});
return { promise, resolve: (value) => settle(value) };
}
/** Сообщение для моков и тестов стора. */
export function makeMessage(
overrides: Partial<Message> & { id: string; channel_id: string },
): Message {
return {
content: 'привет',
type: 'default',
pinned: false,
attachments: [],
mentions: [],
reactions: [],
created_at: '2026-09-19T10:00:00Z',
...overrides,
};
}
/** Мок `GET /channels/{id}/messages` и других ручек комнаты. */
export function messagesRoutes(
channelId: string,
messages: Message[],
extra: FetchRoute[] = [],
): FetchRoute[] {
// Дополнительные маршруты идут первыми: сортировка в installFetch устойчива,
// поэтому при одинаковом шаблоне выигрывает маршрут теста.
return [
...extra,
{
match: `/api/v1/channels/${channelId}/messages/search`,
response: () => json({ messages: [] }),
},
{
match: `/api/v1/channels/${channelId}/pins`,
response: () => json({ messages: messages.filter((message) => message.pinned) }),
},
{
match: `/api/v1/channels/${channelId}/messages`,
response: () => json({ messages: [...messages].reverse() }),
},
{
match: `/api/v1/channels/${channelId}/ack`,
method: 'POST',
response: () => json({ ok: true }),
},
{
match: `/api/v1/channels/${channelId}/typing`,
method: 'POST',
response: () => json({ ok: true }),
},
];
}
+684
View File
@@ -0,0 +1,684 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
deferred,
installFetch,
json,
makeMessage,
recordedRequests,
resetStores,
type FetchRoute,
} from './helpers';
import type { Message } from '@/api/messages';
import { retryAfterMs } from '@/api/messages';
import { channelState, unreadCount, useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
const CHANNEL = 'c-1';
/** Текущий пользователь: от него зависит автор оптимистичного сообщения. */
function signIn(id = 'user-1'): void {
useSessionStore.getState().setUser({
id,
username: 'alice',
display_name: 'Alice',
is_instance_admin: false,
badges: [],
});
}
function state(channelId = CHANNEL) {
return channelState(useMessagesStore.getState(), channelId);
}
/** Последовательные сообщения с возрастающим временем. */
function thread(
count: number,
overrides: (index: number) => Partial<Message> = () => ({}),
): Message[] {
return Array.from({ length: count }, (_, index) =>
makeMessage({
id: `m-${String(index + 1)}`,
channel_id: CHANNEL,
content: `сообщение ${String(index + 1)}`,
author_id: 'user-1',
created_at: new Date(Date.UTC(2026, 8, 19, 10, index)).toISOString(),
...overrides(index),
}),
);
}
function historyRoute(messages: Message[]): FetchRoute {
return {
match: `/api/v1/channels/${CHANNEL}/messages`,
response: () => json({ messages: [...messages].reverse() }),
};
}
beforeEach(() => {
resetStores();
signIn();
});
describe('стор сообщений: загрузка истории', () => {
it('загружает последнюю страницу и хранит сообщения по возрастанию времени', async () => {
const messages = thread(3);
installFetch([historyRoute(messages)]);
await useMessagesStore.getState().ensureChannel(CHANNEL);
const current = state();
expect(current.status).toBe('ready');
expect(current.messages.map((message) => message.id)).toEqual(['m-1', 'm-2', 'm-3']);
expect(current.hasMore).toBe(false);
});
it('подгружает историю вверх по курсору before и останавливается на короткой странице', async () => {
const messages = thread(50);
const older = Array.from({ length: 4 }, (_, index) =>
makeMessage({
id: `old-${String(index + 1)}`,
channel_id: CHANNEL,
content: `старое ${String(index + 1)}`,
created_at: new Date(Date.UTC(2026, 8, 18, 10, index)).toISOString(),
}),
);
const fetchMock = installFetch([
{
match: `/api/v1/channels/${CHANNEL}/messages?limit=50&before=m-1`,
response: () => json({ messages: [...older].reverse() }),
},
historyRoute(messages),
]);
await useMessagesStore.getState().ensureChannel(CHANNEL);
expect(state().hasMore).toBe(true);
await useMessagesStore.getState().loadMore(CHANNEL);
const current = state();
expect(current.messages).toHaveLength(54);
expect(current.messages[0]?.id).toBe('old-1');
expect(current.hasMore).toBe(false);
const requests = recordedRequests(fetchMock);
expect(requests.at(-1)?.url).toContain('before=m-1');
});
it('продолжает подгрузку истории, когда кэш упёрся в лимит', async () => {
/** Страница по 50 сообщений: id убывают вглубь истории. */
const cursorPath = (cursor: string): string =>
`/api/v1/channels/${CHANNEL}/messages?limit=50&before=${cursor}`;
const page = (from: number, count = 50): Message[] =>
Array.from({ length: count }, (_, index) =>
makeMessage({
id: String(from + index),
channel_id: CHANNEL,
content: `сообщение ${String(from + index)}`,
created_at: new Date(Date.UTC(2026, 8, 19, 10, 0, from + index)).toISOString(),
}),
);
const fetchMock = installFetch([
{ match: cursorPath('451'), response: () => json({ messages: [...page(401)].reverse() }) },
{ match: cursorPath('401'), response: () => json({ messages: [...page(351)].reverse() }) },
{ match: cursorPath('351'), response: () => json({ messages: [...page(301)].reverse() }) },
{ match: cursorPath('301'), response: () => json({ messages: [...page(251)].reverse() }) },
{ match: cursorPath('251'), response: () => json({ messages: [...page(249, 2)].reverse() }) },
{
match: `/api/v1/channels/${CHANNEL}/messages`,
response: () => json({ messages: [...page(451)].reverse() }),
},
]);
await useMessagesStore.getState().ensureChannel(CHANNEL);
expect(state().messages).toHaveLength(50);
// Четыре страницы упираются в лимит кэша, но курсор продолжает двигаться.
for (let index = 0; index < 4; index += 1) {
await useMessagesStore.getState().loadMore(CHANNEL);
}
expect(state().messages).toHaveLength(200);
expect(state().pagingCursor).toBe('251');
expect(state().messages[0]?.id).toBe('251');
expect(state().hasMore).toBe(true);
// Короткая страница закрывает историю: дальше запросов нет.
await useMessagesStore.getState().loadMore(CHANNEL);
expect(state().hasMore).toBe(false);
const before = recordedRequests(fetchMock).filter((request) => request.url.includes('before='));
expect(before.map((request) => request.url.split('before=')[1])).toEqual([
'451',
'401',
'351',
'301',
'251',
]);
const calls = recordedRequests(fetchMock).length;
await useMessagesStore.getState().loadMore(CHANNEL);
expect(recordedRequests(fetchMock)).toHaveLength(calls);
});
it('не теряет пин, если сообщения нет в загруженной ленте', async () => {
const pinned = makeMessage({ id: 'm-9', channel_id: CHANNEL, content: 'старое', pinned: true });
installFetch([
{ match: `/api/v1/channels/${CHANNEL}/pins`, response: () => json({ messages: [pinned] }) },
]);
await useMessagesStore.getState().loadPins(CHANNEL);
expect(state().pins).toHaveLength(1);
// Событие о повторном закреплении сообщения вне окна не должно убирать пин.
useMessagesStore.getState().applyPinsUpdate(CHANNEL, 'm-9', true);
expect(state().pins.map((item) => item.id)).toEqual(['m-9']);
useMessagesStore.getState().applyPinsUpdate(CHANNEL, 'm-9', false);
expect(state().pins).toHaveLength(0);
});
it('сохраняет мой флаг реакции при MESSAGE_UPDATE от другого пользователя', () => {
useMessagesStore.getState().applyMessageCreate(
makeMessage({
id: 'm-1',
channel_id: CHANNEL,
content: 'текст',
reactions: [{ emoji: '👍', count: 2, me: true, user_ids: ['user-1', 'user-2'] }],
}),
);
// Payload события собран для автора правки: у него `me` относится к нему.
useMessagesStore.getState().applyMessageUpdate(
makeMessage({
id: 'm-1',
channel_id: CHANNEL,
content: 'текст (правка)',
edited_at: '2026-09-19T11:00:00Z',
reactions: [
{ emoji: '👍', count: 2, me: false, user_ids: ['user-1', 'user-2'] },
{ emoji: '🔥', count: 1, me: true, user_ids: ['user-3'] },
],
}),
);
const reactions = state().messages[0]?.reactions ?? [];
expect(reactions.find((item) => item.emoji === '👍')?.me).toBe(true);
// Новую реакцию берём из payload: локального мнения о ней нет.
expect(reactions.find((item) => item.emoji === '🔥')?.me).toBe(true);
});
it('не считает своё эхо реакции дважды, когда сервер не прислал user_ids', () => {
useMessagesStore.getState().applyMessageCreate(
makeMessage({
id: 'm-1',
channel_id: CHANNEL,
reactions: [{ emoji: '👍', count: 1, me: false }],
}),
);
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', true);
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', true);
expect(state().messages[0]?.reactions[0]?.count).toBe(2);
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', false);
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', false);
expect(state().messages[0]?.reactions[0]?.count).toBe(1);
});
it('ограничивает кэш комнаты 200 сообщениями', () => {
const store = useMessagesStore.getState();
for (const message of thread(210)) {
store.applyMessageCreate(message);
}
const current = state();
expect(current.messages).toHaveLength(200);
// Остаются самые новые.
expect(current.messages.at(-1)?.id).toBe('m-210');
expect(current.messages[0]?.id).toBe('m-11');
});
it('сохраняет ошибку загрузки и повторяет запрос по требованию', async () => {
let fail = true;
installFetch([
{
match: `/api/v1/channels/${CHANNEL}/messages`,
response: () =>
fail
? json({ error: { code: 'internal.error', message: 'boom' } }, 500)
: json({ messages: [] }),
},
]);
await useMessagesStore.getState().ensureChannel(CHANNEL);
expect(state().status).toBe('error');
fail = false;
await useMessagesStore.getState().reloadChannel(CHANNEL);
expect(state().status).toBe('ready');
});
});
describe('стор сообщений: отправка', () => {
it('добавляет оптимистичное сообщение и заменяет его ответом сервера', async () => {
const created = makeMessage({
id: 'm-9',
channel_id: CHANNEL,
content: 'привет',
author_id: 'user-1',
});
const send = deferred<Response>();
const fetchMock = installFetch([
{
match: `/api/v1/channels/${CHANNEL}/messages`,
method: 'POST',
response: () => send.promise,
},
]);
const sending = useMessagesStore.getState().send({ channelId: CHANNEL, content: 'привет' });
const optimistic = state().pending;
expect(optimistic).toHaveLength(1);
expect(optimistic[0]?.pending).toBe(true);
expect(optimistic[0]?.author_id).toBe('user-1');
expect(optimistic[0]?.content).toBe('привет');
send.resolve(json({ message: created }));
await sending;
const current = state();
expect(current.pending).toHaveLength(0);
expect(current.messages.map((message) => message.id)).toEqual(['m-9']);
const body = recordedRequests(fetchMock)[0]?.body as Record<string, unknown>;
expect(body['content']).toBe('привет');
expect(typeof body['nonce']).toBe('string');
});
it('убирает оптимистичное сообщение, если сервер отказал', async () => {
installFetch([
{
match: `/api/v1/channels/${CHANNEL}/messages`,
method: 'POST',
response: () => json({ error: { code: 'perm.denied', message: 'nope' } }, 403),
},
]);
await expect(
useMessagesStore.getState().send({ channelId: CHANNEL, content: 'привет' }),
).rejects.toThrow();
expect(state().pending).toHaveLength(0);
expect(state().messages).toHaveLength(0);
});
it('не дублирует сообщение, если событие шлюза пришло раньше ответа REST', async () => {
const created = makeMessage({
id: 'm-9',
channel_id: CHANNEL,
content: 'привет',
author_id: 'user-1',
});
const send = deferred<Response>();
installFetch([
{
match: `/api/v1/channels/${CHANNEL}/messages`,
method: 'POST',
response: () => send.promise,
},
]);
const sending = useMessagesStore.getState().send({ channelId: CHANNEL, content: 'привет' });
useMessagesStore.getState().applyMessageCreate(created);
// Эхо шлюза сняло оптимистичное сообщение до ответа REST.
expect(state().pending).toHaveLength(0);
expect(state().messages.map((message) => message.id)).toEqual(['m-9']);
send.resolve(json({ message: created }));
await sending;
expect(state().messages.map((message) => message.id)).toEqual(['m-9']);
expect(state().pending).toHaveLength(0);
});
it('редактирует и удаляет сообщение с откатом при ошибке', async () => {
useMessagesStore
.getState()
.applyMessageCreate(
makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'старое', author_id: 'user-1' }),
);
installFetch([
{
match: `/api/v1/channels/${CHANNEL}/messages/m-1`,
method: 'PATCH',
response: () =>
json({
message: makeMessage({
id: 'm-1',
channel_id: CHANNEL,
content: 'новое',
author_id: 'user-1',
edited_at: '2026-09-19T11:00:00Z',
}),
}),
},
]);
await useMessagesStore.getState().edit(CHANNEL, 'm-1', 'новое');
expect(state().messages[0]?.content).toBe('новое');
expect(state().messages[0]?.edited_at).toBe('2026-09-19T11:00:00Z');
});
it('возвращает прежний текст, если правка не удалась', async () => {
useMessagesStore
.getState()
.applyMessageCreate(
makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'старое', author_id: 'user-1' }),
);
installFetch([
{
match: `/api/v1/channels/${CHANNEL}/messages/m-1`,
method: 'PATCH',
response: () =>
json({ error: { code: 'message.edit_window_expired', message: 'late' } }, 403),
},
]);
await expect(useMessagesStore.getState().edit(CHANNEL, 'm-1', 'новое')).rejects.toThrow();
expect(state().messages[0]?.content).toBe('старое');
});
it('удаляет сообщение и возвращает его при ошибке', async () => {
useMessagesStore
.getState()
.applyMessageCreate(
makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'текст', author_id: 'user-1' }),
);
let fail = true;
installFetch([
{
match: `/api/v1/channels/${CHANNEL}/messages/m-1`,
method: 'DELETE',
response: () =>
fail
? json({ error: { code: 'internal.error', message: 'x' } }, 500)
: json({ ok: true }),
},
]);
await expect(useMessagesStore.getState().remove(CHANNEL, 'm-1')).rejects.toThrow();
expect(state().messages).toHaveLength(1);
fail = false;
await useMessagesStore.getState().remove(CHANNEL, 'm-1');
expect(state().messages).toHaveLength(0);
});
});
describe('стор сообщений: реакции', () => {
it('ставит реакцию через PUT и снимает через DELETE', async () => {
useMessagesStore
.getState()
.applyMessageCreate(makeMessage({ id: 'm-1', channel_id: CHANNEL, author_id: 'user-2' }));
const fetchMock = installFetch([
{
match: `/api/v1/channels/${CHANNEL}/messages/m-1/reactions/`,
method: 'PUT',
response: () => json({ ok: true }),
},
{
match: `/api/v1/channels/${CHANNEL}/messages/m-1/reactions/`,
method: 'DELETE',
response: () => json({ ok: true }),
},
]);
await useMessagesStore.getState().toggleReaction(CHANNEL, 'm-1', '👍');
// `user_ids` клиент ведёт сам: по нему отсекаются повторные события.
expect(state().messages[0]?.reactions).toEqual([
{ emoji: '👍', count: 1, me: true, user_ids: ['user-1'] },
]);
expect(recordedRequests(fetchMock)[0]?.url).toContain(`/reactions/${encodeURIComponent('👍')}`);
await useMessagesStore.getState().toggleReaction(CHANNEL, 'm-1', '👍');
expect(state().messages[0]?.reactions).toEqual([]);
});
it('откатывает реакцию, если запрос не удался', async () => {
useMessagesStore
.getState()
.applyMessageCreate(makeMessage({ id: 'm-1', channel_id: CHANNEL, author_id: 'user-2' }));
installFetch([
{
match: `/api/v1/channels/${CHANNEL}/messages/m-1/reactions/`,
method: 'PUT',
response: () => json({ error: { code: 'perm.denied', message: 'nope' } }, 403),
},
]);
await expect(
useMessagesStore.getState().toggleReaction(CHANNEL, 'm-1', '🔥'),
).rejects.toThrow();
expect(state().messages[0]?.reactions).toEqual([]);
});
});
describe('стор сообщений: события шлюза', () => {
it('MESSAGE_CREATE добавляет сообщение и не дублирует известное', () => {
const message = makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'раз' });
useMessagesStore.getState().applyMessageCreate(message);
useMessagesStore.getState().applyMessageCreate(message);
expect(state().messages).toHaveLength(1);
useMessagesStore
.getState()
.applyMessageCreate(makeMessage({ id: 'm-2', channel_id: CHANNEL, content: 'два' }));
expect(state().messages.map((item) => item.id)).toEqual(['m-1', 'm-2']);
});
it('MESSAGE_UPDATE заменяет содержимое, не меняя место в ленте', () => {
useMessagesStore
.getState()
.applyMessageCreate(makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'старое' }));
useMessagesStore.getState().applyMessageUpdate(
makeMessage({
id: 'm-1',
channel_id: CHANNEL,
content: 'новое',
edited_at: '2026-09-19T12:00:00Z',
}),
);
expect(state().messages).toHaveLength(1);
expect(state().messages[0]?.content).toBe('новое');
});
it('MESSAGE_DELETE убирает сообщение и его пин', () => {
useMessagesStore
.getState()
.applyMessageCreate(makeMessage({ id: 'm-1', channel_id: CHANNEL, pinned: true }));
useMessagesStore.getState().applyPinsUpdate(CHANNEL, 'm-1', true);
expect(state().pins).toHaveLength(1);
useMessagesStore.getState().applyMessageDelete(CHANNEL, 'm-1');
expect(state().messages).toHaveLength(0);
expect(state().pins).toHaveLength(0);
});
it('MESSAGE_REACTION_ADD/REMOVE меняет счётчик и подсветку своей реакции', () => {
useMessagesStore.getState().applyMessageCreate(
makeMessage({
id: 'm-1',
channel_id: CHANNEL,
reactions: [{ emoji: '👍', count: 1, me: false, user_ids: ['user-2'] }],
}),
);
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', true);
expect(state().messages[0]?.reactions[0]).toEqual({
emoji: '👍',
count: 2,
me: true,
user_ids: ['user-2', 'user-1'],
});
// Повторное событие того же пользователя счётчик не двигает.
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', true);
expect(state().messages[0]?.reactions[0]?.count).toBe(2);
useMessagesStore.getState().applyReaction(CHANNEL, 'm-1', '👍', 'user-1', false);
expect(state().messages[0]?.reactions[0]).toEqual({
emoji: '👍',
count: 1,
me: false,
user_ids: ['user-2'],
});
});
it('CHANNEL_PINS_UPDATE обновляет флаг и список пинов', () => {
useMessagesStore
.getState()
.applyMessageCreate(makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'важное' }));
useMessagesStore.getState().applyPinsUpdate(CHANNEL, 'm-1', true);
expect(state().messages[0]?.pinned).toBe(true);
expect(state().pins.map((message) => message.id)).toEqual(['m-1']);
useMessagesStore.getState().applyPinsUpdate(CHANNEL, 'm-1', false);
expect(state().messages[0]?.pinned).toBe(false);
expect(state().pins).toHaveLength(0);
});
it('TYPING_START показывает участника и гаснет по таймауту', () => {
useMessagesStore.getState().applyTypingStart(CHANNEL, 'user-2');
expect(state().typing.map((entry) => entry.user_id)).toEqual(['user-2']);
// Своё «печатает…» не показываем.
useMessagesStore.getState().applyTypingStart(CHANNEL, 'user-1');
expect(state().typing).toHaveLength(1);
useMessagesStore.getState().pruneTyping(Date.now() + 7_000);
expect(state().typing).toHaveLength(0);
});
it('READ_STATE_UPDATE задаёт отметку прочтения и счётчик упоминаний', () => {
for (const message of thread(3)) {
useMessagesStore.getState().applyMessageCreate(message);
}
useMessagesStore.getState().applyReadState({
channel_id: CHANNEL,
last_message_id: 'm-1',
mention_count: 2,
});
expect(state().lastReadMessageId).toBe('m-1');
expect(state().mentionCount).toBe(2);
expect(unreadCount(state())).toBe(2);
});
it('READ_STATE_UPDATE помечает отметку прочтения известной', () => {
expect(state().readStateKnown).toBe(false);
useMessagesStore
.getState()
.applyReadState({ channel_id: CHANNEL, last_message_id: 'm-1', mention_count: 0 });
expect(state().readStateKnown).toBe(true);
// Снапшот READY: после него отсутствие записи означает «не читалась».
expect(useMessagesStore.getState().snapshotApplied).toBe(false);
useMessagesStore.getState().markSnapshotApplied();
expect(useMessagesStore.getState().snapshotApplied).toBe(true);
useMessagesStore.getState().reset();
expect(useMessagesStore.getState().snapshotApplied).toBe(false);
});
it('READ_STATE_UPDATE без last_message_id не сбрасывает отметку прочтения', () => {
useMessagesStore.getState().applyReadState({ channel_id: CHANNEL, mention_count: 0 });
useMessagesStore.getState().applyReadState({
channel_id: CHANNEL,
last_message_id: 'm-5',
mention_count: 1,
});
useMessagesStore.getState().applyReadState({ channel_id: CHANNEL, mention_count: 4 });
expect(state().lastReadMessageId).toBe('m-5');
expect(state().mentionCount).toBe(4);
});
});
describe('стор сообщений: прочитанное и поиск', () => {
it('acknowledge обнуляет упоминания и отправляет отметку на сервер', async () => {
const fetchMock = installFetch([
{
match: `/api/v1/channels/${CHANNEL}/ack`,
method: 'POST',
response: () => json({ ok: true }),
},
]);
useMessagesStore.getState().applyReadState({
channel_id: CHANNEL,
last_message_id: 'm-1',
mention_count: 3,
});
await useMessagesStore.getState().acknowledge(CHANNEL, 'm-9');
expect(state().lastReadMessageId).toBe('m-9');
expect(state().mentionCount).toBe(0);
const body = recordedRequests(fetchMock)[0]?.body as Record<string, unknown>;
expect(body['last_message_id']).toBe('m-9');
});
it('счётчик непрочитанных равен нулю без отметки прочтения', () => {
for (const message of thread(3)) {
useMessagesStore.getState().applyMessageCreate(message);
}
expect(unreadCount(state())).toBe(0);
});
it('поиск складывает результаты и хранит ошибку лимита частоты', async () => {
let limited = false;
installFetch([
{
match: `/api/v1/channels/${CHANNEL}/messages/search`,
response: () =>
limited
? json(
{ error: { code: 'rate_limited', message: 'slow down', retry_after_ms: 2500 } },
429,
)
: json({
messages: [makeMessage({ id: 'm-1', channel_id: CHANNEL, content: 'найдено' })],
}),
},
]);
await useMessagesStore.getState().search(CHANNEL, 'най');
expect(state().searchStatus).toBe('ready');
expect(state().searchResults.map((message) => message.id)).toEqual(['m-1']);
limited = true;
await useMessagesStore.getState().search(CHANNEL, 'най');
expect(state().searchStatus).toBe('error');
expect(retryAfterMs(state().searchError)).toBe(2500);
});
it('типинг уходит не чаще раза в 3 секунды', async () => {
vi.useFakeTimers();
const fetchMock = installFetch([
{
match: `/api/v1/channels/${CHANNEL}/typing`,
method: 'POST',
response: () => json({ ok: true }),
},
]);
try {
useMessagesStore.getState().notifyTyping(CHANNEL);
await vi.advanceTimersByTimeAsync(0);
useMessagesStore.getState().notifyTyping(CHANNEL);
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock.mock.calls).toHaveLength(1);
vi.setSystemTime(Date.now() + 3_100);
useMessagesStore.getState().notifyTyping(CHANNEL);
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock.mock.calls).toHaveLength(2);
} finally {
vi.useRealTimers();
}
});
});
+7 -2
View File
@@ -8,6 +8,7 @@ import {
json,
makeReadySnapshot,
makeUser,
messagesRoutes,
recordedRequests,
renderApp,
} from './helpers';
@@ -90,6 +91,9 @@ describe('основной экран', () => {
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
...messagesRoutes('c-general', []),
]);
const gateway = installGatewaySocket(snapshot);
@@ -118,8 +122,9 @@ describe('основной экран', () => {
// Выход переехал во всплывающее меню (проверяется в tests/userMenu.test.tsx).
expect(within(panel).getByLabelText('Открыть меню пользователя')).toBeVisible();
// Заглушка комнаты вместо чата Фазы 1: шапка комнаты + подсказка.
expect(await screen.findByText('Чат появится в Фазе 2')).toBeVisible();
// Фаза 2: вместо заглушки в области комнаты открывается чат.
expect(await screen.findByTestId('composer')).toBeVisible();
expect(await screen.findByTestId('chat-start')).toHaveTextContent('Это начало комнаты #общий');
expect(screen.getByRole('heading', { name: 'общий' })).toBeVisible();
});