085454d555
Два требования 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` зелёные.
106 lines
4.1 KiB
TypeScript
106 lines
4.1 KiB
TypeScript
import { create } from 'zustand';
|
|
|
|
import { platform } from '@/lib/platform';
|
|
|
|
/**
|
|
* Единый реестр несохранённых изменений (docs/client-tauri.md §5).
|
|
*
|
|
* Формы с локальным черновиком регистрируют здесь запись, пока в них есть
|
|
* правки. Реестр нужен по двум причинам: платформа должна знать, перехватывать
|
|
* ли закрытие окна (один флаг на всё приложение), а диалог закрытия — уметь
|
|
* сохранить все формы по очереди, не зная о них ничего.
|
|
*/
|
|
|
|
/** Сохранение формы: та же операция, что и кнопка «Сохранить». */
|
|
export type SaveHandler = () => Promise<void>;
|
|
|
|
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<void>;
|
|
/** Сброс для тестов и выхода из аккаунта. */
|
|
reset: () => void;
|
|
}
|
|
|
|
const defaults = {
|
|
entries: [] as UnsavedEntry[],
|
|
closePrompt: false,
|
|
};
|
|
|
|
export const useUnsavedStore = create<UnsavedState>((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);
|
|
}
|
|
});
|