fix(install): публичный адрес LiveKit вычисляется после TLS_ENABLED

Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL
стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS,
dry-run проходит.
This commit is contained in:
2026-09-20 18:13:04 +03:00
parent ecd52c11d3
commit ca34d05377
33 changed files with 2505 additions and 71 deletions
+11 -1
View File
@@ -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}
+42
View File
@@ -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">
+51 -3
View File
@@ -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}`}
/>
);
}
+80 -2
View File
@@ -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>
+97 -26
View File
@@ -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}
+4
View File
@@ -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]);
}
+54
View File
@@ -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]);
}