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
+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}