feat(web): палитра команд Ctrl/Cmd+K и подтверждение выхода с несохранёнными правками

Два требования 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/<channelId>` (личная беседа без сервера) — его
  присылает обёртка при клике по уведомлению о личном сообщении.

Тесты: 525 passed (501 база + 24 новых) — палитра, реестр правок, диалог
закрытия, `beforeunload`, разбор ссылок; `npm run check` и `npm run lint` зелёные.
This commit is contained in:
2026-09-22 23:17:32 +03:00
parent 72c5189e8f
commit 085454d555
26 changed files with 1818 additions and 34 deletions
+59 -3
View File
@@ -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/<code>` и
* `glchat://guild/<guildId>/channel/<channelId>`; неизвестная ссылка даёт `null`.
* Поддерживаются `glchat://invite/<code>`, `glchat://guild/<guildId>/channel/<channelId>`
* и `glchat://dm/<channelId>` (клик по уведомлению о личном сообщении: сервера
* у беседы нет); неизвестная ссылка даёт `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 (