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
@@ -0,0 +1,360 @@
import { useRef, useState, type DragEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
EMOJI_LIMIT_PER_GUILD,
EMOJI_MAX_BYTES,
deleteGuildEmoji,
fetchGuildEmojis,
guildEmojisQueryKey,
isAllowedEmojiType,
isValidEmojiName,
renameGuildEmoji,
uploadGuildEmoji,
type GuildEmoji,
} from '@/api/emojis';
import { fileContentUrl } from '@/api/files';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Badge, Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { customEmojiShortcode } from '@/lib/customEmoji';
import { formatBytes } from '@/lib/format';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
interface GuildEmojisSectionProps {
guildId: string;
}
/**
* Локальная ошибка проверки файла: текст показываем сразу, на сервер не ходим.
* Ключ перевода — как у серверных ошибок (`errors.<code>`), кроме имени.
*/
type LocalError = { kind: 'file.too_large' | 'emoji.invalid_type' | 'emoji.invalid_name' };
/**
* «Эмодзи»: загрузка, переименование и удаление кастомных эмодзи сервера
* (AGENT.md 7.12, право MANAGE_EMOJIS).
*
* Список читается по REST (`GET /guilds/{id}/emojis`), а после операций
* инвалидируется: снапшот обновляет и событие шлюза `GUILD_EMOJIS_UPDATE`,
* и точечные правки в сторе сессии — лента и пикер видят эмодзи сразу.
*/
export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [file, setFile] = useState<File | null>(null);
const [localError, setLocalError] = useState<LocalError | null>(null);
const [dragging, setDragging] = useState(false);
const [renaming, setRenaming] = useState<GuildEmoji | null>(null);
const [renameValue, setRenameValue] = useState('');
const [removing, setRemoving] = useState<GuildEmoji | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const list = useQuery({
queryKey: guildEmojisQueryKey(guildId),
queryFn: ({ signal }) => fetchGuildEmojis(guildId, signal),
retry: 0,
});
const emojis = list.data ?? [];
const full = emojis.length >= EMOJI_LIMIT_PER_GUILD;
/** Инвалидация кэша: снапшот придёт событием шлюза либо правкой стора. */
const refresh = (): void => {
void queryClient.invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) });
};
const upload = useMutation({
mutationFn: (input: { name: string; file: File }) =>
uploadGuildEmoji(guildId, input.name, input.file),
onSuccess: (emoji) => {
if (emoji !== null) {
useSessionStore.getState().upsertGuildEmoji(guildId, emoji);
}
setName('');
setFile(null);
setLocalError(null);
refresh();
},
});
const rename = useMutation({
mutationFn: (input: { emojiId: string; name: string }) =>
renameGuildEmoji(guildId, input.emojiId, input.name),
onSuccess: (emoji) => {
if (emoji !== null) {
useSessionStore.getState().upsertGuildEmoji(guildId, emoji);
}
setRenaming(null);
refresh();
},
});
const remove = useMutation({
mutationFn: (emojiId: string) => deleteGuildEmoji(guildId, emojiId),
onSuccess: (_result, emojiId) => {
useSessionStore.getState().removeGuildEmoji(guildId, emojiId);
setRemoving(null);
refresh();
},
});
/** Проверки на клиенте: формат, размер и имя — до запроса на сервер. */
const pickFile = (next: File | undefined): void => {
if (next === undefined) {
return;
}
upload.reset();
if (!isAllowedEmojiType(next)) {
setFile(null);
setLocalError({ kind: 'emoji.invalid_type' });
return;
}
if (next.size > EMOJI_MAX_BYTES) {
setFile(null);
setLocalError({ kind: 'file.too_large' });
return;
}
setLocalError(null);
setFile(next);
};
const submit = (): void => {
if (file === null) {
setLocalError({ kind: 'emoji.invalid_type' });
return;
}
if (!isValidEmojiName(name)) {
setLocalError({ kind: 'emoji.invalid_name' });
return;
}
setLocalError(null);
upload.mutate({ name, file });
};
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
event.preventDefault();
setDragging(false);
pickFile(event.dataTransfer.files[0]);
};
return (
<SettingsSection
title={t('settings.servers.emojis.title')}
description={t('settings.servers.emojis.description', { limit: EMOJI_LIMIT_PER_GUILD })}
>
<p className="mt-3 text-sm text-fg-muted" data-testid="guild-emojis-counter">
{t('settings.servers.emojis.counter', {
count: emojis.length,
limit: EMOJI_LIMIT_PER_GUILD,
})}
</p>
{list.isPending ? (
<SkeletonLines className="mt-4" rows={3} />
) : list.isError ? (
<ErrorNotice className="mt-4" error={list.error} onRetry={() => void list.refetch()} />
) : emojis.length === 0 ? (
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.emojis.empty')}</p>
) : (
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="guild-emojis">
{emojis.map((emoji) => (
<li
key={emoji.id}
data-testid={`guild-emoji-${emoji.id}`}
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
>
<img
src={fileContentUrl(emoji.file_id)}
alt={customEmojiShortcode(emoji.name)}
loading="lazy"
className="h-8 w-8 shrink-0"
/>
<span className="min-w-0 flex-1 truncate text-sm">
{customEmojiShortcode(emoji.name)}
</span>
{emoji.size_bytes === undefined ? null : (
<span className="text-xs text-fg-muted">
{formatBytes(emoji.size_bytes, i18n.language)}
</span>
)}
{emoji.animated ? (
<Badge>{t('settings.servers.emojis.animated')}</Badge>
) : null}
<Button
variant="ghost"
aria-label={t('settings.servers.emojis.renameLabel', {
name: customEmojiShortcode(emoji.name),
})}
onClick={() => {
rename.reset();
setRenameValue(emoji.name);
setRenaming(emoji);
}}
>
{t('settings.servers.emojis.rename')}
</Button>
<Button
variant="ghost"
className="border-danger/50 text-danger"
aria-label={t('settings.servers.emojis.deleteLabel', {
name: customEmojiShortcode(emoji.name),
})}
onClick={() => {
remove.reset();
setRemoving(emoji);
}}
>
{t('settings.servers.emojis.delete')}
</Button>
</li>
))}
</ul>
)}
{remove.isError ? <ErrorNotice className="mt-3" error={remove.error} /> : null}
<div className="mt-5 flex flex-col gap-3 border-t border-border/40 pt-4">
<h3 className="text-sm font-semibold">{t('settings.servers.emojis.uploadTitle')}</h3>
<p className="text-xs text-fg-muted">
{t('settings.servers.emojis.uploadHint', {
limit: formatBytes(EMOJI_MAX_BYTES, i18n.language),
})}
</p>
{full ? <p className="text-xs text-warning">{t('errors.emojis.limit_reached')}</p> : null}
<Field
label={t('settings.servers.emojis.name')}
name="emoji_name"
value={name}
maxLength={32}
placeholder={t('settings.servers.emojis.namePlaceholder')}
hint={t('settings.servers.emojis.nameHint')}
onChange={(event) => setName(event.target.value)}
/>
<div
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={`flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] border border-dashed px-3 py-2 text-sm transition-colors duration-[var(--duration-fast)] ${
dragging ? 'border-accent bg-accent/10' : 'border-border/60 bg-surface-2/40'
}`}
data-testid="emoji-dropzone"
>
<span className="text-fg-muted">{t('settings.servers.emojis.dropHint')}</span>
<input
ref={fileInputRef}
type="file"
hidden
accept="image/png,image/jpeg,image/webp,image/gif"
aria-label={t('settings.servers.emojis.file')}
onChange={(event) => {
pickFile(event.target.files?.[0]);
event.target.value = '';
}}
/>
<Button variant="ghost" onClick={() => fileInputRef.current?.click()}>
{t('settings.servers.emojis.chooseFile')}
</Button>
{file === null ? null : (
<span className="text-xs text-fg-muted">
{t('settings.servers.emojis.fileSelected', {
name: file.name,
size: formatBytes(file.size, i18n.language),
})}
</span>
)}
</div>
{localError === null ? null : (
<p role="alert" className="text-xs text-danger">
{t(`errors.${localError.kind}`)}
</p>
)}
{upload.isError ? <ErrorNotice error={upload.error} /> : null}
<div className="flex justify-end">
<Button
disabled={upload.isPending || file === null || name.trim() === '' || full}
onClick={submit}
>
{t(upload.isPending ? 'settings.servers.emojis.uploading' : 'settings.servers.emojis.upload')}
</Button>
</div>
</div>
<Modal
open={renaming !== null}
title={t('settings.servers.emojis.renameTitle')}
onClose={() => setRenaming(null)}
footer={
<>
<Button variant="ghost" onClick={() => setRenaming(null)}>
{t('common.cancel')}
</Button>
<Button
disabled={rename.isPending || !isValidEmojiName(renameValue.trim())}
onClick={() => {
if (renaming !== null) {
rename.mutate({ emojiId: renaming.id, name: renameValue.trim() });
}
}}
>
{t(rename.isPending ? 'common.saving' : 'common.save')}
</Button>
</>
}
>
<Field
label={t('settings.servers.emojis.name')}
name="emoji_rename"
value={renameValue}
maxLength={32}
hint={t('settings.servers.emojis.nameHint')}
onChange={(event) => setRenameValue(event.target.value)}
/>
{rename.isError ? <ErrorNotice className="mt-3" error={rename.error} /> : null}
</Modal>
<Modal
open={removing !== null}
title={t('settings.servers.emojis.deleteConfirmTitle')}
onClose={() => setRemoving(null)}
footer={
<>
<Button variant="ghost" onClick={() => setRemoving(null)}>
{t('common.cancel')}
</Button>
<Button
variant="ghost"
className="border-danger/50 text-danger"
disabled={remove.isPending}
onClick={() => {
if (removing !== null) {
remove.mutate(removing.id);
}
}}
>
{t('settings.servers.emojis.deleteConfirm')}
</Button>
</>
}
>
<p className="text-sm text-fg-muted">
{t('settings.servers.emojis.deleteConfirmBody', {
name: removing === null ? '' : customEmojiShortcode(removing.name),
})}
</p>
</Modal>
</SettingsSection>
);
}
+15 -2
View File
@@ -7,10 +7,11 @@ import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { canAdministerGuild } from '@/lib/identity';
import { canAdministerGuild, canManageEmojis } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection';
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
import { GuildPicker } from '@/pages/settings/GuildPicker';
@@ -18,7 +19,7 @@ import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
/**
* Пункт «Сервер» окна настроек: администрирование одного сервера —
* общее (имя и описание), участники, роли и «Опасная зона».
* общее (имя и описание), участники, роли, эмодзи и «Опасная зона».
*
* В отличие от вкладки «Профиль → Сервер» (там только свой никнейм), сюда
* попадают через шестерёнку в шапке сервера или через пункт меню настроек.
@@ -140,6 +141,15 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
currentUser.data?.is_instance_admin === true,
);
// «Эмодзи» сервер разрешает владельцу, админу инстанса, ADMINISTRATOR
// и MANAGE_EMOJIS: остальным секция не показывается.
const canManageEmojiSet = canManageEmojis(
guild.my_permissions,
guild.owner_id,
currentUser.data?.id,
currentUser.data?.is_instance_admin === true,
);
return (
<>
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
@@ -150,6 +160,9 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
onRetry={() => void members.refetch()}
/>
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
{canManageEmojiSet ? (
<GuildEmojisSection key={`${guild.id}-emojis`} guildId={guild.id} />
) : null}
<GuildDangerSection
key={`${guild.id}-danger`}
guildId={guild.id}