feat(instance): сервер установщика больше не «главный»
Понятие главного сервера убрано из поведения: сервер, создаваемый установщиком, обычный (is_main = false), автовступления в него нет, а в настройках инстанса он остаётся лишь справочно (main_guild_id → AdminGuildID в ответе API). Клиент от него больше не зависит: новичок начинает с пустым списком серверов.
This commit is contained in:
@@ -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<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 (
|
||||
<article
|
||||
data-message-id={message.id}
|
||||
data-testid={`message-${message.id}`}
|
||||
onDoubleClick={onDoubleClick}
|
||||
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
|
||||
pending ? 'opacity-70' : ''
|
||||
} ${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 { 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<UiState>((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 }),
|
||||
}));
|
||||
|
||||
@@ -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