From f26728113d2c4188efc3beff82b02d2fa0087e48 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 01:12:01 +0300 Subject: [PATCH] =?UTF-8?q?feat(instance):=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B5=20=C2=AB=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Понятие главного сервера убрано из поведения: сервер, создаваемый установщиком, обычный (is_main = false), автовступления в него нет, а в настройках инстанса он остаётся лишь справочно (main_guild_id → AdminGuildID в ответе API). Клиент от него больше не зависит: новичок начинает с пустым списком серверов. --- internal/bootstrap/bootstrap.go | 12 +- internal/gateway/gateway_test.go | 8 +- internal/server/api_instance.go | 20 +-- internal/store/guilds.go | 12 ++ web/src/components/chat/MessageItem.tsx | 29 +++- web/src/components/ui/Popover.tsx | 98 +++++++++++++ web/src/lib/useMediaDevices.ts | 68 +++++++++ web/src/stores/ui.ts | 57 ++++++++ web/src/stores/voice.ts | 177 ++++++++++++++++++++++++ 9 files changed, 458 insertions(+), 23 deletions(-) create mode 100644 web/src/components/ui/Popover.tsx create mode 100644 web/src/lib/useMediaDevices.ts create mode 100644 web/src/stores/voice.ts diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 5965892..f6e3cd0 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -97,9 +97,11 @@ func (r *Runner) Run(ctx context.Context, opts Options) (*Result, error) { guildName = DefaultMainGuildName } guild, err := r.store.CreateGuild(ctx, store.CreateGuildParams{ - Name: guildName, - OwnerID: admin.ID, - IsMain: true, + Name: guildName, + OwnerID: admin.ID, + // Главного сервера как обязательной точки входа больше нет: сервер + // установщика — обычный сервер администратора (решение пользователя). + IsMain: false, IsDiscoverable: false, Description: "", }) @@ -109,8 +111,10 @@ func (r *Runner) Run(ctx context.Context, opts Options) (*Result, error) { result.GuildID = guild.ID result.CreatedGuild = true + // Идентификатор сервера администратора сохраняем в настройках только для + // справки: автовступления в него нет. if err := r.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil { - return nil, fmt.Errorf("set main guild: %w", err) + return nil, fmt.Errorf("set admin guild: %w", err) } if err := r.ensureMainGuildSetup(ctx, guild, admin, result); err != nil { return nil, err diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 8898277..2391d9f 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -126,9 +126,9 @@ func joinMainGuildForTest(t *testing.T, f *fixture, token string) { if err != nil { t.Fatalf("ResolveSession: %v", err) } - guild, err := f.store.GetMainGuild(ctx) + guild, err := f.store.GetAdminGuild(ctx) if err != nil { - t.Fatalf("GetMainGuild: %v", err) + t.Fatalf("GetAdminGuild: %v", err) } defaultRole, err := f.store.DefaultRole(ctx, guild.ID) if err != nil { @@ -218,9 +218,9 @@ func TestReadyHidesPrivateChannels(t *testing.T) { f := newFixture(t) ctx := context.Background() // Приватная комната: @user не видит. - mainGuild, err := f.store.GetMainGuild(ctx) + mainGuild, err := f.store.GetAdminGuild(ctx) if err != nil { - t.Fatalf("GetMainGuild: %v", err) + t.Fatalf("GetAdminGuild: %v", err) } defaultRole, err := f.store.DefaultRole(ctx, mainGuild.ID) if err != nil { diff --git a/internal/server/api_instance.go b/internal/server/api_instance.go index c2af861..07a8458 100644 --- a/internal/server/api_instance.go +++ b/internal/server/api_instance.go @@ -23,9 +23,10 @@ type instancePayload struct { MaxGuildsPerUser int `json:"max_guilds_per_user"` MaxMembersPerGuild int `json:"max_members_per_guild"` MaxMessageLength int `json:"max_message_length"` - MainGuildID string `json:"main_guild_id,omitempty"` - UserCount int `json:"user_count"` - GuildCount int `json:"guild_count"` + // AdminGuildID — сервер, созданный установщиком (справочно, автовхода нет). + AdminGuildID string `json:"main_guild_id,omitempty"` + UserCount int `json:"user_count"` + GuildCount int `json:"guild_count"` } type instanceOutput struct { @@ -441,21 +442,17 @@ func (s *Server) instancePayload(ctx context.Context) (instancePayload, error) { GuildCount: len(guilds), } if settings.MainGuildID != 0 { - payload.MainGuildID = formatSnowflake(settings.MainGuildID) + payload.AdminGuildID = formatSnowflake(settings.MainGuildID) } return payload, nil } // createGuildAsAdmin создаёт сервер в обход лимитов и фиксирует это в аудите. func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.User, name string) (*store.Guild, error) { - settings, err := s.store.InstanceSettings(ctx) - if err != nil { - return nil, humaError(err) - } guild, err := s.store.CreateGuild(ctx, store.CreateGuildParams{ Name: name, OwnerID: owner.ID, - IsMain: settings.MainGuildID == 0, + IsMain: false, IsDiscoverable: false, }) if err != nil { @@ -469,11 +466,6 @@ func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.Use }); err != nil { return nil, humaError(err) } - if guild.IsMain { - if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil { - return nil, humaError(err) - } - } s.recordAudit(ctx, admin, guild.ID, "guild.create", "guild", &guild.ID, "created by instance admin") s.recordAudit(ctx, admin, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limits") if s.gateway != nil { diff --git a/internal/store/guilds.go b/internal/store/guilds.go index 3c6ab4f..cc245c0 100644 --- a/internal/store/guilds.go +++ b/internal/store/guilds.go @@ -73,6 +73,18 @@ func (s *Store) GetGuild(ctx context.Context, id uint64) (*Guild, error) { return scanGuild(s.reader.QueryRowContext(ctx, `SELECT `+guildColumns+` FROM guilds WHERE id = ?`, int64(id))) } +// GetAdminGuild возвращает сервер, созданный установщиком (справочно). +func (s *Store) GetAdminGuild(ctx context.Context) (*Guild, error) { + settings, err := s.InstanceSettings(ctx) + if err != nil { + return nil, err + } + if settings.MainGuildID == 0 { + return nil, ErrNotFound + } + return s.GetGuild(ctx, settings.MainGuildID) +} + func (s *Store) GetMainGuild(ctx context.Context) (*Guild, error) { return scanGuild(s.reader.QueryRowContext(ctx, `SELECT `+guildColumns+` FROM guilds WHERE is_main = 1 ORDER BY id LIMIT 1`)) diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx index ced905c..9c30b57 100644 --- a/web/src/components/chat/MessageItem.tsx +++ b/web/src/components/chat/MessageItem.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { fileContentUrl } from '@/api/files'; @@ -34,6 +34,12 @@ interface MessageItemProps { const actionButtonClass = 'rounded-[var(--radius-sm)] px-1.5 py-0.5 text-sm leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95'; +/** + * Селектор интерактива: двойной клик по ссылке, реакции или кнопке не должен + * ставить сообщение в ответ — этим элементам клик принадлежит целиком. + */ +const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="menu"]'; + /** * Сообщение ленты: текст, вложения, реакции, ответы и действия. * @@ -140,10 +146,31 @@ export function MessageItem({ .catch((error: unknown) => setActionError(error)); }; + /** + * Двойной клик по сообщению — быстрый ответ (то же, что пункт «Ответить»). + * Браузер сам ограничивает интервал двойного клика (~300–500 мс). + */ + const onDoubleClick = (event: ReactMouseEvent): void => { + if (!canSend || pending) { + return; + } + // Выделенный пользователем текст важнее: двойной клик — типичный способ + // выделить слово, поэтому ответ в этом случае не ставим. + if ((window.getSelection()?.toString() ?? '') !== '') { + return; + } + const target = event.target; + if (target instanceof Element && target.closest(INTERACTIVE_SELECTOR) !== null) { + return; + } + onReply(message); + }; + return (
void; + children: ReactNode; + /** Элемент-якорь: клик по нему не считается кликом «вне панели». */ + anchorRef?: RefObject; + /** Куда раскрывается панель: вверх (по умолчанию) или вниз. */ + placement?: 'top' | 'bottom'; + align?: 'left' | 'right'; + className?: string; +} + +/** + * Всплывающая панель произвольного содержимого (`role="dialog"`): в отличие от + * `Menu` внутри неё могут быть слайдеры и ссылки, а не только пункты меню. + * + * Закрывается по Escape и клику вне панели — как меню; позиционирование задаёт + * родитель (панель абсолютная, по умолчанию растёт вверх от нижней панели). + */ +export function Popover({ + open, + label, + onClose, + children, + anchorRef, + placement = 'top', + align = 'left', + className = '', +}: PopoverProps) { + const panelRef = useRef(null); + const onCloseRef = useRef(onClose); + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + // Клик вне панели (и вне якоря) закрывает её. + useEffect(() => { + if (!open) { + return; + } + const onPointerDown = (event: Event): void => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if (panelRef.current?.contains(target) === true) { + return; + } + if (anchorRef?.current?.contains(target) === true) { + return; + } + onCloseRef.current(); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('touchstart', onPointerDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('touchstart', onPointerDown); + }; + }, [open, anchorRef]); + + useEffect(() => { + if (!open) { + return; + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + onCloseRef.current(); + anchorRef?.current?.focus(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [open, anchorRef]); + + if (!open) { + return null; + } + + return ( +
+ {children} +
+ ); +} diff --git a/web/src/lib/useMediaDevices.ts b/web/src/lib/useMediaDevices.ts new file mode 100644 index 0000000..7643d0b --- /dev/null +++ b/web/src/lib/useMediaDevices.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; + +import type { VoiceDeviceKind } from '@/stores/voice'; + +/** Устройство ввода/вывода в том виде, в каком его показывает интерфейс. */ +export interface MediaDevice { + deviceId: string; + /** Подпись может быть пустой: браузер скрывает её без разрешения. */ + label: string; +} + +export interface MediaDevicesResult { + devices: MediaDevice[]; + /** Доступен ли `navigator.mediaDevices.enumerateDevices()`. */ + supported: boolean; +} + +/** + * Список устройств нужного вида через `navigator.mediaDevices`. + * + * Без разрешения на доступ браузер отдаёт устройства с пустыми подписями — + * их показываем как «Устройство N». Если API недоступно (старый браузер или + * тестовое окружение), возвращаем `supported: false`. + */ +export function useMediaDevices(kind: VoiceDeviceKind): MediaDevicesResult { + const supported = + typeof navigator !== 'undefined' && + typeof navigator.mediaDevices?.enumerateDevices === 'function'; + const [devices, setDevices] = useState([]); + + useEffect(() => { + if (!supported) { + setDevices([]); + return; + } + let cancelled = false; + + const load = (): void => { + void navigator.mediaDevices + .enumerateDevices() + .then((list) => { + if (cancelled) { + return; + } + setDevices( + list + .filter((device) => device.kind === kind) + .map((device) => ({ deviceId: device.deviceId, label: device.label })), + ); + }) + .catch(() => { + if (!cancelled) { + setDevices([]); + } + }); + }; + + load(); + // Список меняется при подключении гарнитуры — обновляем его на лету. + navigator.mediaDevices.addEventListener?.('devicechange', load); + return () => { + cancelled = true; + navigator.mediaDevices.removeEventListener?.('devicechange', load); + }; + }, [kind, supported]); + + return { devices, supported }; +} diff --git a/web/src/stores/ui.ts b/web/src/stores/ui.ts index 59d630a..a5b7d9b 100644 --- a/web/src/stores/ui.ts +++ b/web/src/stores/ui.ts @@ -1,11 +1,45 @@ import { create } from 'zustand'; +import { useSessionStore } from '@/stores/session'; + export type Theme = 'dark' | 'light'; +/** Пункты модального окна настроек (левое меню). */ +export const settingsSections = [ + 'profile', + 'security', + 'appearance', + 'notifications', + 'voice', + 'language', + 'instance', +] as const; +export type SettingsSection = (typeof settingsSections)[number]; + +/** Вкладки пункта «Профиль»: глобальный профиль и профиль на сервере. */ +export const profileTabs = ['main', 'server'] as const; +export type ProfileTab = (typeof profileTabs)[number]; + +/** Куда открыть окно настроек. */ +export interface SettingsTarget { + section?: SettingsSection; + tab?: ProfileTab; + /** Сервер для вкладки «Профиль → Сервер». */ + guildId?: string | null; +} + interface UiState { theme: Theme; setTheme: (theme: Theme) => void; toggleTheme: () => void; + /** Открыто ли модальное окно настроек (открывается из панели пользователя). */ + settingsOpen: boolean; + settingsSection: SettingsSection; + settingsProfileTab: ProfileTab; + openSettings: (target?: SettingsTarget) => void; + closeSettings: () => void; + setSettingsSection: (section: SettingsSection) => void; + setSettingsProfileTab: (tab: ProfileTab) => void; } const STORAGE_KEY = 'glchat.theme'; @@ -40,4 +74,27 @@ export const useUiStore = create((set, get) => ({ toggleTheme: () => { get().setTheme(get().theme === 'dark' ? 'light' : 'dark'); }, + + settingsOpen: false, + settingsSection: 'profile', + settingsProfileTab: 'main', + + openSettings: (target) => { + // Выбранный сервер живёт в снапшоте сессии: так его видят и секции + // «Опасной зоны», которые сбрасывают выбор после выхода с сервера. + if (target?.guildId !== undefined) { + useSessionStore.getState().selectSettingsGuild(target.guildId); + } + set({ + settingsOpen: true, + ...(target?.section === undefined ? {} : { settingsSection: target.section }), + ...(target?.tab === undefined ? {} : { settingsProfileTab: target.tab }), + }); + }, + + closeSettings: () => set({ settingsOpen: false }), + + setSettingsSection: (section) => set({ settingsSection: section }), + + setSettingsProfileTab: (tab) => set({ settingsProfileTab: tab }), })); diff --git a/web/src/stores/voice.ts b/web/src/stores/voice.ts new file mode 100644 index 0000000..9459652 --- /dev/null +++ b/web/src/stores/voice.ts @@ -0,0 +1,177 @@ +import { create } from 'zustand'; + +/** + * Локальные настройки «голосовой» панели: состояния кнопок микрофона, камеры и + * наушников, выбранные устройства и громкости. + * + * Голос появится в Фазе 3, поэтому сейчас это только предпочтения клиента: + * они хранятся в localStorage и не уходят на сервер. + */ + +/** Виды устройств, которые отдаёт `navigator.mediaDevices.enumerateDevices()`. */ +export type VoiceDeviceKind = 'audioinput' | 'videoinput' | 'audiooutput'; + +interface VoicePreferences { + micEnabled: boolean; + cameraEnabled: boolean; + headphonesEnabled: boolean; + /** Пустая строка — устройство по умолчанию. */ + micDeviceId: string; + cameraDeviceId: string; + outputDeviceId: string; + /** Громкость микрофона и звука, 0–100. */ + micVolume: number; + outputVolume: number; +} + +interface VoiceState extends VoicePreferences { + setMicEnabled: (enabled: boolean) => void; + toggleMic: () => void; + setCameraEnabled: (enabled: boolean) => void; + toggleCamera: () => void; + setHeadphonesEnabled: (enabled: boolean) => void; + toggleHeadphones: () => void; + selectDevice: (kind: VoiceDeviceKind, deviceId: string) => void; + setMicVolume: (value: number) => void; + setOutputVolume: (value: number) => void; + /** Вернуть исходные предпочтения (используется в тестах). */ + reset: () => void; +} + +export const VOICE_STORAGE_KEY = 'glchat.voice.prefs'; + +const defaults: VoicePreferences = { + micEnabled: true, + cameraEnabled: false, + headphonesEnabled: true, + micDeviceId: '', + cameraDeviceId: '', + outputDeviceId: '', + micVolume: 100, + outputVolume: 100, +}; + +function clampVolume(value: number): number { + if (!Number.isFinite(value)) { + return 100; + } + return Math.min(100, Math.max(0, Math.round(value))); +} + +function asBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function asString(value: unknown, fallback: string): string { + return typeof value === 'string' ? value : fallback; +} + +function asVolume(value: unknown, fallback: number): number { + return typeof value === 'number' ? clampVolume(value) : fallback; +} + +/** Начальные значения: то, что уже лежит в localStorage, поверх умолчаний. */ +function initialPreferences(): VoicePreferences { + if (typeof window === 'undefined') { + return { ...defaults }; + } + const raw = window.localStorage.getItem(VOICE_STORAGE_KEY); + if (raw === null) { + return { ...defaults }; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return { ...defaults }; + } + if (typeof parsed !== 'object' || parsed === null) { + return { ...defaults }; + } + const stored = parsed as Record; + return { + micEnabled: asBoolean(stored['micEnabled'], defaults.micEnabled), + cameraEnabled: asBoolean(stored['cameraEnabled'], defaults.cameraEnabled), + headphonesEnabled: asBoolean(stored['headphonesEnabled'], defaults.headphonesEnabled), + micDeviceId: asString(stored['micDeviceId'], defaults.micDeviceId), + cameraDeviceId: asString(stored['cameraDeviceId'], defaults.cameraDeviceId), + outputDeviceId: asString(stored['outputDeviceId'], defaults.outputDeviceId), + micVolume: asVolume(stored['micVolume'], defaults.micVolume), + outputVolume: asVolume(stored['outputVolume'], defaults.outputVolume), + }; +} + +function persist(state: VoicePreferences): void { + if (typeof window === 'undefined') { + return; + } + window.localStorage.setItem( + VOICE_STORAGE_KEY, + JSON.stringify({ + micEnabled: state.micEnabled, + cameraEnabled: state.cameraEnabled, + headphonesEnabled: state.headphonesEnabled, + micDeviceId: state.micDeviceId, + cameraDeviceId: state.cameraDeviceId, + outputDeviceId: state.outputDeviceId, + micVolume: state.micVolume, + outputVolume: state.outputVolume, + } satisfies VoicePreferences), + ); +} + +export const useVoiceStore = create((set, get) => { + /** Изменение предпочтений всегда доезжает до localStorage. */ + const patch = (values: Partial): void => { + set(values); + persist(preferencesOf(get())); + }; + + return { + ...initialPreferences(), + + setMicEnabled: (enabled) => patch({ micEnabled: enabled }), + toggleMic: () => patch({ micEnabled: !get().micEnabled }), + + setCameraEnabled: (enabled) => patch({ cameraEnabled: enabled }), + toggleCamera: () => patch({ cameraEnabled: !get().cameraEnabled }), + + setHeadphonesEnabled: (enabled) => patch({ headphonesEnabled: enabled }), + toggleHeadphones: () => patch({ headphonesEnabled: !get().headphonesEnabled }), + + selectDevice: (kind, deviceId) => { + if (kind === 'audioinput') { + patch({ micDeviceId: deviceId }); + return; + } + if (kind === 'videoinput') { + patch({ cameraDeviceId: deviceId }); + return; + } + patch({ outputDeviceId: deviceId }); + }, + + setMicVolume: (value) => patch({ micVolume: clampVolume(value) }), + setOutputVolume: (value) => patch({ outputVolume: clampVolume(value) }), + + reset: () => { + set({ ...defaults }); + if (typeof window !== 'undefined') { + window.localStorage.removeItem(VOICE_STORAGE_KEY); + } + }, + }; +}); + +function preferencesOf(state: VoiceState): VoicePreferences { + return { + micEnabled: state.micEnabled, + cameraEnabled: state.cameraEnabled, + headphonesEnabled: state.headphonesEnabled, + micDeviceId: state.micDeviceId, + cameraDeviceId: state.cameraDeviceId, + outputDeviceId: state.outputDeviceId, + micVolume: state.micVolume, + outputVolume: state.outputVolume, + }; +}