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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user