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:
@@ -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;
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Панель пользователя",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/*
|
||||
* Каскадное появление списка. Задержка растёт только у первых восьми
|
||||
* элементов — дальше список появляется без ступенек, чтобы длинные
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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': {
|
||||
// Роли участника влияют на права: перечитываем участников и карточку сервера.
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user