From 085454d555b6a7d76fc9bacc2aa016ab9ce4bfc1 Mon Sep 17 00:00:00 2001 From: grendervill Date: Tue, 22 Sep 2026 23:17:32 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BF=D0=B0=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=80=D0=B0=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=20Ctrl/Cmd+?= =?UTF-8?q?K=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20=D1=81=20=D0=BD=D0=B5=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=91=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два требования desktop-клиента (docs/client-tauri.md §5), которые живут в веб-слое — том же, что и в браузере: - **Палитра команд.** Ctrl/Cmd+K (на документе или сообщением `openPalette` от обёртки) открывает быстрый переход: серверы (в первую доступную комнату), комнаты выбранного сервера с группировкой по категориям, личные беседы из уже загруженного снапшота, действия (настройки профиля/безопасности/внешнего вида, настройки сервера, создание сервера, друзья, микрофон и «не слышу» при активной голосовой сессии). Новых запросов палитра не делает; поиск по подстроке без учёта регистра, 8 пунктов на раздел, стрелки со `scrollIntoView`, `role=listbox/option` и `aria-activedescendant`; - **Подтверждение выхода.** Формы с локальным черновиком регистрируются в общем реестре (`stores/unsaved.ts` + хук `useUnsavedChanges`): платформа получает один `setDirty` на переход «есть правки / нет правок», а на сообщение обёртки `closeRequested` клиент отвечает тряской контента и диалогом «Сохранить / Выйти без сохранения». При `prefers-reduced-motion: reduce` тряску заменяет подсветка рамки; в браузере вместо сообщения обёртки работает штатный `beforeunload`. Хук добавлен в формы с явной кнопкой «Сохранить»: профиль, общие настройки сервера, ник, эмодзи, звуки, оформление, вебхуки, лимиты инстанса, переименование сервера (автосохраняемые переключатели намеренно не помечаются); - Deep link `glchat://dm/` (личная беседа без сервера) — его присылает обёртка при клике по уведомлению о личном сообщении. Тесты: 525 passed (501 база + 24 новых) — палитра, реестр правок, диалог закрытия, `beforeunload`, разбор ссылок; `npm run check` и `npm run lint` зелёные. --- web/src/components/CommandPalette.tsx | 459 ++++++++++++++++++ web/src/components/DesktopBridge.tsx | 18 + web/src/components/DirtyCloseGuard.tsx | 125 +++++ web/src/components/RootShell.tsx | 23 + .../instance/InstanceGuildsPanel.tsx | 12 + .../instance/InstanceLimitsPanel.tsx | 74 ++- web/src/components/ui/Modal.tsx | 25 +- web/src/i18n/locales/en.json | 31 ++ web/src/i18n/locales/ru.json | 31 ++ web/src/index.css | 57 +++ web/src/lib/platform.ts | 62 ++- web/src/lib/unsavedChanges.ts | 37 ++ .../pages/settings/ChannelWebhooksSection.tsx | 12 + .../pages/settings/GuildCosmeticsSection.tsx | 12 + web/src/pages/settings/GuildEmojisSection.tsx | 16 + .../pages/settings/GuildGeneralSection.tsx | 9 + .../pages/settings/GuildNicknameSection.tsx | 9 + web/src/pages/settings/GuildSoundsSection.tsx | 22 + .../pages/settings/ProfileSettingsPage.tsx | 30 +- web/src/router.tsx | 4 + web/src/stores/ui.ts | 11 + web/src/stores/unsaved.ts | 105 ++++ web/tests/commandPalette.test.tsx | 301 ++++++++++++ web/tests/platform.test.ts | 9 + web/tests/platformDirty.test.ts | 69 +++ web/tests/unsavedChanges.test.tsx | 289 +++++++++++ 26 files changed, 1818 insertions(+), 34 deletions(-) create mode 100644 web/src/components/CommandPalette.tsx create mode 100644 web/src/components/DirtyCloseGuard.tsx create mode 100644 web/src/components/RootShell.tsx create mode 100644 web/src/lib/unsavedChanges.ts create mode 100644 web/src/stores/unsaved.ts create mode 100644 web/tests/commandPalette.test.tsx create mode 100644 web/tests/platformDirty.test.ts create mode 100644 web/tests/unsavedChanges.test.tsx diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..006641d --- /dev/null +++ b/web/src/components/CommandPalette.tsx @@ -0,0 +1,459 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation, useNavigate } from 'react-router'; + +import type { Channel } from '@/api/types'; +import { Modal } from '@/components/ui/Modal'; +import { groupChannels } from '@/lib/channels'; +import { useSessionStore, type SessionGuild } from '@/stores/session'; +import { useUiStore, type SettingsTarget } from '@/stores/ui'; +import { useVoiceConnectionStore } from '@/stores/voiceConnection'; + +/** Сколько пунктов показываем в разделе: палитра — быстрый переход, не список. */ +const SECTION_LIMIT = 8; + +interface PaletteItem { + /** Устойчивый id: по нему строятся DOM-id опций и выбор мышью. */ + id: string; + label: string; + /** Серый хвост строки: например, сервер комнаты. */ + hint?: string; + icon: string; + /** Текст для поиска помимо подписи (имя сервера, синонимы). */ + keywords: string; + run: () => void; +} + +interface PaletteSection { + title: string; + items: PaletteItem[]; +} + +/** id опции в разметке: по нему `aria-activedescendant` находит активный пункт. */ +function optionId(id: string): string { + return `palette-option-${id}`; +} + +/** + * Совпадение по подстроке без учёта регистра. `toLowerCase` одинаково работает + * и с латиницей, и с кириллицей, поэтому раскладка значения не имеет. + */ +function matches(item: PaletteItem, needle: string): boolean { + return `${item.label} ${item.hint ?? ''} ${item.keywords}`.toLowerCase().includes(needle); +} + +/** Оставляет только совпадения и обрезает разделы до `SECTION_LIMIT`. */ +function filterSections(sections: PaletteSection[], query: string): PaletteSection[] { + const needle = query.trim().toLowerCase(); + const result: PaletteSection[] = []; + for (const section of sections) { + const items = + needle === '' ? section.items : section.items.filter((item) => matches(item, needle)); + if (items.length > 0) { + result.push({ title: section.title, items: items.slice(0, SECTION_LIMIT) }); + } + } + return result; +} + +/** Разделы палитры: серверы, комнаты выбранного сервера, беседы и действия. */ +function buildSections(input: { + t: (key: string) => string; + guilds: SessionGuild[]; + selectedGuildId: string | null; + firstChannelId: (guildId: string) => string | null; + dmChannels: Channel[]; + voiceActive: boolean; + micEnabled: boolean; + deafened: boolean; + navigate: (path: string) => void; + openSettings: (target?: SettingsTarget) => void; + openGuildCreate: () => void; + setMicEnabled: (enabled: boolean) => Promise; + setDeafened: (enabled: boolean) => Promise; +}): PaletteSection[] { + const { t, guilds, selectedGuildId, firstChannelId, dmChannels, navigate } = input; + const sections: PaletteSection[] = []; + + // Серверы: ведём в первую доступную комнату — она же открывается при выборе + // сервера в рейке. + if (guilds.length > 0) { + sections.push({ + title: t('palette.sections.guilds'), + items: guilds.map((guild) => { + const channelId = firstChannelId(guild.id); + return { + id: `guild-${guild.id}`, + label: guild.name, + icon: '🏠', + keywords: t('palette.keywords.guild'), + run: () => { + navigate(channelId === null ? `/app/${guild.id}` : `/app/${guild.id}/${channelId}`); + }, + }; + }), + }); + } + + // Комнаты выбранного сервера: та же группировка по категориям, что в сайдбаре. + const selected = guilds.find((guild) => guild.id === selectedGuildId) ?? null; + if (selected !== null) { + for (const group of groupChannels(selected.channels)) { + sections.push({ + title: group.category?.name ?? t('palette.sections.channels'), + items: group.channels.map((channel) => ({ + id: `channel-${channel.id}`, + label: channel.name, + hint: selected.name, + icon: channel.type === 'voice' ? '🔊' : '#', + keywords: t('palette.keywords.channel'), + run: () => { + navigate(`/app/${selected.id}/${channel.id}`); + }, + })), + }); + } + } + + // Личные беседы: берём уже загруженный снапшот, новых запросов не делаем. + if (dmChannels.length > 0) { + sections.push({ + title: t('palette.sections.dms'), + items: dmChannels.map((channel) => ({ + id: `dm-${channel.id}`, + label: channel.name === '' ? t('friends.dm.unknown') : channel.name, + icon: '💬', + keywords: t('palette.keywords.dm'), + run: () => { + navigate(`/app/friends/${channel.id}`); + }, + })), + }); + } + + const actions: PaletteItem[] = [ + { + id: 'action-profile', + label: t('palette.actions.profile'), + icon: '👤', + keywords: 'settings profile account', + run: () => { + navigate('/settings/account/profile'); + }, + }, + { + id: 'action-security', + label: t('palette.actions.security'), + icon: '🔒', + keywords: 'settings security password 2fa', + run: () => { + navigate('/settings/account/security'); + }, + }, + { + id: 'action-appearance', + label: t('palette.actions.appearance'), + icon: '🎨', + keywords: 'settings appearance theme', + run: () => { + navigate('/settings/account/appearance'); + }, + }, + { + id: 'action-friends', + label: t('palette.actions.friends'), + icon: '👥', + keywords: t('palette.keywords.dm'), + run: () => { + navigate('/app/friends'); + }, + }, + { + id: 'action-create-guild', + label: t('palette.actions.createGuild'), + icon: '➕', + keywords: t('palette.keywords.guild'), + run: input.openGuildCreate, + }, + ]; + + // Настройки сервера — только когда сервер выбран: открываем тот же пункт + // окна настроек, что и шестерёнка в шапке сайдбара. + if (selected !== null) { + actions.push({ + id: 'action-guild-settings', + label: t('palette.actions.server'), + hint: selected.name, + icon: '⚙', + keywords: 'settings guild server', + run: () => { + input.openSettings({ section: 'server', guildId: selected.id }); + }, + }); + } + + // Голосовые действия имеют смысл только в активной сессии. + if (input.voiceActive) { + actions.push( + { + id: 'action-toggle-mic', + label: t('palette.actions.toggleMic'), + icon: input.micEnabled ? '🎙' : '🔇', + keywords: 'voice microphone mute', + run: () => { + void input.setMicEnabled(!input.micEnabled); + }, + }, + { + id: 'action-deafen', + label: t('palette.actions.deafen'), + icon: '🎧', + keywords: 'voice deafen sound', + run: () => { + void input.setDeafened(!input.deafened); + }, + }, + ); + } + + sections.push({ title: t('palette.sections.actions'), items: actions }); + return sections; +} + +/** + * Палитра команд (docs/client-tauri.md §5): быстрый переход по серверам, + * комнатам и действиям. + * + * Открывается по Ctrl/Cmd+K (в том числе из поля ввода — это ожидаемое + * поведение шортката) и по сообщению `openPalette` от desktop-обёртки, когда + * окно было неактивно. Данные берутся из уже загруженных сторов: палитра не + * делает ни одного запроса и на пустом снапшоте показывает только действия. + */ +export function CommandPalette() { + const { t } = useTranslation(); + const location = useLocation(); + const navigate = useNavigate(); + const paletteOpen = useUiStore((state) => state.paletteOpen); + const openPalette = useUiStore((state) => state.openPalette); + const closePalette = useUiStore((state) => state.closePalette); + const openSettings = useUiStore((state) => state.openSettings); + const openGuildCreate = useUiStore((state) => state.openGuildCreate); + const guilds = useSessionStore((state) => state.guilds); + const dmChannels = useSessionStore((state) => state.dmChannels); + const selectedGuildId = useSessionStore((state) => state.selectedGuildId); + const firstChannelId = useSessionStore((state) => state.firstChannelId); + const voiceStatus = useVoiceConnectionStore((state) => state.status); + const micEnabled = useVoiceConnectionStore((state) => state.micEnabled); + const deafened = useVoiceConnectionStore((state) => state.deafened); + const setMicEnabled = useVoiceConnectionStore((state) => state.setMicEnabled); + const setDeafened = useVoiceConnectionStore((state) => state.setDeafened); + + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + // id списка уникален: `aria-controls` не должен указывать на чужой узел. + const listId = useId(); + + // Палитра живёт в защищённой части клиента: на публичных экранах (вход, + // приглашение) перехватывать Ctrl+K нечего. + const enabled = location.pathname.startsWith('/app') || location.pathname.startsWith('/settings'); + + // Ctrl/Cmd+K на уровне документа: работает и когда фокус в поле ввода. + useEffect(() => { + if (!enabled) { + return; + } + const onKeyDown = (event: KeyboardEvent): void => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + openPalette(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [enabled, openPalette]); + + // Каждое открытие начинается с чистого поиска (подстройка состояния под + // изменившийся проп во время рендера — приём из `ui/Modal.tsx`). + const [prevOpen, setPrevOpen] = useState(paletteOpen); + if (prevOpen !== paletteOpen) { + setPrevOpen(paletteOpen); + if (paletteOpen) { + setQuery(''); + setActiveIndex(0); + } + } + + const sections = useMemo( + () => + buildSections({ + t, + guilds, + selectedGuildId, + firstChannelId, + dmChannels, + voiceActive: voiceStatus === 'connected' || voiceStatus === 'reconnecting', + micEnabled, + deafened, + navigate: (path) => void navigate(path), + openSettings, + openGuildCreate, + setMicEnabled, + setDeafened, + }), + [ + t, + guilds, + selectedGuildId, + firstChannelId, + dmChannels, + voiceStatus, + micEnabled, + deafened, + navigate, + openSettings, + openGuildCreate, + setMicEnabled, + setDeafened, + ], + ); + + const visible = useMemo(() => filterSections(sections, query), [sections, query]); + const flat = useMemo(() => visible.flatMap((section) => section.items), [visible]); + const active = flat[activeIndex] ?? null; + + // Активный пункт всегда на виду: список может быть длиннее панели. + useEffect(() => { + if (!paletteOpen || active === null) { + return; + } + const node = listRef.current?.querySelector( + `[data-palette-option="${active.id}"]`, + ); + // jsdom не умеет прокрутку — в тестах вызов просто пропускаем. + if (node !== null && node !== undefined && typeof node.scrollIntoView === 'function') { + node.scrollIntoView({ block: 'nearest' }); + } + }, [paletteOpen, active]); + + const runItem = useCallback( + (item: PaletteItem | null): void => { + if (item === null) { + return; + } + closePalette(); + item.run(); + }, + [closePalette], + ); + + const onKeyDown = (event: ReactKeyboardEvent): void => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + if (flat.length === 0) { + return; + } + const step = event.key === 'ArrowDown' ? 1 : -1; + setActiveIndex((index) => (index + step + flat.length) % flat.length); + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + runItem(active); + } + }; + + return ( + + { + setQuery(event.target.value); + setActiveIndex(0); + }} + onKeyDown={onKeyDown} + /> + +
+ {flat.length === 0 ? ( +

+ {t('palette.empty')} +

+ ) : ( + visible.map((section) => ( +
+

+ {section.title} +

+
    + {section.items.map((item) => { + const selected = item.id === active?.id; + return ( +
  • +
    setActiveIndex(flat.indexOf(item))} + onClick={() => runItem(item)} + > + + {item.label} + {item.hint === undefined ? null : ( + + {item.hint} + + )} +
    +
  • + ); + })} +
+
+ )) + )} +
+
+ ); +} diff --git a/web/src/components/DesktopBridge.tsx b/web/src/components/DesktopBridge.tsx index 8ea15cb..7bed7d1 100644 --- a/web/src/components/DesktopBridge.tsx +++ b/web/src/components/DesktopBridge.tsx @@ -1,6 +1,8 @@ import { useEffect } from 'react'; import { unreadCount, useMessagesStore } from '@/stores/messages'; +import { useUiStore } from '@/stores/ui'; +import { useUnsavedStore } from '@/stores/unsaved'; import { useVoiceConnectionStore } from '@/stores/voiceConnection'; import { platform, routeFromDeepLink } from '@/lib/platform'; import { router } from '@/router'; @@ -47,6 +49,22 @@ export function DesktopBridge() { case 'settings': // Настройки обёртки клиенту не нужны: он читает только свои. break; + case 'closeRequested': { + // Обёртка перехватила закрытие окна, потому что клиент сообщил о + // несохранённых правках. Если реестр уже пуст (успели сохранить), + // отпускаем окно — иначе пользователь не закроет приложение. + const unsaved = useUnsavedStore.getState(); + if (unsaved.entries.length === 0) { + platform.closeWindow(); + } else { + unsaved.requestClose(); + } + break; + } + case 'openPalette': + // Глобальный Ctrl/Cmd+K сработал при неактивном окне. + useUiStore.getState().openPalette(); + break; } }), [], diff --git a/web/src/components/DirtyCloseGuard.tsx b/web/src/components/DirtyCloseGuard.tsx new file mode 100644 index 0000000..751ecc5 --- /dev/null +++ b/web/src/components/DirtyCloseGuard.tsx @@ -0,0 +1,125 @@ +import { useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ApiError } from '@/api/client'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/primitives'; +import { platform } from '@/lib/platform'; +import { useReducedMotion } from '@/lib/reducedMotion'; +import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; +import { useUnsavedStore } from '@/stores/unsaved'; + +/** + * Текст ошибки сохранения: серверную ошибку переводим по коду, локальную + * проверку формы (`Error`) показываем её собственным сообщением. + */ +function saveErrorText(error: unknown, describe: (error: unknown) => string): string { + if (error instanceof ApiError) { + return describe(error); + } + return error instanceof Error ? error.message : describe(error); +} + +/** + * Перехват закрытия окна при несохранённых изменениях (docs/client-tauri.md §5). + * + * Обёртка перехватывает `onCloseRequested` и присылает `closeRequested` только + * тогда, когда клиент сообщил ей о правках. Здесь на это сообщение (флаг + * `closePrompt` в реестре) отвечают тряской контента и диалогом + * «Сохранить / Выйти без сохранения». Тряска — движение, поэтому при + * `prefers-reduced-motion: reduce` её заменяет подсветка рамки. + * + * Диалог рендерится соседом трясущегося контейнера: `transform` анимации + * превратил бы его в содержащий блок для `position: fixed` подложки, и окно + * уезжало бы вместе с контентом. + */ +export function DirtyCloseGuard({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const describeError = useApiErrorMessage(); + const reduced = useReducedMotion(); + const promptOpen = useUnsavedStore((state) => state.closePrompt); + const dismissClose = useUnsavedStore((state) => state.dismissClose); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const attention = promptOpen ? (reduced ? 'gl-attention-flash' : 'gl-attention-shake') : ''; + + const onCancel = (): void => { + // Отмена: окно остаётся открытым, правки — на месте. + dismissClose(); + }; + + const onDiscard = (): void => { + dismissClose(); + platform.closeWindow(); + }; + + const onSave = async (): Promise => { + setSaving(true); + setError(null); + try { + await useUnsavedStore.getState().saveAll(); + } catch (cause) { + // Диалог остаётся открытым: пользователь видит, что именно не сохранилось, + // и может повторить или выйти без сохранения. + setError(cause); + setSaving(false); + return; + } + setSaving(false); + // Реестр опустел: закрываем диалог. В браузере `window.close()` может не + // сработать, и окно должно вернуться в обычное состояние, а не остаться + // с вопросом «сохранить изменения?». + dismissClose(); + platform.closeWindow(); + }; + + return ( + <> +
+ {children} +
+ + { + if (!saving) { + onCancel(); + } + }} + size="sm" + footer={ + <> + + + + + } + > +

{t('unsaved.body')}

+ {error === null ? null : ( +

+ {saveErrorText(error, describeError)} +

+ )} +
+ + ); +} diff --git a/web/src/components/RootShell.tsx b/web/src/components/RootShell.tsx new file mode 100644 index 0000000..5862dc4 --- /dev/null +++ b/web/src/components/RootShell.tsx @@ -0,0 +1,23 @@ +import { Outlet } from 'react-router'; + +import { CommandPalette } from '@/components/CommandPalette'; +import { DirtyCloseGuard } from '@/components/DirtyCloseGuard'; + +/** + * Оболочка корня клиента (docs/client-tauri.md §5). + * + * Тряска контента при закрытии окна с несохранёнными правками и палитра команд + * нужны и в приложении, и в окне настроек, и на экране входа-в-настройки, + * поэтому живут на уровне корневого маршрута, а не внутри `AppLayout`. + */ +export function RootShell() { + return ( + <> + + + + {/* Палитра — сосед трясущегося контейнера: её подложка `fixed`. */} + + + ); +} diff --git a/web/src/components/instance/InstanceGuildsPanel.tsx b/web/src/components/instance/InstanceGuildsPanel.tsx index 3f8777d..aaf254b 100644 --- a/web/src/components/instance/InstanceGuildsPanel.tsx +++ b/web/src/components/instance/InstanceGuildsPanel.tsx @@ -11,6 +11,7 @@ import { import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Field } from '@/components/ui/Field'; import { Button } from '@/components/ui/primitives'; +import { useUnsavedChanges } from '@/lib/unsavedChanges'; import type { InstanceGuild } from '@/api/types'; interface InstanceGuildsPanelProps { @@ -65,6 +66,17 @@ export function InstanceGuildsPanel({ onError: (cause: unknown) => setActionError(cause), }); + // Незаконченное переименование сервера: черновик живёт в поле, пока форма открыта. + useUnsavedChanges({ + dirty: renaming !== null && name.trim() !== '' && name.trim() !== renaming.name, + save: async () => { + if (renaming === null || name.trim() === '') { + return; + } + await rename.mutateAsync({ id: renaming.id, name: name.trim() }); + }, + }); + const pending = rename.isPending || remove.isPending; if (isPending) { diff --git a/web/src/components/instance/InstanceLimitsPanel.tsx b/web/src/components/instance/InstanceLimitsPanel.tsx index 41210ad..ef4530c 100644 --- a/web/src/components/instance/InstanceLimitsPanel.tsx +++ b/web/src/components/instance/InstanceLimitsPanel.tsx @@ -6,6 +6,7 @@ import { updateInstanceSettings, type InstanceSettings } from '@/api/instance'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Field } from '@/components/ui/Field'; import { Button } from '@/components/ui/primitives'; +import { useUnsavedChanges } from '@/lib/unsavedChanges'; interface InstanceLimitsPanelProps { settings: InstanceSettings; @@ -67,7 +68,11 @@ export function InstanceLimitsPanel({ settings, onSaved }: InstanceLimitsPanelPr }, }); - const onSubmit = (): void => { + /** + * Разбор формы: либо патч для сервера, либо текст локальной проверки. + * Общий и для кнопки «Сохранить», и для диалога закрытия окна. + */ + const buildPatch = (): { patch: Partial | null; error: string | null } => { const sizes = { max_avatar_size: mbToBytes(form.avatar), max_guild_image_size: mbToBytes(form.guildImage), @@ -76,32 +81,63 @@ export function InstanceLimitsPanel({ settings, onSaved }: InstanceLimitsPanelPr max_cosmetic_size: mbToBytes(form.cosmetic), }; if (Object.values(sizes).some((value) => value === null)) { - setError(t('settings.instance.limits.invalidSize')); - return; + return { patch: null, error: t('settings.instance.limits.invalidSize') }; } const guilds = Number(form.guilds); const members = Number(form.members); const message = Number(form.message); if (!Number.isFinite(guilds) || !Number.isFinite(members) || !Number.isFinite(message)) { - setError(t('settings.instance.limits.invalidNumber')); - return; + return { patch: null, error: t('settings.instance.limits.invalidNumber') }; } - setError(null); - // Размеры проверены выше: null уже отсеян, здесь только числа. - save.mutate({ - registration_enabled: form.registration, - allow_guild_creation: form.creation, - max_guilds_per_user: guilds, - max_members_per_guild: members, - max_message_length: message, - max_avatar_size: sizes.max_avatar_size ?? settings.max_avatar_size, - max_guild_image_size: sizes.max_guild_image_size ?? settings.max_guild_image_size, - max_emoji_size: sizes.max_emoji_size ?? settings.max_emoji_size, - max_sound_size: sizes.max_sound_size ?? settings.max_sound_size, - max_cosmetic_size: sizes.max_cosmetic_size ?? settings.max_cosmetic_size, - }); + return { + patch: { + registration_enabled: form.registration, + allow_guild_creation: form.creation, + max_guilds_per_user: guilds, + max_members_per_guild: members, + max_message_length: message, + // Размеры проверены выше: null уже отсеян, здесь только числа. + max_avatar_size: sizes.max_avatar_size ?? settings.max_avatar_size, + max_guild_image_size: sizes.max_guild_image_size ?? settings.max_guild_image_size, + max_emoji_size: sizes.max_emoji_size ?? settings.max_emoji_size, + max_sound_size: sizes.max_sound_size ?? settings.max_sound_size, + max_cosmetic_size: sizes.max_cosmetic_size ?? settings.max_cosmetic_size, + }, + error: null, + }; }; + const onSubmit = (): void => { + const { patch, error: message } = buildPatch(); + setError(message); + if (patch !== null) { + save.mutate(patch); + } + }; + + // Черновик лимитов: сравниваем введённое с тем, что уже сохранено на сервере. + useUnsavedChanges({ + dirty: + form.registration !== settings.registration_enabled || + form.creation !== settings.allow_guild_creation || + form.guilds !== String(settings.max_guilds_per_user) || + form.members !== String(settings.max_members_per_guild) || + form.message !== String(settings.max_message_length) || + form.avatar !== bytesToMb(settings.max_avatar_size) || + form.guildImage !== bytesToMb(settings.max_guild_image_size) || + form.emoji !== bytesToMb(settings.max_emoji_size) || + form.sound !== bytesToMb(settings.max_sound_size) || + form.cosmetic !== bytesToMb(settings.max_cosmetic_size), + save: async () => { + const { patch, error: message } = buildPatch(); + if (patch === null) { + // Локальная проверка не прошла: диалог покажет текст, окно останется. + throw new Error(message ?? t('settings.instance.limits.invalidNumber')); + } + await save.mutateAsync(patch); + }, + }); + return (
; } /** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */ @@ -63,6 +75,7 @@ export function Modal({ panelStyle, bodyClassName = '', portal = false, + initialFocusRef, }: ModalProps) { const { t } = useTranslation(); const panelRef = useRef(null); @@ -115,10 +128,10 @@ export function Modal({ }; document.addEventListener('keydown', onKeyDown); const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; - const firstField = panelRef.current?.querySelector( - 'input, textarea, select, button', - ); - firstField?.focus(); + const target = + initialFocusRef?.current ?? + panelRef.current?.querySelector('input, textarea, select, button'); + target?.focus(); return () => { document.removeEventListener('keydown', onKeyDown); const index = openModals.indexOf(token); @@ -128,7 +141,7 @@ export function Modal({ // Возвращаем фокус туда, откуда окно открыли. previous?.focus(); }; - }, [open]); + }, [open, initialFocusRef]); if (!mounted) { return null; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 37fa797..04afcda 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -25,6 +25,37 @@ "online": "Server is reachable", "offline": "Server is unreachable" }, + "palette": { + "title": "Command palette", + "placeholder": "Search servers, channels and actions", + "empty": "Nothing found", + "sections": { + "guilds": "Servers", + "channels": "Channels", + "dms": "Direct messages", + "actions": "Actions" + }, + "keywords": { + "guild": "server guild", + "channel": "channel room", + "dm": "direct message friend dm" + }, + "actions": { + "profile": "Profile settings", + "security": "Security", + "appearance": "Appearance", + "server": "Server settings", + "createGuild": "Create server", + "friends": "Friends", + "toggleMic": "Toggle microphone", + "deafen": "Deafen" + } + }, + "unsaved": { + "title": "You have unsaved changes", + "body": "Save your changes before closing?", + "discard": "Close without saving" + }, "errors": { "network.unreachable": "The server is not responding. Check your connection and try again.", "network.aborted": "The request was aborted.", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 77998ef..3a25a72 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -25,6 +25,37 @@ "online": "Сервер доступен", "offline": "Сервер недоступен" }, + "palette": { + "title": "Палитра команд", + "placeholder": "Поиск серверов, комнат и действий", + "empty": "Ничего не найдено", + "sections": { + "guilds": "Серверы", + "channels": "Комнаты", + "dms": "Личные беседы", + "actions": "Действия" + }, + "keywords": { + "guild": "сервер", + "channel": "комната канал", + "dm": "беседа друг личные сообщения" + }, + "actions": { + "profile": "Настройки профиля", + "security": "Безопасность", + "appearance": "Внешний вид", + "server": "Настройки сервера", + "createGuild": "Создать сервер", + "friends": "Друзья", + "toggleMic": "Переключить микрофон", + "deafen": "Не слышу" + } + }, + "unsaved": { + "title": "Есть несохранённые изменения", + "body": "Сохранить изменения перед выходом?", + "discard": "Выйти без сохранения" + }, "errors": { "network.unreachable": "Сервер не отвечает. Проверьте соединение и повторите попытку.", "network.aborted": "Запрос отменён.", diff --git a/web/src/index.css b/web/src/index.css index dcc67b5..ea7641a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -405,6 +405,55 @@ body { scrollbar-gutter: stable; } +/* + * Попытка закрыть окно с несохранёнными изменениями (docs/client-tauri.md §5): + * контент один раз коротко трясётся, затем открывается диалог сохранения. + * Нативная тряска окна — забота обёртки, в веб-клиенте её нет. + */ +@keyframes gl-attention-shake { + 0%, + 100% { + transform: translateX(0); + } + 15% { + transform: translateX(-8px); + } + 30% { + transform: translateX(7px); + } + 45% { + transform: translateX(-5px); + } + 60% { + transform: translateX(3px); + } + 80% { + transform: translateX(-2px); + } +} + +.gl-attention-shake { + animation: gl-attention-shake 450ms var(--ease-out); +} + +/* + * Замена тряски при `prefers-reduced-motion: reduce`: движение убираем, но + * сигнал оставить нужно — пульс рамки акцентным цветом (он же цвет сервера). + */ +@keyframes gl-attention-flash { + 0%, + 100% { + box-shadow: inset 0 0 0 0 transparent; + } + 50% { + box-shadow: inset 0 0 0 3px var(--color-accent); + } +} + +.gl-attention-flash { + animation: gl-attention-flash 450ms var(--ease-out); +} + /* * Доступность: при `prefers-reduced-motion: reduce` анимации становятся * мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка @@ -421,6 +470,14 @@ body { transition-delay: 0ms !important; scroll-behavior: auto !important; } + + /* + * Подсветка — не движение, а смена цвета: общее правило выше её не гасит. + * Объявление важное, иначе `!important` из правила для `*` победил бы. + */ + .gl-attention-flash { + animation-duration: 450ms !important; + } } /* diff --git a/web/src/lib/platform.ts b/web/src/lib/platform.ts index 0453aed..095ce3f 100644 --- a/web/src/lib/platform.ts +++ b/web/src/lib/platform.ts @@ -32,7 +32,11 @@ export type DesktopMessage = | { type: 'toggleDeafen' } | { type: 'deepLink'; url: string } | { type: 'settings'; settings: DesktopSettings } - | { type: 'updateAvailable'; version: string }; + | { type: 'updateAvailable'; version: string } + /** Обёртка перехватила закрытие окна: у клиента есть несохранённые правки. */ + | { type: 'closeRequested' } + /** Глобальный Ctrl/Cmd+K при неактивном окне: клиент открывает палитру. */ + | { type: 'openPalette' }; /** Уведомление для системы. Текст готовит клиент, показывает — платформа. */ export interface DesktopNotification { @@ -54,6 +58,14 @@ export interface Platform { ready(): void; /** Открыть страницу настроек обёртки. */ openSettings(): void; + /** + * Сообщить платформе, что в клиенте есть несохранённые изменения + * (docs/client-tauri.md §5). Обёртка перехватывает закрытие окна и присылает + * `closeRequested`; в браузере это штатное подтверждение `beforeunload`. + */ + setDirty(dirty: boolean): void; + /** Закрыть окно приложения: обёртка завершает процесс, браузер — вкладку. */ + closeWindow(): void; /** Подписка на сообщения обёртки; возвращает функцию отписки. */ onMessage(handler: (message: DesktopMessage) => void): () => void; } @@ -79,6 +91,18 @@ export function isTauri(): boolean { return tauri() !== null; } +/** + * Штатное подтверждение закрытия вкладки: браузер показывает свой диалог, + * текст в нём не настраивается (`event.returnValue` обязателен для Chrome). + */ +function confirmUnload(event: BeforeUnloadEvent): void { + event.preventDefault(); + event.returnValue = ''; +} + +/** Подписан ли браузер на подтверждение закрытия: лишних подписок не плодим. */ +let unloadGuard = false; + /** Реализация для браузера: системных возможностей почти нет. */ const webPlatform: Platform = { kind: 'web', @@ -101,6 +125,24 @@ const webPlatform: Platform = { }, ready() {}, openSettings() {}, + setDirty(dirty) { + // Браузер умеет только штатное подтверждение закрытия вкладки. Обработчик + // один на страницу: повторный `setDirty(true)` не должен вешать второй. + if (dirty === unloadGuard) { + return; + } + unloadGuard = dirty; + if (dirty) { + window.addEventListener('beforeunload', confirmUnload); + return; + } + window.removeEventListener('beforeunload', confirmUnload); + }, + closeWindow() { + // Страница, открытая не скриптом, закрыться не даст — это ограничение + // браузера, а не ошибка клиента. + window.close(); + }, onMessage() { return () => {}; }, @@ -146,6 +188,13 @@ function tauriPlatform(internals: TauriInternals): Platform { openSettings() { call('desktop_open_settings'); }, + setDirty(dirty) { + // Обёртка по этому флагу решает, перехватывать ли закрытие окна. + call('desktop_set_dirty', { dirty }); + }, + closeWindow() { + call('desktop_close_window'); + }, onMessage(handler) { handlers.add(handler); return () => { @@ -163,8 +212,9 @@ export const platform: Platform = internals === null ? webPlatform : tauriPlatfo /** * Маршрут клиента для deep link обёртки (docs/client-tauri.md §5). * - * Поддерживаются `glchat://invite/` и - * `glchat://guild//channel/`; неизвестная ссылка даёт `null`. + * Поддерживаются `glchat://invite/`, `glchat://guild//channel/` + * и `glchat://dm/` (клик по уведомлению о личном сообщении: сервера + * у беседы нет); неизвестная ссылка даёт `null`. */ export function routeFromDeepLink(link: string): string | null { let url: URL; @@ -183,6 +233,12 @@ export function routeFromDeepLink(link: string): string | null { const code = rest[0]; return rest.length === 1 && code !== undefined ? `/invite/${encodeURIComponent(code)}` : null; } + if (kind === 'dm') { + const channelId = rest[0]; + return rest.length === 1 && channelId !== undefined + ? `/app/friends/${encodeURIComponent(channelId)}` + : null; + } if (kind === 'guild') { const [guildId, marker, channelId] = rest; if ( diff --git a/web/src/lib/unsavedChanges.ts b/web/src/lib/unsavedChanges.ts new file mode 100644 index 0000000..a0cd194 --- /dev/null +++ b/web/src/lib/unsavedChanges.ts @@ -0,0 +1,37 @@ +import { useEffect, useId, useRef } from 'react'; + +import { useUnsavedStore, type SaveHandler } from '@/stores/unsaved'; + +export interface UnsavedChangesOptions { + /** Есть ли в форме несохранённый черновик. */ + dirty: boolean; + /** Сохранение формы — та же операция, что и у кнопки «Сохранить». */ + save: SaveHandler; +} + +/** + * Регистрирует форму в реестре несохранённых изменений, пока `dirty === true` + * (docs/client-tauri.md §5). Запись снимается при размонтировании формы и при + * возврате `dirty` в `false` — например, сразу после успешного сохранения. + * + * `save` хранится в ref: формы пересобирают обработчик на каждом рендере + * (он замкнут на текущее состояние полей), а регистрация должна оставаться + * одной — иначе реестр «мигал» бы на каждый введённый символ. + */ +export function useUnsavedChanges({ dirty, save }: UnsavedChangesOptions): void { + // id компонента вместо счётчика: уникален и не зависит от порядка рендера. + const id = useId(); + const register = useUnsavedStore((state) => state.register); + + const saveRef = useRef(save); + useEffect(() => { + saveRef.current = save; + }); + + useEffect(() => { + if (!dirty) { + return; + } + return register(id, () => saveRef.current()); + }, [dirty, id, register]); +} diff --git a/web/src/pages/settings/ChannelWebhooksSection.tsx b/web/src/pages/settings/ChannelWebhooksSection.tsx index f8d74d2..f12eb41 100644 --- a/web/src/pages/settings/ChannelWebhooksSection.tsx +++ b/web/src/pages/settings/ChannelWebhooksSection.tsx @@ -20,6 +20,7 @@ import { SkeletonLines } from '@/components/ui/Skeleton'; import { stepUp } from '@/api/auth'; import { errorCode } from '@/lib/format'; import { useCurrentUser } from '@/lib/hooks'; +import { useUnsavedChanges } from '@/lib/unsavedChanges'; import { SettingsSection } from '@/pages/settings/SettingsSection'; /** Сколько держится подсказка «ссылка скопирована», мс. */ @@ -131,6 +132,17 @@ export function ChannelWebhooksSection({ channelId, canManage }: ChannelWebhooks onSuccess: invalidate, }); + // Незаконченное переименование вебхука (форма открыта в отдельном окне). + useUnsavedChanges({ + dirty: renaming !== null && renameValue.trim() !== '' && renameValue.trim() !== renaming.name, + save: async () => { + if (renaming === null || renameValue.trim() === '') { + return; + } + await rename.mutateAsync(renaming); + }, + }); + if (!canManage) { return null; } diff --git a/web/src/pages/settings/GuildCosmeticsSection.tsx b/web/src/pages/settings/GuildCosmeticsSection.tsx index d254c90..d3559cc 100644 --- a/web/src/pages/settings/GuildCosmeticsSection.tsx +++ b/web/src/pages/settings/GuildCosmeticsSection.tsx @@ -19,6 +19,7 @@ import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Field } from '@/components/ui/Field'; import { Button } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; +import { useUnsavedChanges } from '@/lib/unsavedChanges'; import { SettingsSection } from '@/pages/settings/SettingsSection'; interface GuildCosmeticsSectionProps { @@ -77,6 +78,17 @@ export function GuildCosmeticsSection({ guildId, canManage }: GuildCosmeticsSect onError: (cause: unknown) => setError(cause), }); + // Незаконченное переименование: черновик живёт в поле, пока форма открыта. + useUnsavedChanges({ + dirty: renaming !== null && name.trim() !== '' && name.trim() !== renaming.name, + save: async () => { + if (renaming === null || name.trim() === '') { + return; + } + await rename.mutateAsync({ id: renaming.id, name: name.trim() }); + }, + }); + if (!canManage) { return null; } diff --git a/web/src/pages/settings/GuildEmojisSection.tsx b/web/src/pages/settings/GuildEmojisSection.tsx index 3847994..70b9a11 100644 --- a/web/src/pages/settings/GuildEmojisSection.tsx +++ b/web/src/pages/settings/GuildEmojisSection.tsx @@ -22,6 +22,7 @@ import { Badge, Button } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; import { customEmojiShortcode } from '@/lib/customEmoji'; import { formatBytes } from '@/lib/format'; +import { useUnsavedChanges } from '@/lib/unsavedChanges'; import { useSessionStore } from '@/stores/session'; import { SettingsSection } from '@/pages/settings/SettingsSection'; @@ -143,6 +144,21 @@ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) { pickFile(event.dataTransfer.files[0]); }; + /* + * Незаконченная загрузка (имя и выбранный файл) — единственный черновик + * раздела: переименование и удаление уходят на сервер сразу. В реестр + * попадает только заполненная форма — невалидную всё равно не сохранить. + */ + useUnsavedChanges({ + dirty: file !== null && isValidEmojiName(name), + save: async () => { + if (file === null) { + return; + } + await upload.mutateAsync({ name, file }); + }, + }); + return ( { + await save.mutateAsync(); + }, + }); + return ( { + await save.mutateAsync(nickname.trim()); + }, + }); + return ( { + if (file === null) { + return; + } + await upload.mutateAsync({ + name, + file, + kind, + ...(kind === 'ui' ? { event } : {}), + ...(emoji.trim() === '' ? {} : { emoji: emoji.trim() }), + }); + }, + }); + /** Строка списка: имя, событие, эмодзи, переименование и удаление. */ const renderRow = (sound: GuildSound) => (
  • ): void => { event.preventDefault(); setSaved(false); // Часовой пояс и статус присутствия форма не трогает: у них свои места. - save.mutate({ - display_name: displayName.trim(), - bio: bio.trim(), - custom_status: customStatus.trim(), - custom_status_emoji: customEmoji.trim(), - }); + save.mutate(draft); }; + useUnsavedChanges({ + dirty: + displayName !== user.display_name || + bio !== user.bio || + customStatus !== user.custom_status || + customEmoji !== user.custom_status_emoji, + save: async () => { + await save.mutateAsync(draft); + }, + }); + const avatarName = displayName === '' ? user.username : displayName; const avatarBusy = upload.isPending || removeAvatar.isPending; // Форматы и лимит живут в подсказке к аватару, а не в тексте формы. diff --git a/web/src/router.tsx b/web/src/router.tsx index 4fabe90..89af1a6 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,6 +1,7 @@ import { createBrowserRouter, Navigate } from 'react-router'; import { AuthGuard } from '@/components/AuthGuard'; +import { RootShell } from '@/components/RootShell'; import { AccountSettingsLayout, AppearanceSettingsPage, @@ -25,6 +26,9 @@ import { export const routes = [ { path: '/', + // Корневая оболочка: тряска при закрытии окна с несохранёнными правками + // и палитра команд доступны на всех экранах внутри роутера (§5). + element: , children: [ { index: true, element: }, { path: 'login', element: }, diff --git a/web/src/stores/ui.ts b/web/src/stores/ui.ts index b49099f..9487a4d 100644 --- a/web/src/stores/ui.ts +++ b/web/src/stores/ui.ts @@ -50,6 +50,13 @@ interface UiState { settingsOpen: boolean; settingsSection: SettingsSection; settingsProfileTab: ProfileTab; + /** + * Палитра команд (Ctrl/Cmd+K): открыть её может и обёртка сообщением + * `openPalette`, поэтому состояние живёт в сторе, а не в компоненте. + */ + paletteOpen: boolean; + openPalette: () => void; + closePalette: () => void; /** * Развёрнут ли список друзей во вкладке «Друзья». Свёрнут по умолчанию, а * выбор пользователя переживает перезагрузку страницы. @@ -114,6 +121,10 @@ export const useUiStore = create((set, get) => ({ settingsSection: 'profile', settingsProfileTab: 'main', + paletteOpen: false, + openPalette: () => set({ paletteOpen: true }), + closePalette: () => set({ paletteOpen: false }), + openSettings: (target) => { // Выбранный сервер живёт в снапшоте сессии: так его видят и секции // «Опасной зоны», которые сбрасывают выбор после выхода с сервера. diff --git a/web/src/stores/unsaved.ts b/web/src/stores/unsaved.ts new file mode 100644 index 0000000..1d9502a --- /dev/null +++ b/web/src/stores/unsaved.ts @@ -0,0 +1,105 @@ +import { create } from 'zustand'; + +import { platform } from '@/lib/platform'; + +/** + * Единый реестр несохранённых изменений (docs/client-tauri.md §5). + * + * Формы с локальным черновиком регистрируют здесь запись, пока в них есть + * правки. Реестр нужен по двум причинам: платформа должна знать, перехватывать + * ли закрытие окна (один флаг на всё приложение), а диалог закрытия — уметь + * сохранить все формы по очереди, не зная о них ничего. + */ + +/** Сохранение формы: та же операция, что и кнопка «Сохранить». */ +export type SaveHandler = () => Promise; + +export interface UnsavedEntry { + id: string; + save: SaveHandler; +} + +interface UnsavedState { + entries: UnsavedEntry[]; + /** Обёртка попросила закрыть окно, а в реестре есть правки. */ + closePrompt: boolean; + /** + * Регистрирует запись и возвращает функцию снятия. Повторная регистрация с + * тем же id заменяет обработчик: форма может пересобрать `save`. + */ + register: (id: string, save: SaveHandler) => () => void; + unregister: (id: string) => void; + /** Показать диалог закрытия (сообщение `closeRequested` от обёртки). */ + requestClose: () => void; + /** Закрыть диалог без выхода: пользователь выбрал «Отмена». */ + dismissClose: () => void; + /** + * Сохранить все формы по очереди. Первая же ошибка останавливает процесс и + * уходит наверх: диалог покажет её текст, а окно останется открытым. + */ + saveAll: () => Promise; + /** Сброс для тестов и выхода из аккаунта. */ + reset: () => void; +} + +const defaults = { + entries: [] as UnsavedEntry[], + closePrompt: false, +}; + +export const useUnsavedStore = create((set, get) => ({ + ...defaults, + + register: (id, save) => { + const entries = get().entries; + const index = entries.findIndex((entry) => entry.id === id); + if (index === -1) { + set({ entries: [...entries, { id, save }] }); + } else { + const next = [...entries]; + next[index] = { id, save }; + set({ entries: next }); + } + return () => get().unregister(id); + }, + + unregister: (id) => { + const entries = get().entries; + if (!entries.some((entry) => entry.id === id)) { + return; + } + set({ entries: entries.filter((entry) => entry.id !== id) }); + }, + + requestClose: () => set({ closePrompt: true }), + + dismissClose: () => set({ closePrompt: false }), + + saveAll: async () => { + // Копия списка: `save` может изменить реестр (например, форма сбросит + // черновик и снимет регистрацию), а обход должен остаться устойчивым. + for (const entry of [...get().entries]) { + await entry.save(); + } + }, + + reset: () => set({ ...defaults }), +})); + +/** Есть ли в реестре хотя бы одна несохранённая форма. */ +export function hasUnsavedChanges(state: UnsavedState): boolean { + return state.entries.length > 0; +} + +/** + * Платформа узнаёт о переходе «есть правки / нет правок» ровно один раз: + * сравниваем агрегат, а не длину списка. Иначе сохранение одной из двух форм + * мигало бы `setDirty(false)` → `setDirty(true)`, и обёртка успела бы решить, + * что закрывать окно можно. + */ +useUnsavedStore.subscribe((state, previous) => { + const dirty = hasUnsavedChanges(state); + if (dirty !== hasUnsavedChanges(previous)) { + platform.setDirty(dirty); + } +}); diff --git a/web/tests/commandPalette.test.tsx b/web/tests/commandPalette.test.tsx new file mode 100644 index 0000000..f7784a2 --- /dev/null +++ b/web/tests/commandPalette.test.tsx @@ -0,0 +1,301 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'; + +import { CommandPalette } from '@/components/CommandPalette'; +import { DesktopBridge } from '@/components/DesktopBridge'; +import type { GatewaySnapshot } from '@/api/gateway'; +import { useSessionStore } from '@/stores/session'; +import { useUiStore } from '@/stores/ui'; +import type { DesktopMessage } from '@/lib/platform'; +import { installFetch, json, renderApp } from './helpers'; + +/** + * Подменная платформа: сообщения обёртки раздаём вручную (`receive`), а вызовы + * клиента только записываем. Настоящую реализацию здесь не проверить — она + * выбирается при импорте модуля по наличию моста Tauri. + */ +const desktop = vi.hoisted(() => { + const handlers = new Set<(message: unknown) => void>(); + return { + setDirty: vi.fn(), + closeWindow: vi.fn(), + onMessage: (handler: (message: unknown) => void) => { + handlers.add(handler); + return () => handlers.delete(handler); + }, + receive: (message: unknown) => { + for (const handler of handlers) { + handler(message); + } + }, + }; +}); + +vi.mock('@/lib/platform', () => ({ + platform: { + kind: 'tauri' as const, + notify: () => {}, + setBadge: () => {}, + ready: () => {}, + openSettings: () => {}, + setDirty: desktop.setDirty, + closeWindow: desktop.closeWindow, + onMessage: desktop.onMessage, + }, + isTauri: () => true, + routeFromDeepLink: () => null, +})); + +/** Текущий путь роутера: по нему проверяются переходы палитры. */ +function LocationProbe() { + const location = useLocation(); + return {location.pathname}; +} + +/** Снапшот READY: два сервера, категория с комнатами и одна личная беседа. */ +const snapshot: GatewaySnapshot = { + user: { + id: 'user-1', + username: 'alice', + display_name: 'Alice', + is_instance_admin: false, + badges: [], + }, + guilds: [ + { + id: 'g-1', + name: 'Основной сервер', + owner_id: 'user-1', + is_main: true, + channels: [ + { id: 'cat-1', name: 'Текстовые', type: 'category', position: 0 }, + { id: 'c-general', name: 'общий', type: 'text', position: 1, parent_id: 'cat-1' }, + { id: 'c-voice', name: 'Голосовая', type: 'voice', position: 2 }, + ], + roles: [], + emojis: [], + sounds: [], + voice_states: [], + member_ids: ['user-1'], + my_role_ids: [], + }, + { + id: 'g-2', + name: 'Второй сервер', + owner_id: 'user-1', + is_main: false, + channels: [], + roles: [], + emojis: [], + sounds: [], + voice_states: [], + member_ids: ['user-1'], + my_role_ids: [], + }, + ], + dm_channels: [{ id: 'dm-1', type: 'dm', name: 'Аня', position: 0, recipient_id: 'u-2' }], + read_states: [], + session_id: 'session-1', + heartbeat_interval_ms: 45_000, +}; + +/** Рендер палитры на заданном маршруте с подготовленным снапшотом. */ +function renderPalette(path = '/app/friends') { + useSessionStore.getState().reset(); + useSessionStore.getState().applyReady(snapshot); + useSessionStore.getState().selectGuild('g-1'); + + const router = createMemoryRouter( + [ + { + path: '*', + element: ( + <> + + + {/* Поле вне палитры: проверяем, что обычный ввод не перехватывается. */} + + + ), + }, + ], + { initialEntries: [path] }, + ); + render(); + return { router }; +} + +/** Открывает палитру шорткатом Ctrl+K и возвращает поле поиска. */ +async function openWithShortcut(): Promise { + await userEvent.keyboard('{Control>}k{/Control}'); + return screen.findByTestId('palette-search'); +} + +beforeEach(() => { + desktop.setDirty.mockClear(); + desktop.closeWindow.mockClear(); + useUiStore.setState({ paletteOpen: false }); +}); + +describe('палитра команд', () => { + it('открывается по Ctrl+K и показывает серверы, комнаты, беседы и действия', async () => { + renderPalette(); + + const search = await openWithShortcut(); + expect(search).toHaveFocus(); + + expect(screen.getByTestId('palette-option-guild-g-1')).toHaveTextContent('Основной сервер'); + expect(screen.getByTestId('palette-option-guild-g-2')).toHaveTextContent('Второй сервер'); + expect(screen.getByTestId('palette-option-channel-c-general')).toHaveTextContent('общий'); + expect(screen.getByTestId('palette-option-channel-c-voice')).toHaveTextContent('Голосовая'); + expect(screen.getByTestId('palette-option-dm-dm-1')).toHaveTextContent('Аня'); + expect(screen.getByTestId('palette-option-action-profile')).toHaveTextContent( + 'Настройки профиля', + ); + expect(screen.getByTestId('palette-option-action-create-guild')).toBeVisible(); + // Комнаты сгруппированы по категории сервера. + expect(screen.getByRole('group', { name: 'Текстовые' })).toBeVisible(); + }); + + it('открывается по Cmd+K из поля ввода, не перехватывая обычный ввод', async () => { + renderPalette(); + const field = screen.getByLabelText('Ввод'); + + await userEvent.click(field); + await userEvent.type(field, 'привет'); + expect(field).toHaveValue('привет'); + expect(screen.queryByTestId('palette-search')).toBeNull(); + + await userEvent.keyboard('{Meta>}k{/Meta}'); + + expect(await screen.findByTestId('palette-search')).toBeVisible(); + }); + + it('фильтрует пункты по подстроке без учёта регистра', async () => { + renderPalette(); + const search = await openWithShortcut(); + + await userEvent.type(search, 'ОБЩИЙ'); + + expect(screen.getByTestId('palette-option-channel-c-general')).toBeVisible(); + expect(screen.queryByTestId('palette-option-guild-g-2')).toBeNull(); + expect(screen.queryByTestId('palette-option-dm-dm-1')).toBeNull(); + }); + + it('сообщает, что ничего не найдено', async () => { + renderPalette(); + const search = await openWithShortcut(); + + await userEvent.type(search, 'ззз'); + + expect(screen.getByTestId('palette-empty')).toHaveTextContent('Ничего не найдено'); + }); + + it('переходит на комнату по Enter, стрелки двигают активный пункт', async () => { + renderPalette(); + const search = await openWithShortcut(); + + expect(search).toHaveAttribute('aria-activedescendant', 'palette-option-guild-g-1'); + await userEvent.keyboard('{ArrowDown}'); + expect(search).toHaveAttribute('aria-activedescendant', 'palette-option-guild-g-2'); + await userEvent.keyboard('{ArrowUp}'); + expect(search).toHaveAttribute('aria-activedescendant', 'palette-option-guild-g-1'); + + await userEvent.clear(search); + await userEvent.type(search, 'общий'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.getByTestId('location')).toHaveTextContent('/app/g-1/c-general'); + }); + // После выполнения пункта палитра закрывается. + await waitFor(() => { + expect(screen.queryByTestId('palette-search')).toBeNull(); + }); + }); + + it('открывает сервер в его первой комнате', async () => { + renderPalette(); + const search = await openWithShortcut(); + + await userEvent.type(search, 'Второй'); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.getByTestId('location')).toHaveTextContent('/app/g-2'); + }); + }); + + it('выполняет действия: «Создать сервер» и переход в друзья', async () => { + renderPalette(); + let search = await openWithShortcut(); + + await userEvent.type(search, 'Создать сервер'); + await userEvent.keyboard('{Enter}'); + expect(useUiStore.getState().guildCreateOpen).toBe(true); + + search = await openWithShortcut(); + await userEvent.type(search, 'Друзья'); + await userEvent.keyboard('{Enter}'); + await waitFor(() => { + expect(screen.getByTestId('location')).toHaveTextContent('/app/friends'); + }); + }); + + it('закрывается по Escape', async () => { + renderPalette(); + await openWithShortcut(); + + await userEvent.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByTestId('palette-search')).toBeNull(); + }); + }); + + it('открывается сообщением openPalette от обёртки', async () => { + renderPalette(); + render(); + + act(() => { + desktop.receive({ type: 'openPalette' } satisfies DesktopMessage); + }); + + expect(await screen.findByTestId('palette-search')).toBeVisible(); + }); + + it('доступна на защищённом маршруте приложения', async () => { + installFetch([ + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + { + match: '/api/v1/users/@me', + response: () => + json({ + user: { + id: 'user-1', + username: 'alice', + display_name: 'Alice', + bio: '', + status: 'online', + custom_status: '', + custom_status_emoji: '', + is_instance_admin: false, + badges: [], + locale: 'ru', + onboarding_completed: true, + totp_enabled: false, + }, + }), + }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + ]); + + // Палитра смонтирована корневой оболочкой роутера, а не тестовым стендом. + renderApp('/app/empty'); + await openWithShortcut(); + + expect(screen.getByTestId('palette-option-action-profile')).toBeVisible(); + }); +}); diff --git a/web/tests/platform.test.ts b/web/tests/platform.test.ts index 185045b..35e677d 100644 --- a/web/tests/platform.test.ts +++ b/web/tests/platform.test.ts @@ -23,6 +23,15 @@ describe('routeFromDeepLink', () => { expect(routeFromDeepLink('glchat://guild/10/channel/20')).toBe('/app/10/20'); }); + it('разбирает личную беседу', () => { + expect(routeFromDeepLink('glchat://dm/77')).toBe('/app/friends/77'); + }); + + it('не принимает мусор в ссылке на беседу', () => { + expect(routeFromDeepLink('glchat://dm/77/лишнее')).toBeNull(); + expect(routeFromDeepLink('glchat://dm')).toBeNull(); + }); + it('игнорирует чужие схемы и неизвестные пути', () => { expect(routeFromDeepLink('https://gl.mhspx.su/invite/abc')).toBeNull(); expect(routeFromDeepLink('glchat://unknown/1')).toBeNull(); diff --git a/web/tests/platformDirty.test.ts b/web/tests/platformDirty.test.ts new file mode 100644 index 0000000..25cca41 --- /dev/null +++ b/web/tests/platformDirty.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { platform } from '@/lib/platform'; + +/** Ставит в window фальшивый мост Tauri v2 и возвращает список вызовов. */ +function stubTauri(): Array<{ command: string; args: unknown }> { + const calls: Array<{ command: string; args: unknown }> = []; + vi.stubGlobal('__TAURI_INTERNALS__', { + invoke: (command: string, args?: unknown) => { + calls.push({ command, args }); + return Promise.resolve(undefined); + }, + }); + return calls; +} + +/** + * Несохранённые изменения (docs/client-tauri.md §5): платформа должна знать о + * них, чтобы обёртка перехватила закрытие окна, а браузер — показал своё + * подтверждение. Здесь проверяются сами вызовы платформы; поведение клиента — + * в `unsavedChanges.test.tsx`. + */ +describe('setDirty в браузере', () => { + it('включает и снимает штатное подтверждение закрытия вкладки', () => { + const added: unknown[] = []; + const removed: unknown[] = []; + vi.spyOn(window, 'addEventListener').mockImplementation((type, handler) => { + if (type === 'beforeunload') { + added.push(handler); + } + }); + vi.spyOn(window, 'removeEventListener').mockImplementation((type, handler) => { + if (type === 'beforeunload') { + removed.push(handler); + } + }); + + platform.setDirty(true); + // Повторный вызов не вешает второй обработчик: подтверждение одно на окно. + platform.setDirty(true); + expect(added).toHaveLength(1); + + const event = new Event('beforeunload', { cancelable: true }); + (added[0] as (event: Event) => void)(event); + expect(event.defaultPrevented).toBe(true); + + platform.setDirty(false); + expect(removed).toEqual(added); + }); +}); + +describe('команды обёртке', () => { + it('передаёт dirty и закрывает окно', async () => { + const calls = stubTauri(); + vi.resetModules(); + const desktop = await import('@/lib/platform'); + + expect(desktop.platform.kind).toBe('tauri'); + desktop.platform.setDirty(true); + desktop.platform.setDirty(false); + desktop.platform.closeWindow(); + + expect(calls).toEqual([ + { command: 'desktop_set_dirty', args: { dirty: true } }, + { command: 'desktop_set_dirty', args: { dirty: false } }, + { command: 'desktop_close_window', args: undefined }, + ]); + }); +}); diff --git a/web/tests/unsavedChanges.test.tsx b/web/tests/unsavedChanges.test.tsx new file mode 100644 index 0000000..eba3571 --- /dev/null +++ b/web/tests/unsavedChanges.test.tsx @@ -0,0 +1,289 @@ +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { DesktopBridge } from '@/components/DesktopBridge'; +import { DirtyCloseGuard } from '@/components/DirtyCloseGuard'; +import { useUnsavedChanges } from '@/lib/unsavedChanges'; +import type { DesktopMessage } from '@/lib/platform'; +import { useSessionStore } from '@/stores/session'; +import { useUiStore } from '@/stores/ui'; +import { useUnsavedStore } from '@/stores/unsaved'; +import { installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers'; + +/** + * Подменная платформа: так же, как в тестах палитры, сообщения обёртки раздаём + * вручную, а `setDirty`/`closeWindow` только записываем. + */ +const desktop = vi.hoisted(() => { + const handlers = new Set<(message: unknown) => void>(); + return { + calls: [] as string[], + setDirty: vi.fn(), + closeWindow: vi.fn(), + onMessage: (handler: (message: unknown) => void) => { + handlers.add(handler); + return () => handlers.delete(handler); + }, + receive: (message: unknown) => { + for (const handler of handlers) { + handler(message); + } + }, + }; +}); + +vi.mock('@/lib/platform', () => ({ + platform: { + kind: 'tauri' as const, + notify: () => {}, + setBadge: () => {}, + ready: () => {}, + openSettings: () => {}, + setDirty: desktop.setDirty, + closeWindow: desktop.closeWindow, + onMessage: desktop.onMessage, + }, + isTauri: () => true, + routeFromDeepLink: () => null, +})); + +const user = makeUser(); + +/** Форма с черновиком: то же, что делают формы настроек с кнопкой «Сохранить». */ +function DraftForm({ save }: { save: () => Promise }) { + const [value, setValue] = useState(''); + useUnsavedChanges({ dirty: value !== '', save }); + return ( + setValue(event.target.value)} /> + ); +} + +/** Приложение с реестром несохранённых изменений и формой-черновиком. */ +function renderGuard(save: () => Promise) { + return render( + <> + + + + + , + ); +} + +/** Правит форму так, чтобы она стала dirty. */ +async function editDraft(): Promise { + await userEvent.type(screen.getByLabelText('Черновик'), 'п'); + await waitFor(() => { + expect(desktop.setDirty).toHaveBeenCalledWith(true); + }); +} + +/** Просит обёртку закрыть окно (сообщение Rust `closeRequested`). */ +async function requestClose(): Promise { + // Промис в `act`: сообщение обёртки меняет состояние стора вне React-события. + await act(() => { + desktop.receive({ type: 'closeRequested' } satisfies DesktopMessage); + return Promise.resolve(); + }); +} + +/** Ожидание диалога закрытия. */ +function closeDialog(): HTMLElement { + return screen.getByRole('dialog', { name: 'Есть несохранённые изменения' }); +} + +beforeEach(() => { + desktop.calls.length = 0; + desktop.setDirty.mockClear(); + desktop.closeWindow.mockClear(); + desktop.closeWindow.mockImplementation(() => { + desktop.calls.push('close'); + }); + // Между тестами реестр должен быть пуст: записи снимает размонтирование. + useUnsavedStore.getState().reset(); +}); + +describe('реестр несохранённых изменений', () => { + it('сообщает платформе о появлении и исчезновении правок ровно один раз', async () => { + const { unmount } = renderGuard(() => Promise.resolve()); + + await editDraft(); + // Второй ввод не должен дёргать платформу повторно. + await userEvent.type(screen.getByLabelText('Черновик'), 'ривет'); + expect(desktop.setDirty).toHaveBeenCalledTimes(1); + + unmount(); + expect(desktop.setDirty).toHaveBeenLastCalledWith(false); + expect(desktop.setDirty).toHaveBeenCalledTimes(2); + }); + + it('без правок не считает окно dirty', async () => { + renderGuard(() => Promise.resolve()); + + await requestClose(); + + expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull(); + // Окно отпускаем: сохранять нечего, иначе приложение не закрыть. + expect(desktop.closeWindow).toHaveBeenCalled(); + }); +}); + +describe('закрытие окна при несохранённых изменениях', () => { + it('показывает диалог и трясёт контент', async () => { + renderGuard(() => Promise.resolve()); + await editDraft(); + + await requestClose(); + + expect(await screen.findByText('Сохранить изменения перед выходом?')).toBeVisible(); + expect(closeDialog()).toBeVisible(); + expect(screen.getByTestId('app-content')).toHaveClass('gl-attention-shake'); + }); + + it('«Сохранить» сохраняет формы и закрывает окно', async () => { + const save = vi.fn(() => { + desktop.calls.push('save'); + return Promise.resolve(); + }); + renderGuard(save); + await editDraft(); + await requestClose(); + + await userEvent.click(screen.getByRole('button', { name: 'Сохранить' })); + + await waitFor(() => { + expect(desktop.closeWindow).toHaveBeenCalledTimes(1); + }); + expect(save).toHaveBeenCalledTimes(1); + expect(desktop.calls).toEqual(['save', 'close']); + // В браузере `window.close()` может не сработать: диалог закрываем сами. + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull(); + }); + }); + + it('«Выйти без сохранения» закрывает окно, не сохраняя', async () => { + const save = vi.fn(() => Promise.resolve()); + renderGuard(save); + await editDraft(); + await requestClose(); + + await userEvent.click(screen.getByRole('button', { name: 'Выйти без сохранения' })); + + expect(desktop.closeWindow).toHaveBeenCalledTimes(1); + expect(save).not.toHaveBeenCalled(); + expect(desktop.calls).toEqual(['close']); + }); + + it('«Отмена» закрывает диалог и оставляет приложение открытым', async () => { + const save = vi.fn(() => Promise.resolve()); + renderGuard(save); + await editDraft(); + await requestClose(); + + await userEvent.click(screen.getByRole('button', { name: 'Отмена' })); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull(); + }); + expect(desktop.closeWindow).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + // Правки на месте: палитра закрытия не сбрасывает черновик. + expect(screen.getByLabelText('Черновик')).toHaveValue('п'); + }); + + it('Escape работает как «Отмена»', async () => { + renderGuard(() => Promise.resolve()); + await editDraft(); + await requestClose(); + + await userEvent.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull(); + }); + expect(desktop.closeWindow).not.toHaveBeenCalled(); + }); + + it('оставляет диалог открытым и показывает текст ошибки', async () => { + const save = vi.fn(() => Promise.reject(new Error('Сервер отклонил правку'))); + renderGuard(save); + await editDraft(); + await requestClose(); + + await userEvent.click(screen.getByRole('button', { name: 'Сохранить' })); + + expect(await screen.findByTestId('unsaved-error')).toHaveTextContent('Сервер отклонил правку'); + expect(closeDialog()).toBeVisible(); + expect(desktop.closeWindow).not.toHaveBeenCalled(); + }); + + it('при prefers-reduced-motion вместо тряски подсвечивает контент', async () => { + // Системная настройка «уменьшить движение»: движение заменяем подсветкой. + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ + matches: true, + addEventListener: () => {}, + removeEventListener: () => {}, + })), + ); + renderGuard(() => Promise.resolve()); + await editDraft(); + + await requestClose(); + + const content = screen.getByTestId('app-content'); + expect(content).toHaveClass('gl-attention-flash'); + expect(content).not.toHaveClass('gl-attention-shake'); + }); +}); + +describe('формы настроек в реестре', () => { + const appRoutes: FetchRoute[] = [ + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + ]; + + it('правка профиля помечает клиент dirty, сохранение снимает флаг', async () => { + installFetch([ + ...appRoutes, + { + match: '/api/v1/users/@me', + method: 'PATCH', + response: () => json({ user: { ...user, display_name: 'Алиса' } }), + }, + ]); + + renderApp('/app/empty'); + // Окно настроек открывается шестерёнкой в панели пользователя. + await userEvent.click(await screen.findByLabelText('Настройки пользователя')); + const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); + + const field = within(dialog).getByLabelText('Отображаемое имя'); + await userEvent.clear(field); + await userEvent.type(field, 'Алиса'); + + await waitFor(() => { + expect(desktop.setDirty).toHaveBeenCalledWith(true); + }); + + await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' })); + + await waitFor(() => { + expect(useUnsavedStore.getState().entries).toHaveLength(0); + }); + await waitFor(() => { + expect(desktop.setDirty).toHaveBeenLastCalledWith(false); + }); + }); +}); + +/** Стор сессии и UI между тестами сбрасывает `renderApp`, здесь — только UI. */ +beforeEach(() => { + useSessionStore.getState().reset(); + useUiStore.setState({ paletteOpen: false, settingsOpen: false }); +});