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:
@@ -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<void>;
|
||||||
|
setDeafened: (enabled: boolean) => Promise<void>;
|
||||||
|
}): 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<HTMLInputElement | null>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(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<HTMLElement>(
|
||||||
|
`[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<HTMLInputElement>): 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 (
|
||||||
|
<Modal
|
||||||
|
open={paletteOpen && enabled}
|
||||||
|
title={t('palette.title')}
|
||||||
|
onClose={closePalette}
|
||||||
|
size="lg"
|
||||||
|
panelClassName="p-0 overflow-hidden"
|
||||||
|
bodyClassName="mt-4"
|
||||||
|
initialFocusRef={inputRef}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls={listId}
|
||||||
|
aria-activedescendant={active === null ? undefined : optionId(active.id)}
|
||||||
|
aria-label={t('palette.placeholder')}
|
||||||
|
placeholder={t('palette.placeholder')}
|
||||||
|
value={query}
|
||||||
|
data-testid="palette-search"
|
||||||
|
className="w-full border-b border-border/60 bg-transparent px-4 py-2 text-sm text-fg outline-none placeholder:text-fg-muted"
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setActiveIndex(0);
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
id={listId}
|
||||||
|
role="listbox"
|
||||||
|
aria-label={t('palette.title')}
|
||||||
|
data-testid="palette-list"
|
||||||
|
className="max-h-80 overflow-y-auto p-2"
|
||||||
|
>
|
||||||
|
{flat.length === 0 ? (
|
||||||
|
<p className="px-2 py-3 text-sm text-fg-muted" data-testid="palette-empty">
|
||||||
|
{t('palette.empty')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
visible.map((section) => (
|
||||||
|
<div key={section.title} role="group" aria-label={section.title}>
|
||||||
|
<p className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||||
|
{section.title}
|
||||||
|
</p>
|
||||||
|
<ul role="presentation">
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const selected = item.id === active?.id;
|
||||||
|
return (
|
||||||
|
<li key={item.id} role="presentation">
|
||||||
|
<div
|
||||||
|
id={optionId(item.id)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
data-palette-option={item.id}
|
||||||
|
data-testid={`palette-option-${item.id}`}
|
||||||
|
className={`flex cursor-pointer items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm ${
|
||||||
|
selected ? 'bg-surface-3 text-fg' : 'text-fg-muted hover:bg-surface-2'
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => setActiveIndex(flat.indexOf(item))}
|
||||||
|
onClick={() => runItem(item)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{item.icon}</span>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
{item.hint === undefined ? null : (
|
||||||
|
<span className="ml-auto truncate text-xs text-fg-muted">
|
||||||
|
{item.hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { unreadCount, useMessagesStore } from '@/stores/messages';
|
import { unreadCount, useMessagesStore } from '@/stores/messages';
|
||||||
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
import { useUnsavedStore } from '@/stores/unsaved';
|
||||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
import { platform, routeFromDeepLink } from '@/lib/platform';
|
import { platform, routeFromDeepLink } from '@/lib/platform';
|
||||||
import { router } from '@/router';
|
import { router } from '@/router';
|
||||||
@@ -47,6 +49,22 @@ export function DesktopBridge() {
|
|||||||
case 'settings':
|
case 'settings':
|
||||||
// Настройки обёртки клиенту не нужны: он читает только свои.
|
// Настройки обёртки клиенту не нужны: он читает только свои.
|
||||||
break;
|
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;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -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<unknown>(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<void> => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await useUnsavedStore.getState().saveAll();
|
||||||
|
} catch (cause) {
|
||||||
|
// Диалог остаётся открытым: пользователь видит, что именно не сохранилось,
|
||||||
|
// и может повторить или выйти без сохранения.
|
||||||
|
setError(cause);
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
// Реестр опустел: закрываем диалог. В браузере `window.close()` может не
|
||||||
|
// сработать, и окно должно вернуться в обычное состояние, а не остаться
|
||||||
|
// с вопросом «сохранить изменения?».
|
||||||
|
dismissClose();
|
||||||
|
platform.closeWindow();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div data-testid="app-content" className={`h-full ${attention}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={promptOpen}
|
||||||
|
title={t('unsaved.title')}
|
||||||
|
onClose={() => {
|
||||||
|
if (!saving) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" disabled={saving} onClick={onCancel}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border-danger/50 text-danger"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={onDiscard}
|
||||||
|
>
|
||||||
|
{t('unsaved.discard')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
void onSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(saving ? 'common.saving' : 'common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-fg-muted">{t('unsaved.body')}</p>
|
||||||
|
{error === null ? null : (
|
||||||
|
<p role="alert" data-testid="unsaved-error" className="mt-3 text-sm text-danger">
|
||||||
|
{saveErrorText(error, describeError)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<DirtyCloseGuard>
|
||||||
|
<Outlet />
|
||||||
|
</DirtyCloseGuard>
|
||||||
|
{/* Палитра — сосед трясущегося контейнера: её подложка `fixed`. */}
|
||||||
|
<CommandPalette />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field } from '@/components/ui/Field';
|
import { Field } from '@/components/ui/Field';
|
||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
||||||
import type { InstanceGuild } from '@/api/types';
|
import type { InstanceGuild } from '@/api/types';
|
||||||
|
|
||||||
interface InstanceGuildsPanelProps {
|
interface InstanceGuildsPanelProps {
|
||||||
@@ -65,6 +66,17 @@ export function InstanceGuildsPanel({
|
|||||||
onError: (cause: unknown) => setActionError(cause),
|
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;
|
const pending = rename.isPending || remove.isPending;
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { updateInstanceSettings, type InstanceSettings } from '@/api/instance';
|
|||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field } from '@/components/ui/Field';
|
import { Field } from '@/components/ui/Field';
|
||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
||||||
|
|
||||||
interface InstanceLimitsPanelProps {
|
interface InstanceLimitsPanelProps {
|
||||||
settings: InstanceSettings;
|
settings: InstanceSettings;
|
||||||
@@ -67,7 +68,11 @@ export function InstanceLimitsPanel({ settings, onSaved }: InstanceLimitsPanelPr
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (): void => {
|
/**
|
||||||
|
* Разбор формы: либо патч для сервера, либо текст локальной проверки.
|
||||||
|
* Общий и для кнопки «Сохранить», и для диалога закрытия окна.
|
||||||
|
*/
|
||||||
|
const buildPatch = (): { patch: Partial<InstanceSettings> | null; error: string | null } => {
|
||||||
const sizes = {
|
const sizes = {
|
||||||
max_avatar_size: mbToBytes(form.avatar),
|
max_avatar_size: mbToBytes(form.avatar),
|
||||||
max_guild_image_size: mbToBytes(form.guildImage),
|
max_guild_image_size: mbToBytes(form.guildImage),
|
||||||
@@ -76,32 +81,63 @@ export function InstanceLimitsPanel({ settings, onSaved }: InstanceLimitsPanelPr
|
|||||||
max_cosmetic_size: mbToBytes(form.cosmetic),
|
max_cosmetic_size: mbToBytes(form.cosmetic),
|
||||||
};
|
};
|
||||||
if (Object.values(sizes).some((value) => value === null)) {
|
if (Object.values(sizes).some((value) => value === null)) {
|
||||||
setError(t('settings.instance.limits.invalidSize'));
|
return { patch: null, error: t('settings.instance.limits.invalidSize') };
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const guilds = Number(form.guilds);
|
const guilds = Number(form.guilds);
|
||||||
const members = Number(form.members);
|
const members = Number(form.members);
|
||||||
const message = Number(form.message);
|
const message = Number(form.message);
|
||||||
if (!Number.isFinite(guilds) || !Number.isFinite(members) || !Number.isFinite(message)) {
|
if (!Number.isFinite(guilds) || !Number.isFinite(members) || !Number.isFinite(message)) {
|
||||||
setError(t('settings.instance.limits.invalidNumber'));
|
return { patch: null, error: t('settings.instance.limits.invalidNumber') };
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setError(null);
|
return {
|
||||||
// Размеры проверены выше: null уже отсеян, здесь только числа.
|
patch: {
|
||||||
save.mutate({
|
registration_enabled: form.registration,
|
||||||
registration_enabled: form.registration,
|
allow_guild_creation: form.creation,
|
||||||
allow_guild_creation: form.creation,
|
max_guilds_per_user: guilds,
|
||||||
max_guilds_per_user: guilds,
|
max_members_per_guild: members,
|
||||||
max_members_per_guild: members,
|
max_message_length: message,
|
||||||
max_message_length: message,
|
// Размеры проверены выше: null уже отсеян, здесь только числа.
|
||||||
max_avatar_size: sizes.max_avatar_size ?? settings.max_avatar_size,
|
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_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_emoji_size: sizes.max_emoji_size ?? settings.max_emoji_size,
|
||||||
max_sound_size: sizes.max_sound_size ?? settings.max_sound_size,
|
max_sound_size: sizes.max_sound_size ?? settings.max_sound_size,
|
||||||
max_cosmetic_size: sizes.max_cosmetic_size ?? settings.max_cosmetic_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 (
|
return (
|
||||||
<form
|
<form
|
||||||
className="mt-3 flex flex-col gap-4"
|
className="mt-3 flex flex-col gap-4"
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
} from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -28,6 +35,11 @@ interface ModalProps {
|
|||||||
* `overflow` и трансформациям родителей.
|
* `overflow` и трансформациям родителей.
|
||||||
*/
|
*/
|
||||||
portal?: boolean;
|
portal?: boolean;
|
||||||
|
/**
|
||||||
|
* Куда поставить фокус при открытии. По умолчанию — первое поле панели;
|
||||||
|
* палитре команд нужен именно поиск, а не крестик в шапке (он тоже кнопка).
|
||||||
|
*/
|
||||||
|
initialFocusRef?: RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */
|
/** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */
|
||||||
@@ -63,6 +75,7 @@ export function Modal({
|
|||||||
panelStyle,
|
panelStyle,
|
||||||
bodyClassName = '',
|
bodyClassName = '',
|
||||||
portal = false,
|
portal = false,
|
||||||
|
initialFocusRef,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -115,10 +128,10 @@ export function Modal({
|
|||||||
};
|
};
|
||||||
document.addEventListener('keydown', onKeyDown);
|
document.addEventListener('keydown', onKeyDown);
|
||||||
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
const firstField = panelRef.current?.querySelector<HTMLElement>(
|
const target =
|
||||||
'input, textarea, select, button',
|
initialFocusRef?.current ??
|
||||||
);
|
panelRef.current?.querySelector<HTMLElement>('input, textarea, select, button');
|
||||||
firstField?.focus();
|
target?.focus();
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', onKeyDown);
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
const index = openModals.indexOf(token);
|
const index = openModals.indexOf(token);
|
||||||
@@ -128,7 +141,7 @@ export function Modal({
|
|||||||
// Возвращаем фокус туда, откуда окно открыли.
|
// Возвращаем фокус туда, откуда окно открыли.
|
||||||
previous?.focus();
|
previous?.focus();
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open, initialFocusRef]);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -25,6 +25,37 @@
|
|||||||
"online": "Server is reachable",
|
"online": "Server is reachable",
|
||||||
"offline": "Server is unreachable"
|
"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": {
|
"errors": {
|
||||||
"network.unreachable": "The server is not responding. Check your connection and try again.",
|
"network.unreachable": "The server is not responding. Check your connection and try again.",
|
||||||
"network.aborted": "The request was aborted.",
|
"network.aborted": "The request was aborted.",
|
||||||
|
|||||||
@@ -25,6 +25,37 @@
|
|||||||
"online": "Сервер доступен",
|
"online": "Сервер доступен",
|
||||||
"offline": "Сервер недоступен"
|
"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": {
|
"errors": {
|
||||||
"network.unreachable": "Сервер не отвечает. Проверьте соединение и повторите попытку.",
|
"network.unreachable": "Сервер не отвечает. Проверьте соединение и повторите попытку.",
|
||||||
"network.aborted": "Запрос отменён.",
|
"network.aborted": "Запрос отменён.",
|
||||||
|
|||||||
@@ -405,6 +405,55 @@ body {
|
|||||||
scrollbar-gutter: stable;
|
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` анимации становятся
|
* Доступность: при `prefers-reduced-motion: reduce` анимации становятся
|
||||||
* мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка
|
* мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка
|
||||||
@@ -421,6 +470,14 @@ body {
|
|||||||
transition-delay: 0ms !important;
|
transition-delay: 0ms !important;
|
||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Подсветка — не движение, а смена цвета: общее правило выше её не гасит.
|
||||||
|
* Объявление важное, иначе `!important` из правила для `*` победил бы.
|
||||||
|
*/
|
||||||
|
.gl-attention-flash {
|
||||||
|
animation-duration: 450ms !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
+59
-3
@@ -32,7 +32,11 @@ export type DesktopMessage =
|
|||||||
| { type: 'toggleDeafen' }
|
| { type: 'toggleDeafen' }
|
||||||
| { type: 'deepLink'; url: string }
|
| { type: 'deepLink'; url: string }
|
||||||
| { type: 'settings'; settings: DesktopSettings }
|
| { type: 'settings'; settings: DesktopSettings }
|
||||||
| { type: 'updateAvailable'; version: string };
|
| { type: 'updateAvailable'; version: string }
|
||||||
|
/** Обёртка перехватила закрытие окна: у клиента есть несохранённые правки. */
|
||||||
|
| { type: 'closeRequested' }
|
||||||
|
/** Глобальный Ctrl/Cmd+K при неактивном окне: клиент открывает палитру. */
|
||||||
|
| { type: 'openPalette' };
|
||||||
|
|
||||||
/** Уведомление для системы. Текст готовит клиент, показывает — платформа. */
|
/** Уведомление для системы. Текст готовит клиент, показывает — платформа. */
|
||||||
export interface DesktopNotification {
|
export interface DesktopNotification {
|
||||||
@@ -54,6 +58,14 @@ export interface Platform {
|
|||||||
ready(): void;
|
ready(): void;
|
||||||
/** Открыть страницу настроек обёртки. */
|
/** Открыть страницу настроек обёртки. */
|
||||||
openSettings(): void;
|
openSettings(): void;
|
||||||
|
/**
|
||||||
|
* Сообщить платформе, что в клиенте есть несохранённые изменения
|
||||||
|
* (docs/client-tauri.md §5). Обёртка перехватывает закрытие окна и присылает
|
||||||
|
* `closeRequested`; в браузере это штатное подтверждение `beforeunload`.
|
||||||
|
*/
|
||||||
|
setDirty(dirty: boolean): void;
|
||||||
|
/** Закрыть окно приложения: обёртка завершает процесс, браузер — вкладку. */
|
||||||
|
closeWindow(): void;
|
||||||
/** Подписка на сообщения обёртки; возвращает функцию отписки. */
|
/** Подписка на сообщения обёртки; возвращает функцию отписки. */
|
||||||
onMessage(handler: (message: DesktopMessage) => void): () => void;
|
onMessage(handler: (message: DesktopMessage) => void): () => void;
|
||||||
}
|
}
|
||||||
@@ -79,6 +91,18 @@ export function isTauri(): boolean {
|
|||||||
return tauri() !== null;
|
return tauri() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Штатное подтверждение закрытия вкладки: браузер показывает свой диалог,
|
||||||
|
* текст в нём не настраивается (`event.returnValue` обязателен для Chrome).
|
||||||
|
*/
|
||||||
|
function confirmUnload(event: BeforeUnloadEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подписан ли браузер на подтверждение закрытия: лишних подписок не плодим. */
|
||||||
|
let unloadGuard = false;
|
||||||
|
|
||||||
/** Реализация для браузера: системных возможностей почти нет. */
|
/** Реализация для браузера: системных возможностей почти нет. */
|
||||||
const webPlatform: Platform = {
|
const webPlatform: Platform = {
|
||||||
kind: 'web',
|
kind: 'web',
|
||||||
@@ -101,6 +125,24 @@ const webPlatform: Platform = {
|
|||||||
},
|
},
|
||||||
ready() {},
|
ready() {},
|
||||||
openSettings() {},
|
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() {
|
onMessage() {
|
||||||
return () => {};
|
return () => {};
|
||||||
},
|
},
|
||||||
@@ -146,6 +188,13 @@ function tauriPlatform(internals: TauriInternals): Platform {
|
|||||||
openSettings() {
|
openSettings() {
|
||||||
call('desktop_open_settings');
|
call('desktop_open_settings');
|
||||||
},
|
},
|
||||||
|
setDirty(dirty) {
|
||||||
|
// Обёртка по этому флагу решает, перехватывать ли закрытие окна.
|
||||||
|
call('desktop_set_dirty', { dirty });
|
||||||
|
},
|
||||||
|
closeWindow() {
|
||||||
|
call('desktop_close_window');
|
||||||
|
},
|
||||||
onMessage(handler) {
|
onMessage(handler) {
|
||||||
handlers.add(handler);
|
handlers.add(handler);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -163,8 +212,9 @@ export const platform: Platform = internals === null ? webPlatform : tauriPlatfo
|
|||||||
/**
|
/**
|
||||||
* Маршрут клиента для deep link обёртки (docs/client-tauri.md §5).
|
* Маршрут клиента для deep link обёртки (docs/client-tauri.md §5).
|
||||||
*
|
*
|
||||||
* Поддерживаются `glchat://invite/<code>` и
|
* Поддерживаются `glchat://invite/<code>`, `glchat://guild/<guildId>/channel/<channelId>`
|
||||||
* `glchat://guild/<guildId>/channel/<channelId>`; неизвестная ссылка даёт `null`.
|
* и `glchat://dm/<channelId>` (клик по уведомлению о личном сообщении: сервера
|
||||||
|
* у беседы нет); неизвестная ссылка даёт `null`.
|
||||||
*/
|
*/
|
||||||
export function routeFromDeepLink(link: string): string | null {
|
export function routeFromDeepLink(link: string): string | null {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
@@ -183,6 +233,12 @@ export function routeFromDeepLink(link: string): string | null {
|
|||||||
const code = rest[0];
|
const code = rest[0];
|
||||||
return rest.length === 1 && code !== undefined ? `/invite/${encodeURIComponent(code)}` : null;
|
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') {
|
if (kind === 'guild') {
|
||||||
const [guildId, marker, channelId] = rest;
|
const [guildId, marker, channelId] = rest;
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { SkeletonLines } from '@/components/ui/Skeleton';
|
|||||||
import { stepUp } from '@/api/auth';
|
import { stepUp } from '@/api/auth';
|
||||||
import { errorCode } from '@/lib/format';
|
import { errorCode } from '@/lib/format';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
/** Сколько держится подсказка «ссылка скопирована», мс. */
|
/** Сколько держится подсказка «ссылка скопирована», мс. */
|
||||||
@@ -131,6 +132,17 @@ export function ChannelWebhooksSection({ channelId, canManage }: ChannelWebhooks
|
|||||||
onSuccess: invalidate,
|
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) {
|
if (!canManage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
|||||||
import { Field } from '@/components/ui/Field';
|
import { Field } from '@/components/ui/Field';
|
||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
interface GuildCosmeticsSectionProps {
|
interface GuildCosmeticsSectionProps {
|
||||||
@@ -77,6 +78,17 @@ export function GuildCosmeticsSection({ guildId, canManage }: GuildCosmeticsSect
|
|||||||
onError: (cause: unknown) => setError(cause),
|
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) {
|
if (!canManage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Badge, Button } from '@/components/ui/primitives';
|
|||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { customEmojiShortcode } from '@/lib/customEmoji';
|
import { customEmojiShortcode } from '@/lib/customEmoji';
|
||||||
import { formatBytes } from '@/lib/format';
|
import { formatBytes } from '@/lib/format';
|
||||||
|
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
@@ -143,6 +144,21 @@ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
|
|||||||
pickFile(event.dataTransfer.files[0]);
|
pickFile(event.dataTransfer.files[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Незаконченная загрузка (имя и выбранный файл) — единственный черновик
|
||||||
|
* раздела: переименование и удаление уходят на сервер сразу. В реестр
|
||||||
|
* попадает только заполненная форма — невалидную всё равно не сохранить.
|
||||||
|
*/
|
||||||
|
useUnsavedChanges({
|
||||||
|
dirty: file !== null && isValidEmojiName(name),
|
||||||
|
save: async () => {
|
||||||
|
if (file === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await upload.mutateAsync({ name, file });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t('settings.servers.emojis.title')}
|
title={t('settings.servers.emojis.title')}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { Guild } from '@/api/types';
|
|||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field, TextAreaField } from '@/components/ui/Field';
|
import { Field, TextAreaField } from '@/components/ui/Field';
|
||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
@@ -46,6 +47,14 @@ export function GuildGeneralSection({ guild, canEdit }: GuildGeneralSectionProps
|
|||||||
save.mutate();
|
save.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Незавершённая правка имени или описания: закрытие окна не должно её терять.
|
||||||
|
useUnsavedChanges({
|
||||||
|
dirty: name !== guild.name || description !== guild.description,
|
||||||
|
save: async () => {
|
||||||
|
await save.mutateAsync();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t('settings.servers.general.title')}
|
title={t('settings.servers.general.title')}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { GuildMember } from '@/api/types';
|
|||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field } from '@/components/ui/Field';
|
import { Field } from '@/components/ui/Field';
|
||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
@@ -70,6 +71,14 @@ export function GuildNicknameSection({
|
|||||||
save.mutate(nickname.trim());
|
save.mutate(nickname.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Поле тронули, но не сохранили (`typed === null` означает «не трогали»).
|
||||||
|
useUnsavedChanges({
|
||||||
|
dirty: typed !== null && typed.trim() !== currentNickname,
|
||||||
|
save: async () => {
|
||||||
|
await save.mutateAsync(nickname.trim());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t('settings.servers.nickname.title')}
|
title={t('settings.servers.nickname.title')}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Modal } from '@/components/ui/Modal';
|
|||||||
import { Badge, Button } from '@/components/ui/primitives';
|
import { Badge, Button } from '@/components/ui/primitives';
|
||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { formatBytes } from '@/lib/format';
|
import { formatBytes } from '@/lib/format';
|
||||||
|
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
@@ -171,6 +172,27 @@ export function GuildSoundsSection({ guildId }: GuildSoundsSectionProps) {
|
|||||||
pickFile(event_.dataTransfer.files[0]);
|
pickFile(event_.dataTransfer.files[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Незаконченная загрузка звука (имя и выбранный файл) — единственный черновик
|
||||||
|
* раздела: переименование и удаление уходят на сервер сразу. В реестр попадает
|
||||||
|
* только заполненная форма — невалидную всё равно не сохранить.
|
||||||
|
*/
|
||||||
|
useUnsavedChanges({
|
||||||
|
dirty: file !== null && isValidSoundName(name),
|
||||||
|
save: async () => {
|
||||||
|
if (file === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await upload.mutateAsync({
|
||||||
|
name,
|
||||||
|
file,
|
||||||
|
kind,
|
||||||
|
...(kind === 'ui' ? { event } : {}),
|
||||||
|
...(emoji.trim() === '' ? {} : { emoji: emoji.trim() }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/** Строка списка: имя, событие, эмодзи, переименование и удаление. */
|
/** Строка списка: имя, событие, эмодзи, переименование и удаление. */
|
||||||
const renderRow = (sound: GuildSound) => (
|
const renderRow = (sound: GuildSound) => (
|
||||||
<li
|
<li
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { SkeletonLines } from '@/components/ui/Skeleton';
|
|||||||
import { formatBytes } from '@/lib/format';
|
import { formatBytes } from '@/lib/format';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
|
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
|
||||||
|
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { ProfileStyleSection } from '@/pages/settings/ProfileStyleSection';
|
import { ProfileStyleSection } from '@/pages/settings/ProfileStyleSection';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
@@ -136,18 +137,35 @@ export function ProfileForm({ user }: { user: User }) {
|
|||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Черновик полей — то, что потерялось бы при закрытии окна. Аватар сюда не
|
||||||
|
* входит: он уходит на сервер сразу после выбора файла.
|
||||||
|
*/
|
||||||
|
const draft: UpdateProfileInput = {
|
||||||
|
display_name: displayName.trim(),
|
||||||
|
bio: bio.trim(),
|
||||||
|
custom_status: customStatus.trim(),
|
||||||
|
custom_status_emoji: customEmoji.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
// Часовой пояс и статус присутствия форма не трогает: у них свои места.
|
// Часовой пояс и статус присутствия форма не трогает: у них свои места.
|
||||||
save.mutate({
|
save.mutate(draft);
|
||||||
display_name: displayName.trim(),
|
|
||||||
bio: bio.trim(),
|
|
||||||
custom_status: customStatus.trim(),
|
|
||||||
custom_status_emoji: customEmoji.trim(),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 avatarName = displayName === '' ? user.username : displayName;
|
||||||
const avatarBusy = upload.isPending || removeAvatar.isPending;
|
const avatarBusy = upload.isPending || removeAvatar.isPending;
|
||||||
// Форматы и лимит живут в подсказке к аватару, а не в тексте формы.
|
// Форматы и лимит живут в подсказке к аватару, а не в тексте формы.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router';
|
import { createBrowserRouter, Navigate } from 'react-router';
|
||||||
|
|
||||||
import { AuthGuard } from '@/components/AuthGuard';
|
import { AuthGuard } from '@/components/AuthGuard';
|
||||||
|
import { RootShell } from '@/components/RootShell';
|
||||||
import {
|
import {
|
||||||
AccountSettingsLayout,
|
AccountSettingsLayout,
|
||||||
AppearanceSettingsPage,
|
AppearanceSettingsPage,
|
||||||
@@ -25,6 +26,9 @@ import {
|
|||||||
export const routes = [
|
export const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
// Корневая оболочка: тряска при закрытии окна с несохранёнными правками
|
||||||
|
// и палитра команд доступны на всех экранах внутри роутера (§5).
|
||||||
|
element: <RootShell />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <IndexRedirect /> },
|
{ index: true, element: <IndexRedirect /> },
|
||||||
{ path: 'login', element: <LoginPage /> },
|
{ path: 'login', element: <LoginPage /> },
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ interface UiState {
|
|||||||
settingsOpen: boolean;
|
settingsOpen: boolean;
|
||||||
settingsSection: SettingsSection;
|
settingsSection: SettingsSection;
|
||||||
settingsProfileTab: ProfileTab;
|
settingsProfileTab: ProfileTab;
|
||||||
|
/**
|
||||||
|
* Палитра команд (Ctrl/Cmd+K): открыть её может и обёртка сообщением
|
||||||
|
* `openPalette`, поэтому состояние живёт в сторе, а не в компоненте.
|
||||||
|
*/
|
||||||
|
paletteOpen: boolean;
|
||||||
|
openPalette: () => void;
|
||||||
|
closePalette: () => void;
|
||||||
/**
|
/**
|
||||||
* Развёрнут ли список друзей во вкладке «Друзья». Свёрнут по умолчанию, а
|
* Развёрнут ли список друзей во вкладке «Друзья». Свёрнут по умолчанию, а
|
||||||
* выбор пользователя переживает перезагрузку страницы.
|
* выбор пользователя переживает перезагрузку страницы.
|
||||||
@@ -114,6 +121,10 @@ export const useUiStore = create<UiState>((set, get) => ({
|
|||||||
settingsSection: 'profile',
|
settingsSection: 'profile',
|
||||||
settingsProfileTab: 'main',
|
settingsProfileTab: 'main',
|
||||||
|
|
||||||
|
paletteOpen: false,
|
||||||
|
openPalette: () => set({ paletteOpen: true }),
|
||||||
|
closePalette: () => set({ paletteOpen: false }),
|
||||||
|
|
||||||
openSettings: (target) => {
|
openSettings: (target) => {
|
||||||
// Выбранный сервер живёт в снапшоте сессии: так его видят и секции
|
// Выбранный сервер живёт в снапшоте сессии: так его видят и секции
|
||||||
// «Опасной зоны», которые сбрасывают выбор после выхода с сервера.
|
// «Опасной зоны», которые сбрасывают выбор после выхода с сервера.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 <span data-testid="location">{location.pathname}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снапшот 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: (
|
||||||
|
<>
|
||||||
|
<CommandPalette />
|
||||||
|
<LocationProbe />
|
||||||
|
{/* Поле вне палитры: проверяем, что обычный ввод не перехватывается. */}
|
||||||
|
<input aria-label="Ввод" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ initialEntries: [path] },
|
||||||
|
);
|
||||||
|
render(<RouterProvider router={router} />);
|
||||||
|
return { router };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Открывает палитру шорткатом Ctrl+K и возвращает поле поиска. */
|
||||||
|
async function openWithShortcut(): Promise<HTMLElement> {
|
||||||
|
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(<DesktopBridge />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,15 @@ describe('routeFromDeepLink', () => {
|
|||||||
expect(routeFromDeepLink('glchat://guild/10/channel/20')).toBe('/app/10/20');
|
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('игнорирует чужие схемы и неизвестные пути', () => {
|
it('игнорирует чужие схемы и неизвестные пути', () => {
|
||||||
expect(routeFromDeepLink('https://gl.mhspx.su/invite/abc')).toBeNull();
|
expect(routeFromDeepLink('https://gl.mhspx.su/invite/abc')).toBeNull();
|
||||||
expect(routeFromDeepLink('glchat://unknown/1')).toBeNull();
|
expect(routeFromDeepLink('glchat://unknown/1')).toBeNull();
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> }) {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
useUnsavedChanges({ dirty: value !== '', save });
|
||||||
|
return (
|
||||||
|
<input aria-label="Черновик" value={value} onChange={(event) => setValue(event.target.value)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Приложение с реестром несохранённых изменений и формой-черновиком. */
|
||||||
|
function renderGuard(save: () => Promise<void>) {
|
||||||
|
return render(
|
||||||
|
<>
|
||||||
|
<DesktopBridge />
|
||||||
|
<DirtyCloseGuard>
|
||||||
|
<DraftForm save={save} />
|
||||||
|
</DirtyCloseGuard>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Правит форму так, чтобы она стала dirty. */
|
||||||
|
async function editDraft(): Promise<void> {
|
||||||
|
await userEvent.type(screen.getByLabelText('Черновик'), 'п');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(desktop.setDirty).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Просит обёртку закрыть окно (сообщение Rust `closeRequested`). */
|
||||||
|
async function requestClose(): Promise<void> {
|
||||||
|
// Промис в `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 });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user