fix(install): публичный адрес LiveKit вычисляется после TLS_ENABLED
Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS, dry-run проходит.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isPendingMessage, type Message } from '@/api/messages';
|
||||
import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages';
|
||||
import type { Channel } from '@/api/types';
|
||||
import { Composer } from '@/components/chat/Composer';
|
||||
import { MessageList } from '@/components/chat/MessageList';
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
canSendMessages,
|
||||
hasPermission,
|
||||
} from '@/lib/identity';
|
||||
import { systemMessageUserId } from '@/lib/systemMessage';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { channelState, useMessagesStore } from '@/stores/messages';
|
||||
|
||||
@@ -86,6 +87,14 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
|
||||
if (message.author_id !== undefined) {
|
||||
ids.add(message.author_id);
|
||||
}
|
||||
// Системные сообщения пишет сервер: имя участника события лежит в
|
||||
// JSON-payload'е, поэтому добираем его тем же справочником.
|
||||
if (messageTypeOf(message.type) === 'system') {
|
||||
const userId = systemMessageUserId(message.content);
|
||||
if (userId !== null) {
|
||||
ids.add(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const entry of record.typing) {
|
||||
ids.add(entry.user_id);
|
||||
@@ -195,6 +204,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
|
||||
<MessageList
|
||||
channelId={channelId}
|
||||
channelName={channel.name}
|
||||
guildId={guildId}
|
||||
direct={isDirect}
|
||||
messages={messages}
|
||||
status={record.status}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ComposerMirror,
|
||||
type ComposerSelection,
|
||||
} from '@/components/chat/ComposerMirror';
|
||||
import { EmojiPicker } from '@/components/chat/EmojiPicker';
|
||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import {
|
||||
useComposerAutocomplete,
|
||||
@@ -74,6 +75,8 @@ export function Composer({
|
||||
/** Тикающее «сейчас»: по нему считается остаток лимита частоты. */
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [dragging, setDragging] = useState(false);
|
||||
/** Открыт пикер эмодзи: вставка идёт в позицию каретки. */
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
|
||||
const [selection, setSelection] = useState<ComposerSelection>({ start: 0, end: 0 });
|
||||
/** Подсказка, закрытая по Escape: её не открываем, пока жив её контекст. */
|
||||
@@ -87,6 +90,7 @@ export function Composer({
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const emojiAnchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
/**
|
||||
* Каретка после программной вставки подсказки. Ставим её в layout-эффекте:
|
||||
* к этому моменту textarea уже содержит новый текст, иначе браузер оставил бы
|
||||
@@ -251,6 +255,20 @@ export function Composer({
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Вставка эмодзи из пикера: заменяем выделение и ставим каретку сразу за
|
||||
* вставленным фрагментом (`pendingCaret` сделает это в layout-эффекте).
|
||||
* Кастомный эмодзи приходит токеном `<:имя:file_id>` — как из автокомплита.
|
||||
*/
|
||||
const insertEmoji = (emoji: string): void => {
|
||||
const { start, end } = selection;
|
||||
pendingCaret.current = start + emoji.length;
|
||||
setValue(`${value.slice(0, start)}${emoji}${value.slice(end)}`);
|
||||
setEmojiOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
useMessagesStore.getState().notifyTyping(channel.id);
|
||||
};
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
const content = value.trim();
|
||||
if (!canSubmit || (content === '' && files.length === 0)) {
|
||||
@@ -402,6 +420,30 @@ export function Composer({
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{/* Эмодзи: тот же пикер, что и у реакций, — юникод и эмодзи сервера. */}
|
||||
<span className="relative">
|
||||
<button
|
||||
ref={emojiAnchorRef}
|
||||
type="button"
|
||||
aria-label={t('chat.composer.emoji')}
|
||||
title={t('chat.composer.emoji')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={emojiOpen}
|
||||
disabled={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={() => setEmojiOpen((open) => !open)}
|
||||
>
|
||||
😀
|
||||
</button>
|
||||
<EmojiPicker
|
||||
open={emojiOpen}
|
||||
anchorRef={emojiAnchorRef}
|
||||
onClose={() => setEmojiOpen(false)}
|
||||
onPick={insertEmoji}
|
||||
placement="top"
|
||||
guildId={guildId}
|
||||
/>
|
||||
</span>
|
||||
{/* Заблокированное поле приглушаем вместе с зеркалом: плейсхолдер и
|
||||
подсветка должны выглядеть одинаково неактивными. */}
|
||||
<div className={`relative w-full ${blockedReason === null ? '' : 'opacity-60'}`}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import type { AutocompleteItem } from '@/lib/autocomplete';
|
||||
|
||||
@@ -28,6 +29,23 @@ function OptionLabel({ item }: { item: AutocompleteItem }) {
|
||||
);
|
||||
}
|
||||
if (item.kind === 'emoji') {
|
||||
// Кастомный эмодзи сервера: картинка вместо символа и `:имя:` подписью.
|
||||
if (item.emojiFileId !== undefined) {
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<img
|
||||
src={fileContentUrl(item.emojiFileId)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-5 w-5 shrink-0"
|
||||
/>
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.hint === undefined ? null : (
|
||||
<span className="truncate text-xs text-fg-muted">{item.hint}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span aria-hidden="true" className="text-lg leading-none">
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Fragment, useMemo, type ReactNode, type RefObject } from 'react';
|
||||
|
||||
import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage';
|
||||
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
|
||||
import {
|
||||
useGuildEmojiDirectory,
|
||||
type GuildEmojiDirectory,
|
||||
} from '@/components/chat/useGuildEmojis';
|
||||
import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
|
||||
import {
|
||||
hasMarkers,
|
||||
selectionIntersects,
|
||||
@@ -54,6 +60,7 @@ function renderFormatted(
|
||||
token: InlineToken,
|
||||
content: string,
|
||||
channels: ChannelDirectory,
|
||||
emojis: GuildEmojiDirectory,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
): ReactNode {
|
||||
switch (token.kind) {
|
||||
@@ -111,6 +118,20 @@ function renderFormatted(
|
||||
const name = channels.byId.get(content);
|
||||
return <span className="text-accent">#{name ?? content}</span>;
|
||||
}
|
||||
case 'custom-emoji': {
|
||||
// Картинка 20×20 не меняет высоту строки (text-sm/leading-5), поэтому
|
||||
// подсветка остаётся на месте textarea. Нет файла в снапшоте — `:имя:`.
|
||||
const parsed = parseCustomEmoji(token.raw);
|
||||
const emoji = parsed === null ? null : emojis.resolve(token.raw);
|
||||
if (parsed === null) {
|
||||
return token.raw;
|
||||
}
|
||||
return emoji === null ? (
|
||||
customEmojiShortcode(parsed.name)
|
||||
) : (
|
||||
<CustomEmojiImage emoji={emoji} />
|
||||
);
|
||||
}
|
||||
case 'channel': {
|
||||
const name = token.raw.slice(1);
|
||||
return channels.names.has(name.toLowerCase()) ? (
|
||||
@@ -128,6 +149,7 @@ function renderToken(
|
||||
line: MirrorLine,
|
||||
selection: ComposerSelection,
|
||||
channels: ChannelDirectory,
|
||||
emojis: GuildEmojiDirectory,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
key: string,
|
||||
): ReactNode {
|
||||
@@ -137,6 +159,27 @@ function renderToken(
|
||||
// было видно и удобно править. Иначе маркеры скрыты.
|
||||
const active = hasMarkers(token.kind) && selectionIntersects(selection, token.start, token.end);
|
||||
|
||||
if (token.kind === 'custom-emoji') {
|
||||
// У эмодзи «содержимое» — весь токен, поэтому при каретке внутри
|
||||
// показываем исходный текст `<:имя:file_id>`: так его видно и можно
|
||||
// править. Вне токена — картинка, как в ленте.
|
||||
return active ? (
|
||||
<span key={key} className="text-fg-muted">
|
||||
{token.raw}
|
||||
</span>
|
||||
) : (
|
||||
<Fragment key={key}>
|
||||
{renderFormatted(
|
||||
token,
|
||||
text.slice(toLocal(token.contentStart), toLocal(token.contentEnd)),
|
||||
channels,
|
||||
emojis,
|
||||
resolveMention,
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
{active ? (
|
||||
@@ -148,6 +191,7 @@ function renderToken(
|
||||
token,
|
||||
text.slice(toLocal(token.contentStart), toLocal(token.contentEnd)),
|
||||
channels,
|
||||
emojis,
|
||||
resolveMention,
|
||||
)}
|
||||
{active ? (
|
||||
@@ -164,6 +208,7 @@ function renderTokens(
|
||||
line: MirrorLine,
|
||||
selection: ComposerSelection,
|
||||
channels: ChannelDirectory,
|
||||
emojis: GuildEmojiDirectory,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
): ReactNode {
|
||||
const start = line.start + line.prefixLength;
|
||||
@@ -180,6 +225,7 @@ function renderTokens(
|
||||
line,
|
||||
selection,
|
||||
channels,
|
||||
emojis,
|
||||
resolveMention,
|
||||
`${String(line.start)}-${String(index)}`,
|
||||
),
|
||||
@@ -197,6 +243,7 @@ function renderLine(
|
||||
line: MirrorLine,
|
||||
selection: ComposerSelection,
|
||||
channels: ChannelDirectory,
|
||||
emojis: GuildEmojiDirectory,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
): ReactNode {
|
||||
switch (line.kind) {
|
||||
@@ -213,11 +260,11 @@ function renderLine(
|
||||
return (
|
||||
<>
|
||||
<span className="text-fg-muted">{line.text.slice(0, line.prefixLength)}</span>
|
||||
{renderTokens(line, selection, channels, resolveMention)}
|
||||
{renderTokens(line, selection, channels, emojis, resolveMention)}
|
||||
</>
|
||||
);
|
||||
case 'text':
|
||||
return renderTokens(line, selection, channels, resolveMention);
|
||||
return renderTokens(line, selection, channels, emojis, resolveMention);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +280,7 @@ export function ComposerMirror({
|
||||
}: ComposerMirrorProps) {
|
||||
const lines = useMemo(() => toMirrorLines(value), [value]);
|
||||
const channels = useChannelDirectory();
|
||||
const emojis = useGuildEmojiDirectory();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -243,7 +291,7 @@ export function ComposerMirror({
|
||||
>
|
||||
{lines.map((line, index) => (
|
||||
<Fragment key={`${String(line.start)}-${String(index)}`}>
|
||||
{renderLine(line, selection, channels, resolveMention)}
|
||||
{renderLine(line, selection, channels, emojis, resolveMention)}
|
||||
{index < lines.length - 1 ? '\n' : null}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
import type { GuildEmoji } from '@/api/emojis';
|
||||
import { customEmojiShortcode } from '@/lib/customEmoji';
|
||||
|
||||
interface CustomEmojiImageProps {
|
||||
emoji: GuildEmoji;
|
||||
/**
|
||||
* Размер картинки. По умолчанию 20×20 (`h-5 w-5`) с вертикальным
|
||||
* выравниванием под строку текста: те же метрики у ленты и у зеркала
|
||||
* композера, иначе подсветка разъехалась бы с полем ввода.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Кастомный эмодзи сервера картинкой. Анимированные (GIF/WebP) анимируются
|
||||
* сами: отдельного плеера не нужно. Подпись `:имя:` уходит и в `alt`, и в
|
||||
* `title` — скринридер и подсказка показывают то же, что юникод-эмодзи.
|
||||
*/
|
||||
export function CustomEmojiImage({ emoji, className = '' }: CustomEmojiImageProps) {
|
||||
const shortcode = customEmojiShortcode(emoji.name);
|
||||
return (
|
||||
<img
|
||||
src={fileContentUrl(emoji.file_id)}
|
||||
alt={shortcode}
|
||||
title={shortcode}
|
||||
loading="lazy"
|
||||
className={`inline-block h-5 w-5 align-[-0.15em] ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
import { useGuildEmojis } from '@/components/chat/useGuildEmojis';
|
||||
import { POPULAR_EMOJIS, QUICK_REACTIONS } from '@/lib/chat';
|
||||
import { customEmojiShortcode } from '@/lib/customEmoji';
|
||||
|
||||
interface EmojiPickerProps {
|
||||
open: boolean;
|
||||
@@ -11,11 +14,19 @@ interface EmojiPickerProps {
|
||||
anchorRef?: RefObject<HTMLElement | null>;
|
||||
/** Куда раскрывается панель: под якорем или над ним. */
|
||||
placement?: 'top' | 'bottom';
|
||||
/** Сервер, чьи кастомные эмодзи показываем: в беседе их нет. */
|
||||
guildId?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Выпадающий пикер эмодзи: ряд быстрых реакций и сетка популярных.
|
||||
* Выпадающий пикер эмодзи: ряд быстрых реакций, эмодзи сервера и сетка
|
||||
* популярных. Пикер общий для ленты (реакции) и композера (вставка в текст):
|
||||
* `onPick` получает либо юникод-эмодзи, либо токен `<:имя:file_id>`.
|
||||
*
|
||||
* Эмодзи сервера ищутся по имени; список пуст, если сервера нет (личная
|
||||
* беседа) или на нём ещё не загрузили ни одного эмодзи.
|
||||
*
|
||||
* Закрывается по Escape и клику вне панели; фокус остаётся в пределах
|
||||
* tab-порядка страницы.
|
||||
*/
|
||||
@@ -25,15 +36,33 @@ export function EmojiPicker({
|
||||
onPick,
|
||||
anchorRef,
|
||||
placement = 'bottom',
|
||||
guildId = null,
|
||||
className = '',
|
||||
}: EmojiPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const serverEmojis = useGuildEmojis(guildId);
|
||||
const onCloseRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
// Поиск сбрасываем при каждом открытии: иначе список «залипал» бы от прошлого раза.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const found = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (needle === '') {
|
||||
return serverEmojis;
|
||||
}
|
||||
return serverEmojis.filter((emoji) => emoji.name.toLowerCase().includes(needle));
|
||||
}, [serverEmojis, query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
@@ -90,6 +119,55 @@ export function EmojiPicker({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{serverEmojis.length === 0 ? null : (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('chat.emoji.server')}
|
||||
data-testid="emoji-picker-server"
|
||||
className="mt-2 border-t border-border/40 pt-2"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
aria-label={t('chat.emoji.search')}
|
||||
placeholder={t('chat.emoji.searchPlaceholder')}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="w-full rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 text-xs text-fg placeholder:text-fg-muted/70"
|
||||
/>
|
||||
{found.length === 0 ? (
|
||||
<p className="mt-1 px-1 text-xs text-fg-muted">{t('chat.emoji.notFound')}</p>
|
||||
) : (
|
||||
<ul className="mt-1 flex max-h-40 flex-col gap-0.5 overflow-y-auto">
|
||||
{found.map((emoji) => (
|
||||
<li key={emoji.id}>
|
||||
<button
|
||||
type="button"
|
||||
title={customEmojiShortcode(emoji.name)}
|
||||
aria-label={t('chat.emoji.pickServer', { name: emoji.name })}
|
||||
className="flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-1 py-0.5 text-left text-xs text-fg transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-[0.98]"
|
||||
onClick={() => onPick(emoji.token)}
|
||||
>
|
||||
<img
|
||||
src={fileContentUrl(emoji.file_id)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-6 w-6 shrink-0"
|
||||
/>
|
||||
<span className="truncate">{customEmojiShortcode(emoji.name)}</span>
|
||||
{emoji.animated ? (
|
||||
<span className="ml-auto shrink-0 text-[10px] uppercase text-fg-muted">
|
||||
{t('chat.emoji.animated')}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('chat.emoji.popular')}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage';
|
||||
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
|
||||
import {
|
||||
useGuildEmojiDirectory,
|
||||
type GuildEmojiDirectory,
|
||||
} from '@/components/chat/useGuildEmojis';
|
||||
import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
|
||||
import {
|
||||
parseBlocks,
|
||||
parseInline,
|
||||
@@ -61,6 +67,7 @@ function renderToken(
|
||||
key: string,
|
||||
resolveMention: MentionResolver,
|
||||
channels: ChannelDirectory,
|
||||
emojis: GuildEmojiDirectory,
|
||||
): ReactNode {
|
||||
switch (token.kind) {
|
||||
case 'code':
|
||||
@@ -141,6 +148,21 @@ function renderToken(
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case 'custom-emoji': {
|
||||
// `<:имя:file_id>`: картинку отдаёт файловый сервис. Если эмодзи нет в
|
||||
// снапшоте сервера (удалён, чужой сервер), показываем `:имя:` — разметка
|
||||
// остаётся целой, а текст читаемым.
|
||||
const parsed = parseCustomEmoji(token.raw);
|
||||
if (parsed === null) {
|
||||
return <Fragment key={key}>{token.raw}</Fragment>;
|
||||
}
|
||||
const emoji = emojis.resolve(token.raw);
|
||||
return emoji === null ? (
|
||||
<Fragment key={key}>{customEmojiShortcode(parsed.name)}</Fragment>
|
||||
) : (
|
||||
<CustomEmojiImage key={key} emoji={emoji} />
|
||||
);
|
||||
}
|
||||
case 'channel': {
|
||||
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
|
||||
const name = token.raw.slice(1);
|
||||
@@ -159,6 +181,7 @@ function renderInline(
|
||||
text: string,
|
||||
resolveMention: MentionResolver,
|
||||
channels: ChannelDirectory,
|
||||
emojis: GuildEmojiDirectory,
|
||||
keyPrefix: string,
|
||||
): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
@@ -178,6 +201,7 @@ function renderInline(
|
||||
key,
|
||||
resolveMention,
|
||||
channels,
|
||||
emojis,
|
||||
),
|
||||
);
|
||||
lastIndex = token.end;
|
||||
@@ -193,6 +217,8 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess
|
||||
const blocks = useMemo(() => parseBlocks(content), [content]);
|
||||
// Комнаты снапшота: `#канал` и `<#id>` подсвечиваем только для известных.
|
||||
const channels = useChannelDirectory();
|
||||
// Эмодзи снапшота: токен без файла остаётся текстом `:имя:`.
|
||||
const emojis = useGuildEmojiDirectory();
|
||||
|
||||
return (
|
||||
<div className={`whitespace-pre-wrap break-words ${className}`}>
|
||||
@@ -215,6 +241,7 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess
|
||||
block.lines.map((line) => line.text.slice(QUOTE_PREFIX.length)).join('\n'),
|
||||
resolveMention,
|
||||
channels,
|
||||
emojis,
|
||||
key,
|
||||
)}
|
||||
</blockquote>
|
||||
@@ -226,6 +253,7 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess
|
||||
block.lines.map((line) => line.text).join('\n'),
|
||||
resolveMention,
|
||||
channels,
|
||||
emojis,
|
||||
key,
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -9,19 +9,25 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
import { MESSAGE_MAX_LENGTH, messageTypeOf, type Message, type MessageType } from '@/api/messages';
|
||||
import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage';
|
||||
import { EmojiPicker } from '@/components/chat/EmojiPicker';
|
||||
import { MessageContent } from '@/components/chat/MessageContent';
|
||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { useGuildEmojiDirectory } from '@/components/chat/useGuildEmojis';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||
import { QUICK_REACTIONS, copyText, formatMessageDateTime, formatMessageTime } from '@/lib/chat';
|
||||
import { customEmojiShortcode } from '@/lib/customEmoji';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { parseSystemMessage } from '@/lib/systemMessage';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
/** Сервер комнаты: из него пикер берёт кастомные эмодзи. */
|
||||
guildId?: string | null;
|
||||
authors: AuthorDirectory;
|
||||
currentUserId: string | null;
|
||||
canManageMessages: boolean;
|
||||
@@ -140,6 +146,62 @@ function MessageBody({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Системное сообщение: сервер присылает JSON о событии, лента показывает его
|
||||
* человекочитаемой строкой через i18n. Имя участника резолвит тот же
|
||||
* справочник авторов; если пользователь неизвестен (вышел, удалён) —
|
||||
* подставляем «участник». Неизвестное событие и мусорный JSON показываем как
|
||||
* есть: ничего не теряем и не падаем.
|
||||
*/
|
||||
function SystemMessageBody({
|
||||
message,
|
||||
authors,
|
||||
}: {
|
||||
message: Message;
|
||||
authors: AuthorDirectory;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const event = parseSystemMessage(message.content);
|
||||
|
||||
if (event === null) {
|
||||
return (
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
resolveMention={authors.resolveMention}
|
||||
className="text-xs text-fg-muted"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const nameOf = (userId: string): string =>
|
||||
authors.resolveMemberName?.(userId) ?? t('chat.system.member');
|
||||
|
||||
if (event.kind === 'member_join') {
|
||||
return (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('chat.system.memberJoin', { name: nameOf(event.userId) })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (event.kind === 'member_kick') {
|
||||
return (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('chat.system.memberKick', { name: nameOf(event.userId) })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t(
|
||||
event.channelType === 'voice'
|
||||
? 'chat.system.voiceChannelCreated'
|
||||
: 'chat.system.channelCreated',
|
||||
{ name: event.name },
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сообщение ленты: текст, вложения, реакции, ответы и действия.
|
||||
*
|
||||
@@ -150,6 +212,7 @@ export function MessageItem({
|
||||
message,
|
||||
channelId,
|
||||
channelName,
|
||||
guildId = null,
|
||||
authors,
|
||||
currentUserId,
|
||||
canManageMessages,
|
||||
@@ -162,6 +225,8 @@ export function MessageItem({
|
||||
onJumpToReply,
|
||||
}: MessageItemProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
// Кастомные эмодзи: реакции и пикер показывают их картинкой.
|
||||
const emojis = useGuildEmojiDirectory();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -281,11 +346,7 @@ export function MessageItem({
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<SystemMessageBody message={message} authors={authors} />
|
||||
)}
|
||||
<time dateTime={message.created_at} className="text-[10px] text-fg-muted/70">
|
||||
{formatMessageTime(message.created_at, i18n.language)}
|
||||
@@ -408,27 +469,36 @@ export function MessageItem({
|
||||
|
||||
{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>
|
||||
))}
|
||||
{message.reactions.map((reaction) => {
|
||||
// Кастомный эмодзи едет токеном `<:имя:file_id>`: показываем
|
||||
// картинку, а в подписи — читаемое `:имя:`.
|
||||
const custom = emojis.resolve(reaction.emoji);
|
||||
return (
|
||||
<li key={reaction.emoji}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={reaction.me}
|
||||
aria-label={t('chat.reactions.toggle', {
|
||||
emoji: custom === null ? reaction.emoji : customEmojiShortcode(custom.name),
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{custom === null ? (
|
||||
<span aria-hidden="true">{reaction.emoji}</span>
|
||||
) : (
|
||||
<CustomEmojiImage emoji={custom} className="h-4 w-4" />
|
||||
)}
|
||||
<span>{reaction.count}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -506,6 +576,7 @@ export function MessageItem({
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onPick={react}
|
||||
placement={dropUp ? 'top' : 'bottom'}
|
||||
guildId={guildId}
|
||||
/>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
interface MessageListProps {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
/** Сервер комнаты: пикер реакций берёт из него кастомные эмодзи. */
|
||||
guildId?: string | null;
|
||||
/** Личная беседа: тексты без «#» и без упоминания комнаты. */
|
||||
direct?: boolean;
|
||||
/** Лента с оптимистичными сообщениями в конце. */
|
||||
@@ -50,6 +52,7 @@ const STICK_THRESHOLD_PX = 120;
|
||||
export function MessageList({
|
||||
channelId,
|
||||
channelName,
|
||||
guildId = null,
|
||||
direct = false,
|
||||
messages,
|
||||
status,
|
||||
@@ -228,6 +231,7 @@ export function MessageList({
|
||||
message={message}
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
guildId={guildId}
|
||||
authors={authors}
|
||||
currentUserId={currentUserId}
|
||||
canManageMessages={canManageMessages}
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface ChatAuthor {
|
||||
export interface AuthorDirectory {
|
||||
resolve: (userId: string | undefined) => ChatAuthor;
|
||||
resolveMention: (userId: string) => { name: string; isMe: boolean };
|
||||
/**
|
||||
* Имя участника или `null`, если он неизвестен (например, вышел с сервера
|
||||
* и `GET /users/{id}` его не знает). Нужно системным сообщениям: они
|
||||
* показывают «участник», а не «неизвестный».
|
||||
*/
|
||||
resolveMemberName?: ((userId: string) => string | null) | undefined;
|
||||
/** Загружены ли участники: до этого момента имена могут быть неполными. */
|
||||
loaded: boolean;
|
||||
}
|
||||
@@ -161,6 +167,13 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
const author = resolve(userId);
|
||||
return { name: author.name, isMe: userId === currentUserId };
|
||||
},
|
||||
resolveMemberName: (userId) => {
|
||||
const member = memberMap.get(userId);
|
||||
if (member !== undefined) {
|
||||
return memberDisplayName(member);
|
||||
}
|
||||
return userNameMap.get(userId) ?? null;
|
||||
},
|
||||
loaded: membersLoaded,
|
||||
};
|
||||
}, [memberMap, rolesData, userNameMap, userAvatarMap, currentUserId, membersLoaded, t]);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fetchMembers, guildMembersQueryKey } from '@/api/guilds';
|
||||
import type { GuildEmoji } from '@/api/emojis';
|
||||
import type { Channel, GuildMember, UserSummary } from '@/api/types';
|
||||
import { fetchUser, userQueryKey } from '@/api/users';
|
||||
import {
|
||||
@@ -12,8 +13,10 @@ import {
|
||||
type AutocompleteItem,
|
||||
} from '@/lib/autocomplete';
|
||||
import { memberDisplayName } from '@/lib/chat';
|
||||
import { customEmojiShortcode } from '@/lib/customEmoji';
|
||||
import { searchEmoji } from '@/lib/emoji';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useGuildEmojis } from '@/components/chat/useGuildEmojis';
|
||||
|
||||
/** Сколько подсказок показываем: список должен помещаться над полем ввода. */
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
@@ -71,6 +74,33 @@ function recipientToCandidate(recipient: UserSummary | undefined): MentionCandid
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Подсказки кастомных эмодзи сервера: сначала точное совпадение по имени
|
||||
* (`:fire:` при вводе `:fire`), затем остальные по алфавиту. Вставка — токен
|
||||
* `<:имя:file_id>`: ровно то, что понимает сервер в тексте и реакции.
|
||||
*/
|
||||
function serverEmojiItems(
|
||||
emojis: readonly GuildEmoji[],
|
||||
query: string,
|
||||
animatedLabel: string,
|
||||
): AutocompleteItem[] {
|
||||
return emojis
|
||||
.filter((emoji) => emoji.name.toLowerCase().startsWith(query))
|
||||
.sort((left, right) => {
|
||||
const leftExact = left.name.toLowerCase() === query ? 0 : 1;
|
||||
const rightExact = right.name.toLowerCase() === query ? 0 : 1;
|
||||
return leftExact - rightExact || left.name.localeCompare(right.name);
|
||||
})
|
||||
.map((emoji) => ({
|
||||
id: `emoji:${emoji.id}`,
|
||||
kind: 'emoji' as const,
|
||||
insert: emoji.token,
|
||||
label: customEmojiShortcode(emoji.name),
|
||||
...(emoji.animated ? { hint: animatedLabel } : {}),
|
||||
emojiFileId: emoji.file_id,
|
||||
}));
|
||||
}
|
||||
|
||||
interface ComposerAutocompleteOptions {
|
||||
value: string;
|
||||
/** Позиция каретки в поле ввода. */
|
||||
@@ -121,6 +151,8 @@ export function useComposerAutocomplete(
|
||||
[guildId],
|
||||
),
|
||||
);
|
||||
// Кастомные эмодзи сервера: в личной беседе список пуст.
|
||||
const serverEmojis = useGuildEmojis(guildId);
|
||||
|
||||
const trigger = useMemo(() => detectAutocomplete(value, caret), [value, caret]);
|
||||
|
||||
@@ -176,13 +208,18 @@ export function useComposerAutocomplete(
|
||||
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}:`,
|
||||
}));
|
||||
// Порядок важен: эмодзи сервера идут первыми (точное совпадение по имени
|
||||
// выше остальных), затем дополняет юникод-словарь.
|
||||
items = [
|
||||
...serverEmojiItems(serverEmojis, query, t('chat.emoji.animated')),
|
||||
...searchEmoji(query, MAX_SUGGESTIONS).map((entry) => ({
|
||||
id: `emoji:${entry.code}`,
|
||||
kind: 'emoji' as const,
|
||||
insert: entry.emoji,
|
||||
label: entry.emoji,
|
||||
hint: `:${entry.code}:`,
|
||||
})),
|
||||
].slice(0, MAX_SUGGESTIONS);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
@@ -196,5 +233,5 @@ export function useComposerAutocomplete(
|
||||
labelKey: LABEL_KEYS[trigger.kind],
|
||||
items,
|
||||
};
|
||||
}, [trigger, guildId, guild, members.data, recipient.data, t]);
|
||||
}, [trigger, guildId, guild, members.data, recipient.data, serverEmojis, t]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { GuildEmoji } from '@/api/emojis';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Эмодзи сервера из снапшота (READY и `GUILD_EMOJIS_UPDATE`).
|
||||
*
|
||||
* Один источник данных для всех потребителей: лента и зеркало композера
|
||||
* рисуют токены `<:имя:file_id>`, пикер и автокомплит `:` подставляют их
|
||||
* в текст. Логику разбора токена держит `lib/customEmoji`, здесь — только
|
||||
* доступ к снапшоту.
|
||||
*/
|
||||
|
||||
/** Пустой список — стабильная ссылка: селектор не должен перерисовывать зря. */
|
||||
const NO_EMOJIS: GuildEmoji[] = [];
|
||||
|
||||
/** Эмодзи выбранного сервера: в личной беседе и без сервера их нет. */
|
||||
export function useGuildEmojis(guildId: string | null): GuildEmoji[] {
|
||||
return useSessionStore(
|
||||
useCallback(
|
||||
(state) =>
|
||||
guildId === null
|
||||
? NO_EMOJIS
|
||||
: (state.guilds.find((guild) => guild.id === guildId)?.emojis ?? NO_EMOJIS),
|
||||
[guildId],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Справочник эмодзи для рендера: токен → эмодзи. */
|
||||
export interface GuildEmojiDirectory {
|
||||
/** Эмодзи по токену `<:имя:file_id>`; `null` — файла нет в снапшоте. */
|
||||
resolve: (token: string) => GuildEmoji | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск эмодзи по токену среди всех серверов снапшота. Лента и зеркало не
|
||||
* знают, какому серверу принадлежит сообщение (в личной беседе сервера нет
|
||||
* вовсе), а `file_id` уникален на инстансе — поэтому индекс общий. Токен,
|
||||
* которого нет в снапшоте, остаётся текстом `:имя:`.
|
||||
*/
|
||||
export function useGuildEmojiDirectory(): GuildEmojiDirectory {
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
return useMemo(() => {
|
||||
const byToken = new Map<string, GuildEmoji>();
|
||||
for (const guild of guilds) {
|
||||
for (const emoji of guild.emojis) {
|
||||
byToken.set(emoji.token, emoji);
|
||||
}
|
||||
}
|
||||
return { resolve: (token: string) => byToken.get(token) ?? null };
|
||||
}, [guilds]);
|
||||
}
|
||||
Reference in New Issue
Block a user