Files
glchat/web/src/lib/platform.ts
T
grendervill 085454d555 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` зелёные.
2026-09-22 23:17:32 +03:00

256 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Платформенный адаптер клиента (docs/client-tauri.md §2).
*
* Один и тот же веб-код работает и в браузере, и внутри desktop-обёртки Tauri:
* реализация выбирается при старте по наличию моста Tauri, а вызовы платформы
* идут только через этот модуль. В браузере доступно не всё (нативные
* уведомления — по разрешению браузера, трея и бейджа нет), поэтому у каждой
* реализации свои возможности, а не заглушки «на будущее».
*/
/** Настройки обёртки: поля совпадают с Rust-структурой `Settings`. */
export interface DesktopSettings {
/** Адрес инстанса, загруженного в окно. */
instanceUrl: string;
/** Закрытие окна сворачивает приложение в трей. */
closeToTray: boolean;
/** Автозапуск при входе в систему. */
autostart: boolean;
/** Вопрос об автозапуске уже задавали. */
autostartPrompted: boolean;
pttShortcut: string;
muteShortcut: string;
deafenShortcut: string;
/** Нативные уведомления включены. */
notifications: boolean;
}
/** Сообщение из обёртки (см. `WebMessage` в desktop/src-tauri/src/bridge.rs). */
export type DesktopMessage =
| { type: 'ptt'; active: boolean }
| { type: 'toggleMic' }
| { type: 'toggleDeafen' }
| { type: 'deepLink'; url: string }
| { type: 'settings'; settings: DesktopSettings }
| { type: 'updateAvailable'; version: string }
/** Обёртка перехватила закрытие окна: у клиента есть несохранённые правки. */
| { type: 'closeRequested' }
/** Глобальный Ctrl/Cmd+K при неактивном окне: клиент открывает палитру. */
| { type: 'openPalette' };
/** Уведомление для системы. Текст готовит клиент, показывает — платформа. */
export interface DesktopNotification {
title: string;
body: string;
guildId?: string | null;
channelId?: string | null;
}
export type PlatformKind = 'web' | 'tauri';
export interface Platform {
readonly kind: PlatformKind;
/** Показать системное уведомление (если платформа это умеет). */
notify(notification: DesktopNotification): void;
/** Число непрочитанных для значка приложения и трея. */
setBadge(count: number): void;
/** Сообщить обёртке, что клиент загрузился (deep links ждут этого). */
ready(): void;
/** Открыть страницу настроек обёртки. */
openSettings(): void;
/**
* Сообщить платформе, что в клиенте есть несохранённые изменения
* (docs/client-tauri.md §5). Обёртка перехватывает закрытие окна и присылает
* `closeRequested`; в браузере это штатное подтверждение `beforeunload`.
*/
setDirty(dirty: boolean): void;
/** Закрыть окно приложения: обёртка завершает процесс, браузер — вкладку. */
closeWindow(): void;
/** Подписка на сообщения обёртки; возвращает функцию отписки. */
onMessage(handler: (message: DesktopMessage) => void): () => void;
}
/** Мост Tauri v2: этого объекта в браузере нет. */
interface TauriInternals {
invoke(command: string, args?: Record<string, unknown>): Promise<unknown>;
}
interface DesktopBridgeGlobal {
__TAURI_INTERNALS__?: TauriInternals;
__glchatDesktop?: { receive: (message: DesktopMessage) => void };
}
function tauri(): TauriInternals | null {
const bridge = window as unknown as DesktopBridgeGlobal;
const internals = bridge.__TAURI_INTERNALS__;
return internals !== undefined && typeof internals.invoke === 'function' ? internals : null;
}
/** Открыт ли клиент внутри desktop-обёртки. */
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',
notify(notification) {
// В браузере показываем только при уже выданном разрешении: запрашивать
// его без действия пользователя нельзя, а веб-пуш — отдельная фаза.
if (!('Notification' in window) || Notification.permission !== 'granted') {
return;
}
try {
new Notification(notification.title, { body: notification.body });
} catch {
// Браузер может запретить уведомление (например, в фоновой вкладке) —
// это не ошибка клиента.
}
},
setBadge(count) {
// В браузере счётчик виден в заголовке вкладки: это делает сам клиент.
void count;
},
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 () => {};
},
};
/** Реализация для desktop-обёртки: тонкие вызовы команд Rust. */
function tauriPlatform(internals: TauriInternals): Platform {
const handlers = new Set<(message: DesktopMessage) => void>();
const bridge = window as unknown as DesktopBridgeGlobal;
bridge.__glchatDesktop = {
receive(message) {
for (const handler of handlers) {
handler(message);
}
},
};
/** Команды обёртки не должны ронять клиент: ошибку только логируем. */
const call = (command: string, args?: Record<string, unknown>): void => {
void internals.invoke(command, args).catch((error: unknown) => {
console.warn(`[desktop] команда ${command} не выполнена`, error);
});
};
return {
kind: 'tauri',
notify(notification) {
call('desktop_notify', {
payload: {
title: notification.title,
body: notification.body,
guildId: notification.guildId ?? null,
channelId: notification.channelId ?? null,
},
});
},
setBadge(count) {
call('desktop_set_badge', { count });
},
ready() {
call('desktop_ready');
},
openSettings() {
call('desktop_open_settings');
},
setDirty(dirty) {
// Обёртка по этому флагу решает, перехватывать ли закрытие окна.
call('desktop_set_dirty', { dirty });
},
closeWindow() {
call('desktop_close_window');
},
onMessage(handler) {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
},
};
}
const internals = tauri();
/** Платформа текущего запуска: браузер или desktop-обёртка. */
export const platform: Platform = internals === null ? webPlatform : tauriPlatform(internals);
/**
* Маршрут клиента для deep link обёртки (docs/client-tauri.md §5).
*
* Поддерживаются `glchat://invite/<code>`, `glchat://guild/<guildId>/channel/<channelId>`
* и `glchat://dm/<channelId>` (клик по уведомлению о личном сообщении: сервера
* у беседы нет); неизвестная ссылка даёт `null`.
*/
export function routeFromDeepLink(link: string): string | null {
let url: URL;
try {
url = new URL(link);
} catch {
return null;
}
if (url.protocol !== 'glchat:') {
return null;
}
const segments = url.pathname.split('/').filter((segment) => segment !== '');
// В `glchat://invite/ABC` хост — это первый сегмент пути.
const [kind, ...rest] = [url.hostname, ...segments];
if (kind === 'invite') {
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 (
rest.length !== 3 ||
marker !== 'channel' ||
guildId === undefined ||
channelId === undefined
) {
return null;
}
return `/app/${encodeURIComponent(guildId)}/${encodeURIComponent(channelId)}`;
}
return null;
}