feat(web): виды сообщений, автокомплиты композера и ссылки на комнаты
- лента оформляет виды сообщений от сервера: action (/me) — имя акцентом и курсив, whisper — приглушённый блок с бейджем, scream — крупнее и жирнее, private (/ls) — рамка, замок и бейдж «лично», system — центрированная строка без аватара и панели действий; реакции, ответы, правка и удаление работают как раньше, группировка не сливает нестандартные виды с обычными; - автокомплиты в поле ввода с навигацией ↑/↓, Enter/Tab, Escape и мышью: @ — участники сервера (в DM собеседник), # — видимые текстовые комнаты, : — словарь на 113 эмодзи, / — команды с описанием; подсказки не появляются внутри слова и в блоках кода, вставка восстанавливает каретку и не расходится со слоем подсветки; - `<#id>` рендерится как #имя комнаты из снапшота (иначе #id); - 287 тестов Vitest (было 262), check/lint/test/build зелёные.
This commit is contained in:
@@ -218,6 +218,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
|
||||
<TypingIndicator channelId={channelId} typing={record.typing} authors={authors} />
|
||||
<Composer
|
||||
channel={channel}
|
||||
guildId={guildId}
|
||||
authors={authors}
|
||||
direct={isDirect}
|
||||
replyTo={replyTo}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -6,18 +6,26 @@ import { fileContentUrl, uploadChannelFile, type UploadedFile } from '@/api/file
|
||||
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
||||
import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages';
|
||||
import type { Channel } from '@/api/types';
|
||||
import { ComposerAutocomplete } from '@/components/chat/ComposerAutocomplete';
|
||||
import {
|
||||
COMPOSER_TEXT_CLASS,
|
||||
ComposerMirror,
|
||||
type ComposerSelection,
|
||||
} from '@/components/chat/ComposerMirror';
|
||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import {
|
||||
useComposerAutocomplete,
|
||||
type ComposerAutocompleteState,
|
||||
} from '@/components/chat/useComposerAutocomplete';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { detectAutocomplete, type AutocompleteItem } from '@/lib/autocomplete';
|
||||
import { formatBytes, errorCode } from '@/lib/format';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
|
||||
interface ComposerProps {
|
||||
channel: Channel;
|
||||
/** Сервер комнаты: нужен для подсказок участников и комнат. */
|
||||
guildId: string | null;
|
||||
authors: AuthorDirectory;
|
||||
/** Личная беседа: подписи без «#» (имя собеседника вместо комнаты). */
|
||||
direct?: boolean;
|
||||
@@ -45,6 +53,7 @@ const LINE_HEIGHT_PX = 22;
|
||||
*/
|
||||
export function Composer({
|
||||
channel,
|
||||
guildId,
|
||||
authors,
|
||||
direct = false,
|
||||
replyTo,
|
||||
@@ -67,10 +76,38 @@ export function Composer({
|
||||
const [dragging, setDragging] = useState(false);
|
||||
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
|
||||
const [selection, setSelection] = useState<ComposerSelection>({ start: 0, end: 0 });
|
||||
/** Подсказка, закрытая по Escape: её не открываем, пока жив её контекст. */
|
||||
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
|
||||
/**
|
||||
* Активный вариант подсказки: выбор привязан к позиции каретки, поэтому при
|
||||
* её смене (новый ввод) он снова первый — без эффекта и лишнего рендера.
|
||||
*/
|
||||
const [activePick, setActivePick] = useState({ caret: 0, index: 0 });
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
/**
|
||||
* Каретка после программной вставки подсказки. Ставим её в layout-эффекте:
|
||||
* к этому моменту textarea уже содержит новый текст, иначе браузер оставил бы
|
||||
* каретку в конце и слой подсветки разъехался бы с полем.
|
||||
*/
|
||||
const pendingCaret = useRef<number | null>(null);
|
||||
const autocompleteId = useId();
|
||||
|
||||
const suggestion = useComposerAutocomplete({
|
||||
value,
|
||||
caret: selection.end,
|
||||
channel,
|
||||
guildId,
|
||||
});
|
||||
const open = suggestion !== null && suggestion.key !== dismissedKey;
|
||||
const suggestionSize = suggestion?.items.length ?? 0;
|
||||
const activeIndex =
|
||||
!open || activePick.caret !== selection.end
|
||||
? 0
|
||||
: Math.min(activePick.index, suggestionSize - 1);
|
||||
const activeOptionId = open ? `${autocompleteId}-option-${String(activeIndex)}` : undefined;
|
||||
|
||||
// Предельный размер файла знает сервер (`GET /meta`); если он неизвестен,
|
||||
// ограничение проверяет сам сервер, а мы показываем его ошибку.
|
||||
@@ -114,6 +151,20 @@ export function Composer({
|
||||
syncScroll();
|
||||
}, [value, syncScroll]);
|
||||
|
||||
// Каретка сразу после вставленного фрагмента: иначе подсветка и правка
|
||||
// разъедутся с полем ввода.
|
||||
useLayoutEffect(() => {
|
||||
const caret = pendingCaret.current;
|
||||
const element = textareaRef.current;
|
||||
if (caret === null || element === null) {
|
||||
return;
|
||||
}
|
||||
pendingCaret.current = null;
|
||||
element.focus();
|
||||
element.setSelectionRange(caret, caret);
|
||||
syncSelection(element);
|
||||
}, [value, syncSelection]);
|
||||
|
||||
// Таймер лимита частоты: пока он идёт, отправка заблокирована.
|
||||
// Когда пауза истекла, снимаем `retryAt` — иначе интервал тикал бы вечно.
|
||||
useEffect(() => {
|
||||
@@ -179,6 +230,27 @@ export function Composer({
|
||||
[canAttach, channel.id, i18n.language, maxUploadSize, t],
|
||||
);
|
||||
|
||||
/**
|
||||
* Вставка варианта подсказки: заменяем триггер вместе с запросом и ставим
|
||||
* каретку сразу за вставленным фрагментом.
|
||||
*/
|
||||
const insertAutocomplete = (current: ComposerAutocompleteState, item: AutocompleteItem): void => {
|
||||
pendingCaret.current = current.start + item.insert.length;
|
||||
setValue(`${value.slice(0, current.start)}${item.insert}${value.slice(current.end)}`);
|
||||
setActivePick({ caret: 0, index: 0 });
|
||||
setDismissedKey(null);
|
||||
useMessagesStore.getState().notifyTyping(channel.id);
|
||||
};
|
||||
|
||||
/** Стрелки выбирают вариант; выбор живёт до следующего движения каретки. */
|
||||
const moveAutocomplete = (step: number): void => {
|
||||
setActivePick((current) => {
|
||||
const base = current.caret === selection.end ? current.index : 0;
|
||||
const index = Math.min(base, suggestionSize - 1);
|
||||
return { caret: selection.end, index: (index + step + suggestionSize) % suggestionSize };
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
const content = value.trim();
|
||||
if (!canSubmit || (content === '' && files.length === 0)) {
|
||||
@@ -196,6 +268,8 @@ export function Composer({
|
||||
setValue('');
|
||||
// Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров.
|
||||
setSelection({ start: 0, end: 0 });
|
||||
// Подсказок после отправки нет: отказ по Escape тоже больше не нужен.
|
||||
setDismissedKey(null);
|
||||
setFiles([]);
|
||||
setNotice(null);
|
||||
// Отправка прошла — лимит частоты больше не держим.
|
||||
@@ -337,6 +411,19 @@ export function Composer({
|
||||
mirrorRef={mirrorRef}
|
||||
resolveMention={authors.resolveMention}
|
||||
/>
|
||||
<ComposerAutocomplete
|
||||
open={open}
|
||||
label={suggestion === null ? '' : t(suggestion.labelKey)}
|
||||
id={autocompleteId}
|
||||
items={suggestion?.items ?? []}
|
||||
activeIndex={activeIndex}
|
||||
onActiveIndexChange={(index) => setActivePick({ caret: selection.end, index })}
|
||||
onPick={(item) => {
|
||||
if (suggestion !== null) {
|
||||
insertAutocomplete(suggestion, item);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
|
||||
@@ -346,6 +433,13 @@ export function Composer({
|
||||
direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder',
|
||||
{ name: channel.name },
|
||||
)}
|
||||
// Подсказки — это listbox над полем: фокус остаётся в textarea, а
|
||||
// активный вариант помечается `aria-activedescendant`.
|
||||
aria-expanded={open}
|
||||
{...(open ? { 'aria-controls': autocompleteId } : {})}
|
||||
{...(activeOptionId === undefined ? {} : { 'aria-activedescendant': activeOptionId })}
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
// Текст прозрачный: видно зеркало с разметкой. Каретка и выделение
|
||||
// остаются родными для поля ввода.
|
||||
className={`gl-composer-scroll relative z-10 block max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent text-transparent caret-fg selection:bg-accent/30 placeholder:text-fg-muted/70 focus:outline-none ${COMPOSER_TEXT_CLASS}`}
|
||||
@@ -357,8 +451,17 @@ export function Composer({
|
||||
// бы поверх подсветки.
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
const next = event.target.value;
|
||||
setValue(next);
|
||||
syncSelection(event.target);
|
||||
// Контекст подсказки потерян (стёрли триггер) — отказ по Escape
|
||||
// больше не нужен: следующая подсказка откроется сразу.
|
||||
if (
|
||||
dismissedKey !== null &&
|
||||
detectAutocomplete(next, event.target.selectionStart) === null
|
||||
) {
|
||||
setDismissedKey(null);
|
||||
}
|
||||
useMessagesStore.getState().notifyTyping(channel.id);
|
||||
}}
|
||||
onSelect={(event) => {
|
||||
@@ -372,6 +475,28 @@ export function Composer({
|
||||
}}
|
||||
onScroll={syncScroll}
|
||||
onKeyDown={(event) => {
|
||||
// Открытая подсказка перехватывает навигацию: стрелки выбирают
|
||||
// вариант, Enter/Tab вставляют его, Escape закрывает.
|
||||
if (open && suggestion !== null) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveAutocomplete(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
const item = suggestion.items[activeIndex];
|
||||
if (item !== undefined) {
|
||||
insertAutocomplete(suggestion, item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setDismissedKey(suggestion.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import type { AutocompleteItem } from '@/lib/autocomplete';
|
||||
|
||||
interface ComposerAutocompleteProps {
|
||||
open: boolean;
|
||||
/** Доступное имя списка (`aria-label`). */
|
||||
label: string;
|
||||
/** id списка: на него ссылается поле ввода через `aria-controls`. */
|
||||
id: string;
|
||||
items: AutocompleteItem[];
|
||||
activeIndex: number;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
onPick: (item: AutocompleteItem) => void;
|
||||
}
|
||||
|
||||
/** Подпись варианта: у команды — название и описание, у остальных — одна строка. */
|
||||
function OptionLabel({ item }: { item: AutocompleteItem }) {
|
||||
if (item.kind === 'command') {
|
||||
return (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium text-fg">{item.label}</span>
|
||||
{item.hint === undefined ? null : (
|
||||
<span className="truncate text-xs text-fg-muted">{item.hint}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'emoji') {
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span aria-hidden="true" className="text-lg leading-none">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="truncate text-fg-muted">{item.hint}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.hint === undefined ? null : (
|
||||
<span className="truncate text-xs text-fg-muted">{item.hint}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Список подсказок композера: `role="listbox"` над полем ввода.
|
||||
*
|
||||
* Фокус остаётся в поле — активный вариант помечается `aria-selected` и
|
||||
* `aria-activedescendant` у textarea, поэтому список доступен и с клавиатуры,
|
||||
* и скринридеру. Клик не забирает фокус (`onMouseDown` отменяется), иначе
|
||||
* каретка потерялась бы до вставки.
|
||||
*/
|
||||
export function ComposerAutocomplete({
|
||||
open,
|
||||
label,
|
||||
id,
|
||||
items,
|
||||
activeIndex,
|
||||
onActiveIndexChange,
|
||||
onPick,
|
||||
}: ComposerAutocompleteProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!open || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="composer-autocomplete"
|
||||
className="gl-popover absolute bottom-full left-0 z-40 mb-1 w-full max-w-80 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 p-1 shadow-[var(--shadow-3)]"
|
||||
>
|
||||
<p className="px-2 pb-1 text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t('chat.composer.autocomplete.hint')}
|
||||
</p>
|
||||
{/* Внутри listbox только варианты: подпись с подсказкой по клавишам —
|
||||
соседний узел, иначе скринридер читал бы её как вариант списка. */}
|
||||
<div
|
||||
id={id}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
className="gl-stagger flex max-h-56 flex-col overflow-y-auto"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
id={`${id}-option-${String(index)}`}
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
data-testid={`composer-option-${item.kind}`}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||
index === activeIndex ? 'bg-surface-3 text-fg' : 'text-fg-muted hover:bg-surface-2'
|
||||
}`}
|
||||
onMouseEnter={() => onActiveIndexChange(index)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onPick(item)}
|
||||
>
|
||||
{item.avatarName === undefined ? null : (
|
||||
<Avatar name={item.avatarName} seed={item.id} fileId={item.avatarFileId} size="sm" />
|
||||
)}
|
||||
<OptionLabel item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment, useMemo, type ReactNode, type RefObject } from 'react';
|
||||
|
||||
import { useChannelNames } from '@/components/chat/useChannelNames';
|
||||
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
|
||||
import {
|
||||
hasMarkers,
|
||||
selectionIntersects,
|
||||
@@ -53,7 +53,7 @@ interface ComposerMirrorProps {
|
||||
function renderFormatted(
|
||||
token: InlineToken,
|
||||
content: string,
|
||||
channels: ReadonlySet<string>,
|
||||
channels: ChannelDirectory,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
): ReactNode {
|
||||
switch (token.kind) {
|
||||
@@ -106,9 +106,14 @@ function renderFormatted(
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'channel-link': {
|
||||
// `<#id>`: как в ленте — имя комнаты из снапшота, иначе `#id`.
|
||||
const name = channels.byId.get(content);
|
||||
return <span className="text-accent">#{name ?? content}</span>;
|
||||
}
|
||||
case 'channel': {
|
||||
const name = token.raw.slice(1);
|
||||
return channels.has(name.toLowerCase()) ? (
|
||||
return channels.names.has(name.toLowerCase()) ? (
|
||||
<span className="text-accent">{token.raw}</span>
|
||||
) : (
|
||||
token.raw
|
||||
@@ -122,7 +127,7 @@ function renderToken(
|
||||
token: InlineToken,
|
||||
line: MirrorLine,
|
||||
selection: ComposerSelection,
|
||||
channels: ReadonlySet<string>,
|
||||
channels: ChannelDirectory,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
key: string,
|
||||
): ReactNode {
|
||||
@@ -158,7 +163,7 @@ function renderToken(
|
||||
function renderTokens(
|
||||
line: MirrorLine,
|
||||
selection: ComposerSelection,
|
||||
channels: ReadonlySet<string>,
|
||||
channels: ChannelDirectory,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
): ReactNode {
|
||||
const start = line.start + line.prefixLength;
|
||||
@@ -191,7 +196,7 @@ function renderTokens(
|
||||
function renderLine(
|
||||
line: MirrorLine,
|
||||
selection: ComposerSelection,
|
||||
channels: ReadonlySet<string>,
|
||||
channels: ChannelDirectory,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
): ReactNode {
|
||||
switch (line.kind) {
|
||||
@@ -227,7 +232,7 @@ export function ComposerMirror({
|
||||
resolveMention,
|
||||
}: ComposerMirrorProps) {
|
||||
const lines = useMemo(() => toMirrorLines(value), [value]);
|
||||
const channels = useChannelNames();
|
||||
const channels = useChannelDirectory();
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChannelNames } from '@/components/chat/useChannelNames';
|
||||
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
|
||||
import {
|
||||
parseBlocks,
|
||||
parseInline,
|
||||
@@ -60,7 +60,7 @@ function renderToken(
|
||||
content: string,
|
||||
key: string,
|
||||
resolveMention: MentionResolver,
|
||||
channels: ReadonlySet<string>,
|
||||
channels: ChannelDirectory,
|
||||
): ReactNode {
|
||||
switch (token.kind) {
|
||||
case 'code':
|
||||
@@ -129,10 +129,22 @@ function renderToken(
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
case 'channel-link': {
|
||||
// `<#id>`: имя комнаты берём из снапшота, иначе показываем `#id`.
|
||||
const name = channels.byId.get(content);
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className="rounded-[var(--radius-sm)] bg-accent/15 px-1 font-medium text-accent"
|
||||
>
|
||||
#{name ?? content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case 'channel': {
|
||||
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
|
||||
const name = token.raw.slice(1);
|
||||
return channels.has(name.toLowerCase()) ? (
|
||||
return channels.names.has(name.toLowerCase()) ? (
|
||||
<span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent">
|
||||
{token.raw}
|
||||
</span>
|
||||
@@ -146,7 +158,7 @@ function renderToken(
|
||||
function renderInline(
|
||||
text: string,
|
||||
resolveMention: MentionResolver,
|
||||
channels: ReadonlySet<string>,
|
||||
channels: ChannelDirectory,
|
||||
keyPrefix: string,
|
||||
): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
@@ -179,8 +191,8 @@ function renderInline(
|
||||
|
||||
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
|
||||
const blocks = useMemo(() => parseBlocks(content), [content]);
|
||||
// Имена каналов снапшота: `#канал` подсвечиваем только для них.
|
||||
const channels = useChannelNames();
|
||||
// Комнаты снапшота: `#канал` и `<#id>` подсвечиваем только для известных.
|
||||
const channels = useChannelDirectory();
|
||||
|
||||
return (
|
||||
<div className={`whitespace-pre-wrap break-words ${className}`}>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
import { MESSAGE_MAX_LENGTH, type Message } from '@/api/messages';
|
||||
import { MESSAGE_MAX_LENGTH, messageTypeOf, type Message, type MessageType } from '@/api/messages';
|
||||
import { EmojiPicker } from '@/components/chat/EmojiPicker';
|
||||
import { MessageContent } from '@/components/chat/MessageContent';
|
||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
@@ -40,6 +46,100 @@ const actionButtonClass =
|
||||
*/
|
||||
const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="menu"]';
|
||||
|
||||
/** Пометка вида сообщения: «шёпот», «громко», «лично». */
|
||||
function TypeBadge({ children, accent = false }: { children: ReactNode; accent?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full border px-1.5 align-middle text-[10px] uppercase tracking-wide ${
|
||||
accent ? 'border-accent/40 text-accent' : 'border-border/60 text-fg-muted'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Текст сообщения по виду из контракта: обычный, действие `/me`, шёпот, крик
|
||||
* и личное сообщение оформляются по-разному (AGENT.md §7.6). Системные
|
||||
* сообщения обрабатываются отдельной веткой — у них нет ни шапки автора, ни
|
||||
* панели действий.
|
||||
*/
|
||||
function MessageBody({
|
||||
type,
|
||||
message,
|
||||
authorName,
|
||||
resolveMention,
|
||||
}: {
|
||||
type: MessageType;
|
||||
message: Message;
|
||||
authorName: string;
|
||||
resolveMention: AuthorDirectory['resolveMention'];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (type === 'action') {
|
||||
// Действие: «Имя танцует» — имя акцентом, текст курсивом.
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5 text-sm text-fg italic">
|
||||
<span className="font-semibold text-accent not-italic">{authorName}</span>
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
resolveMention={resolveMention}
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (type === 'whisper') {
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5 rounded-[var(--radius-md)] border border-border/60 bg-surface-2/50 px-2 py-1">
|
||||
<TypeBadge>{t('chat.types.whisper')}</TypeBadge>
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
resolveMention={resolveMention}
|
||||
className="min-w-0 flex-1 text-sm text-fg-muted italic"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (type === 'scream') {
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5">
|
||||
<TypeBadge accent>{t('chat.types.scream')}</TypeBadge>
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
resolveMention={resolveMention}
|
||||
className="min-w-0 flex-1 text-base font-semibold text-accent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (type === 'private') {
|
||||
// Личное сообщение: сервер отдаёт его только автору и адресату.
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5 rounded-[var(--radius-md)] border border-accent/40 bg-accent/10 px-2 py-1">
|
||||
<span aria-hidden="true" className="text-xs">
|
||||
🔒
|
||||
</span>
|
||||
<TypeBadge accent>{t('chat.types.private')}</TypeBadge>
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
resolveMention={resolveMention}
|
||||
className="min-w-0 flex-1 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
resolveMention={resolveMention}
|
||||
className="text-sm text-fg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сообщение ленты: текст, вложения, реакции, ответы и действия.
|
||||
*
|
||||
@@ -78,6 +178,7 @@ export function MessageItem({
|
||||
const canEdit = isMine && canSend && !pending;
|
||||
const canDelete = !pending && (isMine || canManageMessages);
|
||||
const edited = message.edited_at !== undefined;
|
||||
const type = messageTypeOf(message.type);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
@@ -166,10 +267,38 @@ export function MessageItem({
|
||||
onReply(message);
|
||||
};
|
||||
|
||||
// Системное сообщение: центрированная приглушённая строка без шапки автора,
|
||||
// вложений и панели действий — реагировать и отвечать на неё нечего.
|
||||
if (type === 'system') {
|
||||
return (
|
||||
<article
|
||||
data-message-id={message.id}
|
||||
data-testid={`message-${message.id}`}
|
||||
data-message-type="system"
|
||||
className={`flex flex-col items-center gap-0.5 px-1 py-1 text-center ${
|
||||
pending ? 'opacity-70' : ''
|
||||
} ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`}
|
||||
>
|
||||
<span className="sr-only">{t('chat.types.system')}</span>
|
||||
{message.content === '' ? null : (
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
resolveMention={authors.resolveMention}
|
||||
className="text-xs text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
<time dateTime={message.created_at} className="text-[10px] text-fg-muted/70">
|
||||
{formatMessageTime(message.created_at, i18n.language)}
|
||||
</time>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
data-message-id={message.id}
|
||||
data-testid={`message-${message.id}`}
|
||||
data-message-type={type}
|
||||
onDoubleClick={onDoubleClick}
|
||||
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
|
||||
pending ? 'opacity-70' : ''
|
||||
@@ -234,10 +363,11 @@ export function MessageItem({
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{message.content === '' ? null : (
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
<MessageBody
|
||||
type={type}
|
||||
message={message}
|
||||
authorName={author.name}
|
||||
resolveMention={authors.resolveMention}
|
||||
className="text-sm text-fg"
|
||||
/>
|
||||
)}
|
||||
{message.attachments.length === 0 ? null : (
|
||||
@@ -310,6 +440,11 @@ export function MessageItem({
|
||||
>
|
||||
{formatMessageTime(message.created_at, i18n.language)}
|
||||
</time>
|
||||
) : type === 'action' ? (
|
||||
// У действия нет шапки автора — время показываем всегда.
|
||||
<time dateTime={message.created_at}>
|
||||
{formatMessageTime(message.created_at, i18n.language)}
|
||||
</time>
|
||||
) : null}
|
||||
{edited ? <span>{t('chat.edited')}</span> : null}
|
||||
{pending ? <span role="status">{t('chat.pending')}</span> : null}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isPendingMessage, type Message } from '@/api/messages';
|
||||
import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages';
|
||||
import { MessageItem } from '@/components/chat/MessageItem';
|
||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
@@ -215,77 +215,94 @@ export function MessageList({
|
||||
if (first === undefined) {
|
||||
return null;
|
||||
}
|
||||
const headType = messageTypeOf(first.type);
|
||||
const author = authors.resolve(row.group.authorId ?? undefined);
|
||||
// У системных сообщений нет ни аватара, ни шапки автора, а действие
|
||||
// `/me` рисует имя внутри строки — шапка ему тоже не нужна.
|
||||
const showHeader = headType !== 'system' && headType !== 'action';
|
||||
const body = (
|
||||
<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>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={row.key}
|
||||
data-testid="message-group"
|
||||
data-message-type={headType}
|
||||
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' });
|
||||
{headType === 'system' ? (
|
||||
// Системная строка: по центру, без колонки аватара.
|
||||
<div className="flex justify-center">{body}</div>
|
||||
) : (
|
||||
<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">
|
||||
{showHeader ? (
|
||||
<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>
|
||||
) : null}
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/** Комнаты снапшота в двух видах: по id и по имени. */
|
||||
export interface ChannelDirectory {
|
||||
/** `id → имя`: для ссылок на комнату `<#id>`. */
|
||||
byId: ReadonlyMap<string, string>;
|
||||
/** Имена в нижнем регистре: `#имя` подсвечивается только для известных комнат. */
|
||||
names: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Справочник комнат из снапшота: общий хук для ленты сообщений и зеркала
|
||||
* композера. Один разбор — оба вида ссылок (`#имя` и `<#id>`) оформляются
|
||||
* одинаково в предпросмотре и в ленте.
|
||||
*/
|
||||
export function useChannelDirectory(): ChannelDirectory {
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
return useMemo(() => {
|
||||
const byId = new Map<string, string>();
|
||||
const names = new Set<string>();
|
||||
for (const guild of guilds) {
|
||||
for (const channel of guild.channels) {
|
||||
byId.set(channel.id, channel.name);
|
||||
names.add(channel.name.toLowerCase());
|
||||
}
|
||||
}
|
||||
return { byId, names };
|
||||
}, [guilds]);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Имена каналов из снапшота: `#канал` подсвечивается только для известных
|
||||
* комнат. Общий хук для ленты сообщений и зеркала композера.
|
||||
*/
|
||||
export function useChannelNames(): ReadonlySet<string> {
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
return useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const guild of guilds) {
|
||||
for (const channel of guild.channels) {
|
||||
names.add(channel.name.toLowerCase());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [guilds]);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fetchMembers, guildMembersQueryKey } from '@/api/guilds';
|
||||
import type { Channel, GuildMember, UserSummary } from '@/api/types';
|
||||
import { fetchUser, userQueryKey } from '@/api/users';
|
||||
import {
|
||||
detectAutocomplete,
|
||||
CHAT_COMMANDS,
|
||||
type AutocompleteKind,
|
||||
type AutocompleteItem,
|
||||
} from '@/lib/autocomplete';
|
||||
import { memberDisplayName } from '@/lib/chat';
|
||||
import { searchEmoji } from '@/lib/emoji';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/** Сколько подсказок показываем: список должен помещаться над полем ввода. */
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
|
||||
/** Ключи перевода заголовка списка — по виду подсказки. */
|
||||
const LABEL_KEYS: Readonly<Record<AutocompleteKind, string>> = {
|
||||
mention: 'chat.composer.autocomplete.mentions',
|
||||
channel: 'chat.composer.autocomplete.channels',
|
||||
emoji: 'chat.composer.autocomplete.emoji',
|
||||
command: 'chat.composer.autocomplete.commands',
|
||||
};
|
||||
|
||||
/** Открытая подсказка: контекст триггера и готовые варианты. */
|
||||
export interface ComposerAutocompleteState {
|
||||
kind: AutocompleteKind;
|
||||
/** Смещение символа-триггера: с него начинается заменяемый фрагмент. */
|
||||
start: number;
|
||||
/** Позиция каретки: конец заменяемого фрагмента. */
|
||||
end: number;
|
||||
/** Ключ контекста: по нему композер отличает один триггер от другого. */
|
||||
key: string;
|
||||
/** Ключ перевода заголовка списка. */
|
||||
labelKey: string;
|
||||
items: AutocompleteItem[];
|
||||
}
|
||||
|
||||
/** Участник, которого можно упомянуть. */
|
||||
interface MentionCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
avatarFileId: string | undefined;
|
||||
}
|
||||
|
||||
function membersToCandidates(members: GuildMember[] | undefined): MentionCandidate[] {
|
||||
return (members ?? []).map((member) => ({
|
||||
id: member.user_id,
|
||||
name: memberDisplayName(member) ?? member.username,
|
||||
username: member.username,
|
||||
avatarFileId: member.avatar_file_id,
|
||||
}));
|
||||
}
|
||||
|
||||
function recipientToCandidate(recipient: UserSummary | undefined): MentionCandidate[] {
|
||||
if (recipient === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: recipient.id,
|
||||
name: recipient.display_name !== '' ? recipient.display_name : recipient.username,
|
||||
username: recipient.username,
|
||||
avatarFileId: recipient.avatar_file_id,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
interface ComposerAutocompleteOptions {
|
||||
value: string;
|
||||
/** Позиция каретки в поле ввода. */
|
||||
caret: number;
|
||||
channel: Channel;
|
||||
/** Сервер комнаты: из него берём участников и видимые текстовые комнаты. */
|
||||
guildId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подсказки композера: `@` — участники сервера (в личной беседе — собеседник),
|
||||
* `#` — видимые текстовые комнаты, `:` — эмодзи по коду, `/` — команды чата.
|
||||
*
|
||||
* Контекст триггера ищет `detectAutocomplete` (без DOM), здесь только данные и
|
||||
* фильтр по префиксу. Участники читаются через тот же ключ запроса, что и
|
||||
* справочник авторов, поэтому лишнего обращения к серверу не происходит.
|
||||
*
|
||||
* Хук не хранит состояние: закрытие по Escape — забота композера, а контекст
|
||||
* триггера целиком выводится из текста и позиции каретки.
|
||||
*/
|
||||
export function useComposerAutocomplete(
|
||||
options: ComposerAutocompleteOptions,
|
||||
): ComposerAutocompleteState | null {
|
||||
const { value, caret, channel, guildId } = options;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: guildMembersQueryKey(guildId ?? ''),
|
||||
queryFn: ({ signal }) => fetchMembers(guildId ?? '', signal),
|
||||
enabled: guildId !== null,
|
||||
staleTime: 60_000,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const recipientId = channel.type === 'dm' ? channel.recipient_id : undefined;
|
||||
const recipient = useQuery({
|
||||
queryKey: userQueryKey(recipientId ?? ''),
|
||||
queryFn: ({ signal }) => fetchUser(recipientId ?? '', signal),
|
||||
enabled: recipientId !== undefined && recipientId !== '',
|
||||
staleTime: 300_000,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const guild = useSessionStore(
|
||||
useCallback(
|
||||
(state) =>
|
||||
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
|
||||
[guildId],
|
||||
),
|
||||
);
|
||||
|
||||
const trigger = useMemo(() => detectAutocomplete(value, caret), [value, caret]);
|
||||
|
||||
return useMemo<ComposerAutocompleteState | null>(() => {
|
||||
if (trigger === null) {
|
||||
return null;
|
||||
}
|
||||
const query = trigger.query.toLowerCase();
|
||||
let items: AutocompleteItem[];
|
||||
|
||||
if (trigger.kind === 'command') {
|
||||
items = CHAT_COMMANDS.filter((command) =>
|
||||
command.command.slice(1).toLowerCase().startsWith(query),
|
||||
)
|
||||
.slice(0, MAX_SUGGESTIONS)
|
||||
.map((command) => ({
|
||||
id: `command:${command.command}`,
|
||||
kind: 'command',
|
||||
insert: `${command.command} `,
|
||||
label: command.command,
|
||||
hint: t(command.descriptionKey),
|
||||
}));
|
||||
} else if (trigger.kind === 'mention') {
|
||||
const candidates =
|
||||
guildId === null ? recipientToCandidate(recipient.data) : membersToCandidates(members.data);
|
||||
items = candidates
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.username.toLowerCase().startsWith(query) ||
|
||||
candidate.name.toLowerCase().startsWith(query),
|
||||
)
|
||||
.slice(0, MAX_SUGGESTIONS)
|
||||
.map((candidate) => ({
|
||||
id: `mention:${candidate.id}`,
|
||||
kind: 'mention',
|
||||
insert: `<@${candidate.id}>`,
|
||||
label: candidate.name,
|
||||
hint: `@${candidate.username}`,
|
||||
avatarName: candidate.name,
|
||||
avatarFileId: candidate.avatarFileId,
|
||||
}));
|
||||
} else if (trigger.kind === 'channel') {
|
||||
items = (guild?.channels ?? [])
|
||||
.filter((item) => item.type === 'text' && item.can_view !== false)
|
||||
.slice()
|
||||
.sort((left, right) => left.position - right.position)
|
||||
.filter((item) => item.name.toLowerCase().startsWith(query))
|
||||
.slice(0, MAX_SUGGESTIONS)
|
||||
.map((item) => ({
|
||||
id: `channel:${item.id}`,
|
||||
kind: 'channel',
|
||||
insert: `<#${item.id}>`,
|
||||
label: `#${item.name}`,
|
||||
}));
|
||||
} else {
|
||||
items = searchEmoji(query, MAX_SUGGESTIONS).map((entry) => ({
|
||||
id: `emoji:${entry.code}`,
|
||||
kind: 'emoji',
|
||||
insert: entry.emoji,
|
||||
label: entry.emoji,
|
||||
hint: `:${entry.code}:`,
|
||||
}));
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: trigger.kind,
|
||||
start: trigger.start,
|
||||
end: trigger.end,
|
||||
key: `${trigger.kind}:${String(trigger.start)}`,
|
||||
labelKey: LABEL_KEYS[trigger.kind],
|
||||
items,
|
||||
};
|
||||
}, [trigger, guildId, guild, members.data, recipient.data, t]);
|
||||
}
|
||||
Reference in New Issue
Block a user