feat(instance): сервер установщика больше не «главный»
Понятие главного сервера убрано из поведения: сервер, создаваемый установщиком, обычный (is_main = false), автовступления в него нет, а в настройках инстанса он остаётся лишь справочно (main_guild_id → AdminGuildID в ответе API). Клиент от него больше не зависит: новичок начинает с пустым списком серверов.
This commit is contained in:
@@ -99,7 +99,9 @@ func (r *Runner) Run(ctx context.Context, opts Options) (*Result, error) {
|
|||||||
guild, err := r.store.CreateGuild(ctx, store.CreateGuildParams{
|
guild, err := r.store.CreateGuild(ctx, store.CreateGuildParams{
|
||||||
Name: guildName,
|
Name: guildName,
|
||||||
OwnerID: admin.ID,
|
OwnerID: admin.ID,
|
||||||
IsMain: true,
|
// Главного сервера как обязательной точки входа больше нет: сервер
|
||||||
|
// установщика — обычный сервер администратора (решение пользователя).
|
||||||
|
IsMain: false,
|
||||||
IsDiscoverable: false,
|
IsDiscoverable: false,
|
||||||
Description: "",
|
Description: "",
|
||||||
})
|
})
|
||||||
@@ -109,8 +111,10 @@ func (r *Runner) Run(ctx context.Context, opts Options) (*Result, error) {
|
|||||||
result.GuildID = guild.ID
|
result.GuildID = guild.ID
|
||||||
result.CreatedGuild = true
|
result.CreatedGuild = true
|
||||||
|
|
||||||
|
// Идентификатор сервера администратора сохраняем в настройках только для
|
||||||
|
// справки: автовступления в него нет.
|
||||||
if err := r.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
|
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 {
|
if err := r.ensureMainGuildSetup(ctx, guild, admin, result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -126,9 +126,9 @@ func joinMainGuildForTest(t *testing.T, f *fixture, token string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveSession: %v", err)
|
t.Fatalf("ResolveSession: %v", err)
|
||||||
}
|
}
|
||||||
guild, err := f.store.GetMainGuild(ctx)
|
guild, err := f.store.GetAdminGuild(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetMainGuild: %v", err)
|
t.Fatalf("GetAdminGuild: %v", err)
|
||||||
}
|
}
|
||||||
defaultRole, err := f.store.DefaultRole(ctx, guild.ID)
|
defaultRole, err := f.store.DefaultRole(ctx, guild.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -218,9 +218,9 @@ func TestReadyHidesPrivateChannels(t *testing.T) {
|
|||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
// Приватная комната: @user не видит.
|
// Приватная комната: @user не видит.
|
||||||
mainGuild, err := f.store.GetMainGuild(ctx)
|
mainGuild, err := f.store.GetAdminGuild(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetMainGuild: %v", err)
|
t.Fatalf("GetAdminGuild: %v", err)
|
||||||
}
|
}
|
||||||
defaultRole, err := f.store.DefaultRole(ctx, mainGuild.ID)
|
defaultRole, err := f.store.DefaultRole(ctx, mainGuild.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ type instancePayload struct {
|
|||||||
MaxGuildsPerUser int `json:"max_guilds_per_user"`
|
MaxGuildsPerUser int `json:"max_guilds_per_user"`
|
||||||
MaxMembersPerGuild int `json:"max_members_per_guild"`
|
MaxMembersPerGuild int `json:"max_members_per_guild"`
|
||||||
MaxMessageLength int `json:"max_message_length"`
|
MaxMessageLength int `json:"max_message_length"`
|
||||||
MainGuildID string `json:"main_guild_id,omitempty"`
|
// AdminGuildID — сервер, созданный установщиком (справочно, автовхода нет).
|
||||||
|
AdminGuildID string `json:"main_guild_id,omitempty"`
|
||||||
UserCount int `json:"user_count"`
|
UserCount int `json:"user_count"`
|
||||||
GuildCount int `json:"guild_count"`
|
GuildCount int `json:"guild_count"`
|
||||||
}
|
}
|
||||||
@@ -441,21 +442,17 @@ func (s *Server) instancePayload(ctx context.Context) (instancePayload, error) {
|
|||||||
GuildCount: len(guilds),
|
GuildCount: len(guilds),
|
||||||
}
|
}
|
||||||
if settings.MainGuildID != 0 {
|
if settings.MainGuildID != 0 {
|
||||||
payload.MainGuildID = formatSnowflake(settings.MainGuildID)
|
payload.AdminGuildID = formatSnowflake(settings.MainGuildID)
|
||||||
}
|
}
|
||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createGuildAsAdmin создаёт сервер в обход лимитов и фиксирует это в аудите.
|
// createGuildAsAdmin создаёт сервер в обход лимитов и фиксирует это в аудите.
|
||||||
func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.User, name string) (*store.Guild, error) {
|
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{
|
guild, err := s.store.CreateGuild(ctx, store.CreateGuildParams{
|
||||||
Name: name,
|
Name: name,
|
||||||
OwnerID: owner.ID,
|
OwnerID: owner.ID,
|
||||||
IsMain: settings.MainGuildID == 0,
|
IsMain: false,
|
||||||
IsDiscoverable: false,
|
IsDiscoverable: false,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -469,11 +466,6 @@ func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.Use
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, humaError(err)
|
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, "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")
|
s.recordAudit(ctx, admin, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limits")
|
||||||
if s.gateway != nil {
|
if s.gateway != nil {
|
||||||
|
|||||||
@@ -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)))
|
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) {
|
func (s *Store) GetMainGuild(ctx context.Context) (*Guild, error) {
|
||||||
return scanGuild(s.reader.QueryRowContext(ctx,
|
return scanGuild(s.reader.QueryRowContext(ctx,
|
||||||
`SELECT `+guildColumns+` FROM guilds WHERE is_main = 1 ORDER BY id LIMIT 1`))
|
`SELECT `+guildColumns+` FROM guilds WHERE is_main = 1 ORDER BY id LIMIT 1`))
|
||||||
|
|||||||
@@ -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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { fileContentUrl } from '@/api/files';
|
import { fileContentUrl } from '@/api/files';
|
||||||
@@ -34,6 +34,12 @@ interface MessageItemProps {
|
|||||||
const actionButtonClass =
|
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';
|
'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));
|
.catch((error: unknown) => setActionError(error));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Двойной клик по сообщению — быстрый ответ (то же, что пункт «Ответить»).
|
||||||
|
* Браузер сам ограничивает интервал двойного клика (~300–500 мс).
|
||||||
|
*/
|
||||||
|
const onDoubleClick = (event: ReactMouseEvent<HTMLElement>): 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 (
|
return (
|
||||||
<article
|
<article
|
||||||
data-message-id={message.id}
|
data-message-id={message.id}
|
||||||
data-testid={`message-${message.id}`}
|
data-testid={`message-${message.id}`}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
|
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
|
||||||
pending ? 'opacity-70' : ''
|
pending ? 'opacity-70' : ''
|
||||||
} ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`}
|
} ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
||||||
|
|
||||||
|
interface PopoverProps {
|
||||||
|
open: boolean;
|
||||||
|
/** Доступное имя панели (озвучивается скринридером). */
|
||||||
|
label: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
/** Элемент-якорь: клик по нему не считается кликом «вне панели». */
|
||||||
|
anchorRef?: RefObject<HTMLElement | null>;
|
||||||
|
/** Куда раскрывается панель: вверх (по умолчанию) или вниз. */
|
||||||
|
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<HTMLDivElement | null>(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 (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={label}
|
||||||
|
className={`absolute z-40 w-64 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 shadow-[var(--shadow-3)] ${
|
||||||
|
placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2'
|
||||||
|
} ${align === 'right' ? 'right-0' : 'left-0'} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<MediaDevice[]>([]);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -1,11 +1,45 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
export type Theme = 'dark' | 'light';
|
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 {
|
interface UiState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
toggleTheme: () => 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';
|
const STORAGE_KEY = 'glchat.theme';
|
||||||
@@ -40,4 +74,27 @@ export const useUiStore = create<UiState>((set, get) => ({
|
|||||||
toggleTheme: () => {
|
toggleTheme: () => {
|
||||||
get().setTheme(get().theme === 'dark' ? 'light' : 'dark');
|
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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
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<VoiceState>((set, get) => {
|
||||||
|
/** Изменение предпочтений всегда доезжает до localStorage. */
|
||||||
|
const patch = (values: Partial<VoicePreferences>): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user