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

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

- стор сообщений по комнатам (кэш 200, оптимистичная отправка с nonce,
  применение всех событий Gateway, пагинация курсором, непрочитанные и
  упоминания, сброс при логауте и INVALID_SESSION);
- лента: группировка подряд идущих сообщений одного автора, разделители дат,
  разделитель «новые сообщения», подгрузка истории вверх, пустые состояния;
- разметка: жирный/курсив/код/блок кода/зачёркнутый/цитата/ссылки (только
  http(s)) и упоминания <@id> — безопасный рендер React-узлами без
  dangerouslySetInnerHTML;
- реакции чипами с подсветкой своих, быстрые эмодзи и пикер, панель действий и
  контекстное меню (ответ, реакция, закрепление, правка, удаление, копирование);
- ответы с переходом к оригиналу, inline-правка, «печатает…» с POST /typing раз
  в 3 с, пины в модалке, поиск по комнате с подсветкой и переходом;
- композер: автовысота, Enter/Shift+Enter, лимит 4000, drag&drop и скрепка
  (POST /channels/{id}/files → attachment_ids), обработка 429 с таймером и
  perm.denied с причиной, read-only без SEND_MESSAGES;
- бейджи непрочитанных и упоминаний в сайдбаре, авто-POST /ack при активном окне;
- 176 тестов Vitest (было 100), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 00:15:18 +03:00
parent b22db8f048
commit b01eb1f34c
34 changed files with 6417 additions and 52 deletions
+286
View File
@@ -0,0 +1,286 @@
import type { GuildMember, Role } from '@/api/types';
import { roleColorHex } from '@/lib/format';
/**
* Чистые помощники чата: группировка сообщений, разделители дат, форматирование
* времени и цвета автора. Вынесены из компонентов, чтобы их можно было
* проверить юнит-тестами без DOM.
*/
/** Подряд идущие сообщения одного автора объединяются в пределах 5 минут. */
export const GROUP_WINDOW_MS = 5 * 60 * 1000;
/** Быстрые реакции в панели действий над сообщением. */
export const QUICK_REACTIONS = ['👍', '🔥', '😂', '❤️', '🎉'] as const;
/** Набор популярных эмодзи для выпадающего пикера. */
export const POPULAR_EMOJIS = [
'👍',
'👎',
'🔥',
'🎉',
'😂',
'😅',
'😍',
'🤔',
'😮',
'😢',
'😡',
'🙏',
'👏',
'💪',
'❤️',
'💔',
'✅',
'❌',
'⚠️',
'❓',
'💡',
'🚀',
'🐛',
'☕',
'🍕',
'🎯',
'📌',
'👀',
'🤝',
'🫡',
] as const;
/** Сравнение идентификаторов сообщений: снежинки — возрастающие числа. */
export function compareMessageIds(left: string, right: string): number {
try {
const a = BigInt(left);
const b = BigInt(right);
return a === b ? 0 : a < b ? -1 : 1;
} catch {
return left === right ? 0 : left < right ? -1 : 1;
}
}
/** Минимум полей, нужный для построения строк ленты. */
export interface MessageLike {
id: string;
author_id?: string | undefined;
created_at: string;
reply_to_id?: string | undefined;
type?: string | undefined;
}
export interface ChatGroup<M extends MessageLike> {
key: string;
authorId: string | null;
messages: M[];
}
export type ChatRow<M extends MessageLike> =
| { kind: 'date'; key: string; iso: string }
| { kind: 'unread'; key: string }
| { kind: 'group'; key: string; group: ChatGroup<M> };
export interface BuildRowsOptions {
/**
* id последнего прочитанного сообщения: разделитель «новые сообщения»
* встаёт перед следующим за ним сообщением.
*/
unreadAnchorId?: string | null | undefined;
}
export function parseDate(iso: string): Date | null {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? null : date;
}
export function sameDay(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
/** Ключ локального дня (`YYYY-MM-DD`) — для разделителей и группировки. */
export function dayKey(iso: string): string {
const date = parseDate(iso);
if (date === null) {
return iso;
}
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${String(date.getFullYear())}-${month}-${day}`;
}
/** «Сегодня» / «Вчера» / конкретная дата — метка разделителя дня. */
export type DayLabel = 'today' | 'yesterday' | 'date';
export function dayLabel(iso: string, now: Date = new Date()): DayLabel {
const date = parseDate(iso);
if (date === null) {
return 'date';
}
if (sameDay(date, now)) {
return 'today';
}
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
return sameDay(date, yesterday) ? 'yesterday' : 'date';
}
/** Могут ли два соседних сообщения стоять в одной группе. */
export function canGroup(previous: MessageLike, next: MessageLike): boolean {
if (previous.author_id === undefined || previous.author_id !== next.author_id) {
return false;
}
if (next.reply_to_id !== undefined) {
return false;
}
if ((next.type ?? 'default') !== 'default') {
return false;
}
const left = parseDate(previous.created_at);
const right = parseDate(next.created_at);
if (left === null || right === null || !sameDay(left, right)) {
return false;
}
const delta = right.getTime() - left.getTime();
return delta >= 0 && delta <= GROUP_WINDOW_MS;
}
/**
* Строки ленты: разделитель дня, необязательный разделитель непрочитанного
* и группы сообщений. Порядок входа — от старых к новым.
*/
export function buildChatRows<M extends MessageLike>(
messages: M[],
options: BuildRowsOptions = {},
): ChatRow<M>[] {
const rows: ChatRow<M>[] = [];
const anchor = options.unreadAnchorId ?? null;
// «0» — комната никогда не читалась: разделитель встаёт перед первым сообщением.
const neverRead = anchor === '0';
const anchorIndex =
anchor === null || neverRead ? -1 : messages.findIndex((message) => message.id === anchor);
const first = messages[0];
// Отметка прочтения старше загруженного окна — непрочитано всё, что загружено
// (так же считает `unreadCount`), поэтому линия встаёт перед первым сообщением.
const anchorIsOlder =
anchor !== null &&
!neverRead &&
anchorIndex < 0 &&
first !== undefined &&
compareMessageIds(first.id, anchor) > 0;
const unreadBefore = neverRead
? messages[0]
: anchorIndex >= 0
? messages[anchorIndex + 1]
: anchorIsOlder
? messages[0]
: undefined;
let currentDay: string | null = null;
let group: ChatGroup<M> | null = null;
for (const message of messages) {
const day = dayKey(message.created_at);
if (day !== currentDay) {
currentDay = day;
group = null;
rows.push({ kind: 'date', key: `date:${day}`, iso: message.created_at });
}
if (unreadBefore !== undefined && unreadBefore.id === message.id) {
group = null;
rows.push({ kind: 'unread', key: 'unread' });
}
const last = group?.messages[group.messages.length - 1];
if (group !== null && last !== undefined && canGroup(last, message)) {
group.messages.push(message);
continue;
}
group = {
key: `group:${message.id}`,
authorId: message.author_id ?? null,
messages: [message],
};
rows.push({ kind: 'group', key: group.key, group });
}
return rows;
}
/** Время сообщения в формате локали (`12:34`). */
export function formatMessageTime(iso: string, locale: string): string {
const date = parseDate(iso);
if (date === null) {
return '';
}
return new Intl.DateTimeFormat(locale, { timeStyle: 'short' }).format(date);
}
/** Дата разделителя (`19 сентября 2026 г.`). */
export function formatMessageDate(iso: string, locale: string): string {
const date = parseDate(iso);
if (date === null) {
return iso;
}
return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(date);
}
/** Полная дата и время — для подсказки `title` у времени сообщения. */
export function formatMessageDateTime(iso: string, locale: string): string {
const date = parseDate(iso);
if (date === null) {
return iso;
}
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(date);
}
/**
* Цвет автора — цвет самой высокой роли участника с заданным цветом.
* Роли без цвета (0) пропускаем: у них нейтральный вид.
*/
export function roleColorForMember(
member: GuildMember | undefined,
roles: readonly Role[],
): string | null {
if (member === undefined || member.role_ids.length === 0) {
return null;
}
const colors = member.role_ids
.map((roleId) => roles.find((role) => role.id === roleId))
.filter((role): role is Role => role !== undefined && role.color > 0)
.sort((left, right) => right.position - left.position);
const top = colors[0];
return top === undefined ? null : roleColorHex(top.color);
}
/** Имя автора: ник на сервере важнее отображаемого имени. */
export function memberDisplayName(member: GuildMember | undefined): string | null {
if (member === undefined) {
return null;
}
const nickname = member.nickname;
if (nickname !== undefined && nickname.trim() !== '') {
return nickname;
}
return member.display_name !== '' ? member.display_name : member.username;
}
/** Копирование текста с мягкой деградацией (в тестах clipboard нет). */
export async function copyText(text: string): Promise<boolean> {
if (typeof navigator === 'undefined' || navigator.clipboard === undefined) {
return false;
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
/** Экранирование значения для CSS-селектора (в jsdom `CSS.escape` может не быть). */
export function escapeSelector(value: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
return value.replace(/[^a-zA-Z0-9_-]/gu, (char) => `\\${char}`);
}
+29
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { fetchInstance, instanceQueryKey } from '@/api/instance';
@@ -21,3 +22,31 @@ export function useInstance(): UseQueryResult<InstanceInfo> {
staleTime: 60_000,
});
}
/**
* Активно ли окно: вкладка видима и окно в фокусе.
*
* Нужен для отметки прочтения — `POST /ack` уходит только когда пользователь
* действительно смотрит на комнату.
*/
export function useWindowActive(): boolean {
const [active, setActive] = useState(
() => typeof document === 'undefined' || document.visibilityState !== 'hidden',
);
useEffect(() => {
const onFocus = (): void => setActive(true);
const onBlur = (): void => setActive(false);
const onVisibility = (): void => setActive(document.visibilityState !== 'hidden');
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
return active;
}
+44 -2
View File
@@ -39,6 +39,10 @@ export function avatarUrl(fileId: string | undefined): string | null {
export const PERMISSION_ADMINISTRATOR = 'ADMINISTRATOR';
export const PERMISSION_MANAGE_GUILD = 'MANAGE_GUILD';
export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS';
export const PERMISSION_MANAGE_MESSAGES = 'MANAGE_MESSAGES';
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
export const PERMISSION_ADD_REACTIONS = 'ADD_REACTIONS';
/** Есть ли у пользователя право с указанным именем. */
export function hasPermission(permissions: readonly string[], name: string): boolean {
@@ -81,8 +85,46 @@ export function canManageGuildSettings(
);
}
/** Человекочитаемая дата/время для списков (сессии, аудит). */
export function formatDateTime(value: string, locale: string): string {
/**
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
* проверит права в конкретной комнате.
*/
export function canManageMessages(
permissions: readonly string[],
isInstanceAdmin = false,
): boolean {
if (isInstanceAdmin) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_MANAGE_MESSAGES)
);
}
/** Может ли пользователь отправлять сообщения в комнату. */
export function canSendMessages(
permissions: readonly string[],
isInstanceAdmin = false,
channelCanSend?: boolean,
): boolean {
if (channelCanSend === false) {
return false;
}
if (isInstanceAdmin) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_SEND_MESSAGES)
);
}
/** Человекочитаемая дата/время для списков (сессии, аудит). */ export function formatDateTime(
value: string,
locale: string,
): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;