feat(desktop): обёртка Tauri 2 вокруг веб-клиента
Первый инкремент Фазы 6 (docs/client-tauri.md): окно с тем же веб-клиентом (по умолчанию https://gl.mhspx.su, адрес настраивается), трей с меню и индикатором непрочитанных, нативные уведомления, глобальный push-to-talk и переключение микрофона/deafen, автозапуск, single-instance, сохранение геометрии окна, deep links glchat://…, автообновление по подписанному манифесту. Связь страницы с обёрткой — через платформенный адаптер (web/src/lib/platform.ts) и минимальный типизированный набор команд; в веб-клиенте появились системные уведомления об упоминаниях и личных сообщениях и переключатель в настройках. В web/src/stores/gateway.ts вместе с вызовом уведомления лежат правки по дефектам realtime (READY и сессии) из параллельной волны: файл коммитится целиком по договорённости с координатором.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Suspense } from 'react';
|
||||
import { RouterProvider } from 'react-router';
|
||||
|
||||
import { DesktopBridge } from '@/components/DesktopBridge';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { router } from '@/router';
|
||||
@@ -20,6 +21,8 @@ export function RouteFallback() {
|
||||
export function App() {
|
||||
return (
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
{/* Мост desktop-обёртки: события платформы и счётчик непрочитанных. */}
|
||||
<DesktopBridge />
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { unreadCount, useMessagesStore } from '@/stores/messages';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
import { platform, routeFromDeepLink } from '@/lib/platform';
|
||||
import { router } from '@/router';
|
||||
|
||||
/**
|
||||
* Мост desktop-обёртки (docs/client-tauri.md §2).
|
||||
*
|
||||
* Обёртка присылает события платформы (глобальный push-to-talk, mute/deafen,
|
||||
* deep link, готовность обновления), а клиент отдаёт ей счётчик непрочитанных.
|
||||
* В браузере компонент безвреден: реализация платформы там пустая.
|
||||
*/
|
||||
export function DesktopBridge() {
|
||||
// События обёртки: глобальные шорткаты, deep links, обновления.
|
||||
useEffect(
|
||||
() =>
|
||||
platform.onMessage((message) => {
|
||||
const voice = useVoiceConnectionStore.getState();
|
||||
switch (message.type) {
|
||||
case 'ptt':
|
||||
// Удержание клавиши: микрофон включается, пока она нажата.
|
||||
voice.setPushToTalkActive(message.active);
|
||||
break;
|
||||
case 'toggleMic':
|
||||
void voice.setMicEnabled(!voice.micEnabled);
|
||||
break;
|
||||
case 'toggleDeafen':
|
||||
void voice.setDeafened(!voice.deafened);
|
||||
break;
|
||||
case 'deepLink': {
|
||||
const route = routeFromDeepLink(message.url);
|
||||
if (route === null) {
|
||||
console.warn('[desktop] неизвестная ссылка', message.url);
|
||||
break;
|
||||
}
|
||||
void router.navigate(route);
|
||||
break;
|
||||
}
|
||||
case 'updateAvailable':
|
||||
platform.notify({
|
||||
title: 'Доступно обновление glchat',
|
||||
body: `Версия ${message.version}. Установить можно из настроек приложения.`,
|
||||
});
|
||||
break;
|
||||
case 'settings':
|
||||
// Настройки обёртки клиенту не нужны: он читает только свои.
|
||||
break;
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Обёртка отдаёт накопленные deep links после этого сигнала.
|
||||
useEffect(() => {
|
||||
platform.ready();
|
||||
}, []);
|
||||
|
||||
// Счётчик непрочитанных для значка приложения и иконки в трее.
|
||||
useEffect(() => {
|
||||
let last = -1;
|
||||
const sync = (): void => {
|
||||
const channels = useMessagesStore.getState().channels;
|
||||
let total = 0;
|
||||
for (const record of Object.values(channels)) {
|
||||
total += unreadCount(record);
|
||||
}
|
||||
if (total !== last) {
|
||||
last = total;
|
||||
platform.setBadge(total);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
return useMessagesStore.subscribe(sync);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1107,8 +1107,11 @@
|
||||
"navLabel": "Settings sections",
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Sounds, mentions and web push.",
|
||||
"stub": "Notification settings are coming later."
|
||||
"description": "System notifications for mentions and direct messages.",
|
||||
"desktop": "Desktop notifications",
|
||||
"desktopHint": "Show system notifications when the window is inactive or the channel is not open. Native in the desktop app, permission-based in the browser.",
|
||||
"desktopSettings": "Desktop app settings",
|
||||
"unsupported": "This browser does not show system notifications: open the client in the desktop app."
|
||||
},
|
||||
"voice": {
|
||||
"title": "Audio & video",
|
||||
|
||||
@@ -1107,8 +1107,11 @@
|
||||
"navLabel": "Пункты настроек",
|
||||
"notifications": {
|
||||
"title": "Уведомления",
|
||||
"description": "Звуки, упоминания и веб-пуш.",
|
||||
"stub": "Настройки уведомлений появятся позже."
|
||||
"description": "Системные уведомления об упоминаниях и личных сообщениях.",
|
||||
"desktop": "Уведомления рабочего стола",
|
||||
"desktopHint": "Показывать системные уведомления, когда окно неактивно или комната не открыта. В desktop-приложении уведомления нативные, в браузере — при разрешённых уведомлениях.",
|
||||
"desktopSettings": "Настройки desktop-приложения",
|
||||
"unsupported": "Браузер не показывает системные уведомления: откройте клиент в desktop-приложении."
|
||||
},
|
||||
"voice": {
|
||||
"title": "Аудио-видео",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Нативные уведомления desktop-обёртки (docs/client-tauri.md §5).
|
||||
*
|
||||
* Решение «показывать или нет» принимает клиент: только упоминания и личные
|
||||
* сообщения, только когда окно неактивно или комната не открыта, и только если
|
||||
* пользователь не выключил уведомления в настройках. Текст уведомления готовит
|
||||
* этот модуль, показывает — платформенный адаптер.
|
||||
*/
|
||||
|
||||
import type { Message } from '@/api/messages';
|
||||
import type { DesktopNotification } from '@/lib/platform';
|
||||
|
||||
/** Ключ предпочтения в localStorage (настройка устройства, не аккаунта). */
|
||||
const STORAGE_KEY = 'glchat.desktop-notifications';
|
||||
|
||||
/** Сколько символов сообщения показываем в уведомлении. */
|
||||
const PREVIEW_LIMIT = 140;
|
||||
|
||||
/** Включены ли уведомления рабочего стола (по умолчанию — да). */
|
||||
export function notificationsEnabled(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) !== 'off';
|
||||
} catch {
|
||||
// Приватный режим браузера может запретить localStorage — считаем включёнными.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Переключает уведомления рабочего стола. */
|
||||
export function setNotificationsEnabled(enabled: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, enabled ? 'on' : 'off');
|
||||
} catch {
|
||||
// Без localStorage предпочтение живёт до перезагрузки страницы.
|
||||
}
|
||||
}
|
||||
|
||||
/** Данные, от которых зависит решение об уведомлении. */
|
||||
export interface NotificationInput {
|
||||
message: Pick<Message, 'type' | 'content' | 'mentions' | 'channel_id' | 'author_id'>;
|
||||
/** Мой идентификатор: свои сообщения не уведомляют. */
|
||||
meId: string | null;
|
||||
/** Комната — личная беседа. */
|
||||
isDirect: boolean;
|
||||
/** Имя комнаты для заголовка. */
|
||||
channelName: string | null;
|
||||
/** Сервер комнаты (в личной беседе — null). */
|
||||
guildId: string | null;
|
||||
/** Комната открыта в клиенте. */
|
||||
isChannelOpen: boolean;
|
||||
/** Окно клиента в фокусе. */
|
||||
windowFocused: boolean;
|
||||
/** Уведомления включены пользователем. */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Убирает Markdown-разметку и лишние переводы строк из текста сообщения. */
|
||||
export function previewText(content: string): string {
|
||||
const plain = content
|
||||
.replace(/```[\s\S]*?```/g, ' [код] ')
|
||||
.replace(/`([^`]*)`/g, '$1')
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[*_~>#]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (plain.length <= PREVIEW_LIMIT) {
|
||||
return plain;
|
||||
}
|
||||
return `${plain.slice(0, PREVIEW_LIMIT - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Текст уведомления или `null`, если показывать не нужно.
|
||||
*
|
||||
* Чистая функция: её поведение проверяется тестами без DOM и без обёртки.
|
||||
*/
|
||||
export function notificationFor(input: NotificationInput): DesktopNotification | null {
|
||||
if (!input.enabled) {
|
||||
return null;
|
||||
}
|
||||
const { message } = input;
|
||||
// Системные сообщения (входы, баны) уведомлений не требуют.
|
||||
if (message.type === 'system') {
|
||||
return null;
|
||||
}
|
||||
if (input.meId !== null && message.author_id === input.meId) {
|
||||
return null;
|
||||
}
|
||||
const mentionsMe = input.meId !== null && message.mentions.includes(input.meId);
|
||||
if (!mentionsMe && !input.isDirect) {
|
||||
return null;
|
||||
}
|
||||
// Комната открыта и окно активно — пользователь и так видит сообщение.
|
||||
if (input.isChannelOpen && input.windowFocused) {
|
||||
return null;
|
||||
}
|
||||
const body = previewText(message.content);
|
||||
if (body === '' && message.mentions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const title = input.isDirect
|
||||
? input.channelName === null || input.channelName === ''
|
||||
? 'Личное сообщение'
|
||||
: `Личное сообщение — ${input.channelName}`
|
||||
: input.channelName === null || input.channelName === ''
|
||||
? 'Упоминание'
|
||||
: `Упоминание в #${input.channelName}`;
|
||||
return {
|
||||
title,
|
||||
body: body === '' ? 'Вложение' : body,
|
||||
guildId: input.guildId,
|
||||
channelId: message.channel_id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Платформенный адаптер клиента (docs/client-tauri.md §2).
|
||||
*
|
||||
* Один и тот же веб-код работает и в браузере, и внутри desktop-обёртки Tauri:
|
||||
* реализация выбирается при старте по наличию моста Tauri, а вызовы платформы
|
||||
* идут только через этот модуль. В браузере доступно не всё (нативные
|
||||
* уведомления — по разрешению браузера, трея и бейджа нет), поэтому у каждой
|
||||
* реализации свои возможности, а не заглушки «на будущее».
|
||||
*/
|
||||
|
||||
/** Настройки обёртки: поля совпадают с Rust-структурой `Settings`. */
|
||||
export interface DesktopSettings {
|
||||
/** Адрес инстанса, загруженного в окно. */
|
||||
instanceUrl: string;
|
||||
/** Закрытие окна сворачивает приложение в трей. */
|
||||
closeToTray: boolean;
|
||||
/** Автозапуск при входе в систему. */
|
||||
autostart: boolean;
|
||||
/** Вопрос об автозапуске уже задавали. */
|
||||
autostartPrompted: boolean;
|
||||
pttShortcut: string;
|
||||
muteShortcut: string;
|
||||
deafenShortcut: string;
|
||||
/** Нативные уведомления включены. */
|
||||
notifications: boolean;
|
||||
}
|
||||
|
||||
/** Сообщение из обёртки (см. `WebMessage` в desktop/src-tauri/src/bridge.rs). */
|
||||
export type DesktopMessage =
|
||||
| { type: 'ptt'; active: boolean }
|
||||
| { type: 'toggleMic' }
|
||||
| { type: 'toggleDeafen' }
|
||||
| { type: 'deepLink'; url: string }
|
||||
| { type: 'settings'; settings: DesktopSettings }
|
||||
| { type: 'updateAvailable'; version: string };
|
||||
|
||||
/** Уведомление для системы. Текст готовит клиент, показывает — платформа. */
|
||||
export interface DesktopNotification {
|
||||
title: string;
|
||||
body: string;
|
||||
guildId?: string | null;
|
||||
channelId?: string | null;
|
||||
}
|
||||
|
||||
export type PlatformKind = 'web' | 'tauri';
|
||||
|
||||
export interface Platform {
|
||||
readonly kind: PlatformKind;
|
||||
/** Показать системное уведомление (если платформа это умеет). */
|
||||
notify(notification: DesktopNotification): void;
|
||||
/** Число непрочитанных для значка приложения и трея. */
|
||||
setBadge(count: number): void;
|
||||
/** Сообщить обёртке, что клиент загрузился (deep links ждут этого). */
|
||||
ready(): void;
|
||||
/** Открыть страницу настроек обёртки. */
|
||||
openSettings(): void;
|
||||
/** Подписка на сообщения обёртки; возвращает функцию отписки. */
|
||||
onMessage(handler: (message: DesktopMessage) => void): () => void;
|
||||
}
|
||||
|
||||
/** Мост Tauri v2: этого объекта в браузере нет. */
|
||||
interface TauriInternals {
|
||||
invoke(command: string, args?: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface DesktopBridgeGlobal {
|
||||
__TAURI_INTERNALS__?: TauriInternals;
|
||||
__glchatDesktop?: { receive: (message: DesktopMessage) => void };
|
||||
}
|
||||
|
||||
function tauri(): TauriInternals | null {
|
||||
const bridge = window as unknown as DesktopBridgeGlobal;
|
||||
const internals = bridge.__TAURI_INTERNALS__;
|
||||
return internals !== undefined && typeof internals.invoke === 'function' ? internals : null;
|
||||
}
|
||||
|
||||
/** Открыт ли клиент внутри desktop-обёртки. */
|
||||
export function isTauri(): boolean {
|
||||
return tauri() !== null;
|
||||
}
|
||||
|
||||
/** Реализация для браузера: системных возможностей почти нет. */
|
||||
const webPlatform: Platform = {
|
||||
kind: 'web',
|
||||
notify(notification) {
|
||||
// В браузере показываем только при уже выданном разрешении: запрашивать
|
||||
// его без действия пользователя нельзя, а веб-пуш — отдельная фаза.
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new Notification(notification.title, { body: notification.body });
|
||||
} catch {
|
||||
// Браузер может запретить уведомление (например, в фоновой вкладке) —
|
||||
// это не ошибка клиента.
|
||||
}
|
||||
},
|
||||
setBadge(count) {
|
||||
// В браузере счётчик виден в заголовке вкладки: это делает сам клиент.
|
||||
void count;
|
||||
},
|
||||
ready() {},
|
||||
openSettings() {},
|
||||
onMessage() {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
/** Реализация для desktop-обёртки: тонкие вызовы команд Rust. */
|
||||
function tauriPlatform(internals: TauriInternals): Platform {
|
||||
const handlers = new Set<(message: DesktopMessage) => void>();
|
||||
const bridge = window as unknown as DesktopBridgeGlobal;
|
||||
bridge.__glchatDesktop = {
|
||||
receive(message) {
|
||||
for (const handler of handlers) {
|
||||
handler(message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Команды обёртки не должны ронять клиент: ошибку только логируем. */
|
||||
const call = (command: string, args?: Record<string, unknown>): void => {
|
||||
void internals.invoke(command, args).catch((error: unknown) => {
|
||||
console.warn(`[desktop] команда ${command} не выполнена`, error);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
kind: 'tauri',
|
||||
notify(notification) {
|
||||
call('desktop_notify', {
|
||||
payload: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
guildId: notification.guildId ?? null,
|
||||
channelId: notification.channelId ?? null,
|
||||
},
|
||||
});
|
||||
},
|
||||
setBadge(count) {
|
||||
call('desktop_set_badge', { count });
|
||||
},
|
||||
ready() {
|
||||
call('desktop_ready');
|
||||
},
|
||||
openSettings() {
|
||||
call('desktop_open_settings');
|
||||
},
|
||||
onMessage(handler) {
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const internals = tauri();
|
||||
|
||||
/** Платформа текущего запуска: браузер или desktop-обёртка. */
|
||||
export const platform: Platform = internals === null ? webPlatform : tauriPlatform(internals);
|
||||
|
||||
/**
|
||||
* Маршрут клиента для deep link обёртки (docs/client-tauri.md §5).
|
||||
*
|
||||
* Поддерживаются `glchat://invite/<code>` и
|
||||
* `glchat://guild/<guildId>/channel/<channelId>`; неизвестная ссылка даёт `null`.
|
||||
*/
|
||||
export function routeFromDeepLink(link: string): string | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(link);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'glchat:') {
|
||||
return null;
|
||||
}
|
||||
const segments = url.pathname.split('/').filter((segment) => segment !== '');
|
||||
// В `glchat://invite/ABC` хост — это первый сегмент пути.
|
||||
const [kind, ...rest] = [url.hostname, ...segments];
|
||||
if (kind === 'invite') {
|
||||
const code = rest[0];
|
||||
return rest.length === 1 && code !== undefined ? `/invite/${encodeURIComponent(code)}` : null;
|
||||
}
|
||||
if (kind === 'guild') {
|
||||
const [guildId, marker, channelId] = rest;
|
||||
if (
|
||||
rest.length !== 3 ||
|
||||
marker !== 'channel' ||
|
||||
guildId === undefined ||
|
||||
channelId === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `/app/${encodeURIComponent(guildId)}/${encodeURIComponent(channelId)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,22 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { notificationsEnabled, setNotificationsEnabled } from '@/lib/desktopNotifications';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
/**
|
||||
* «Уведомления»: пункт уже есть в списке настроек, но сами уведомления
|
||||
* (веб-пуш и звуки) появятся в следующих фазах — показываем заглушку.
|
||||
* «Уведомления»: системные уведомления об упоминаниях и личных сообщениях.
|
||||
*
|
||||
* Предпочтение хранится на устройстве (localStorage): в desktop-обёртке
|
||||
* уведомления нативные, в браузере — только при выданном разрешении, а
|
||||
* веб-пуш (Фаза 7) появится отдельным пунктом.
|
||||
*/
|
||||
export function NotificationsSettingsSection() {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(notificationsEnabled);
|
||||
// В обёртке уведомления всегда доступны, в браузере — по разрешению.
|
||||
const supported = platform.kind === 'tauri' || 'Notification' in window;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.notifications.title')}
|
||||
description={t('settings.notifications.description')}
|
||||
>
|
||||
<p className="mt-3 text-sm text-fg-muted" role="status">
|
||||
{t('settings.notifications.stub')}
|
||||
</p>
|
||||
<label className="mt-3 flex items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={enabled}
|
||||
onChange={(event) => {
|
||||
setEnabled(event.target.checked);
|
||||
setNotificationsEnabled(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">{t('settings.notifications.desktop')}</span>
|
||||
<span className="block text-fg-muted">{t('settings.notifications.desktopHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{supported ? null : (
|
||||
<p className="mt-3 text-sm text-fg-muted" role="status">
|
||||
{t('settings.notifications.unsupported')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{platform.kind === 'tauri' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 rounded-md border border-border px-3 py-1.5 text-sm hover:border-accent"
|
||||
onClick={() => {
|
||||
platform.openSettings();
|
||||
}}
|
||||
>
|
||||
{t('settings.notifications.desktopSettings')}
|
||||
</button>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
parseUserUpdate,
|
||||
} from '@/api/gatewayEvents';
|
||||
import { guildSoundsQueryKey } from '@/api/sounds';
|
||||
import { parseSystemMessage } from '@/lib/systemMessage';
|
||||
import { useFriendsStore } from '@/stores/friends';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore, type SessionGuild } from '@/stores/session';
|
||||
@@ -39,6 +38,9 @@ import { playEventSound, useSoundboardStore } from '@/stores/soundboard';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
import { parseVoiceStateEvent } from '@/api/voice';
|
||||
import type { Message } from '@/api/messages';
|
||||
import { notificationFor, notificationsEnabled } from '@/lib/desktopNotifications';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { parseSystemMessage } from '@/lib/systemMessage';
|
||||
|
||||
/**
|
||||
* Состояние соединения со шлюзом. Данные приходят в session-store,
|
||||
@@ -232,6 +234,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
// В личной беседе «последнее сообщение» — источник бейджа и порядка.
|
||||
session.touchDmChannel(message.channel_id, message.id, message.created_at);
|
||||
playMessageSound(message);
|
||||
notifyAboutMessage(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -448,6 +451,37 @@ function playMessageSound(message: Message): void {
|
||||
playEventSound(guildId, me !== null && message.mentions.includes(me) ? 'mention' : 'message');
|
||||
}
|
||||
|
||||
/**
|
||||
* Системное уведомление об упоминании или личном сообщении
|
||||
* (docs/client-tauri.md §5). Решение принимает `notificationFor`,
|
||||
* а показывает его платформенный адаптер.
|
||||
*/
|
||||
function notifyAboutMessage(message: Message): void {
|
||||
const session = useSessionStore.getState();
|
||||
const me = session.user?.id ?? null;
|
||||
const dmChannel = session.dmChannels.find((channel) => channel.id === message.channel_id);
|
||||
const guildId = guildIdOfChannel(session.guilds, message.channel_id);
|
||||
const guildChannel =
|
||||
guildId === null
|
||||
? undefined
|
||||
: session.guilds
|
||||
.find((guild) => guild.id === guildId)
|
||||
?.channels.find((channel) => channel.id === message.channel_id);
|
||||
const notification = notificationFor({
|
||||
message,
|
||||
meId: me,
|
||||
isDirect: dmChannel !== undefined,
|
||||
channelName: dmChannel?.name ?? guildChannel?.name ?? null,
|
||||
guildId,
|
||||
isChannelOpen: session.selectedChannelId === message.channel_id,
|
||||
windowFocused: document.hasFocus(),
|
||||
enabled: notificationsEnabled(),
|
||||
});
|
||||
if (notification !== null) {
|
||||
platform.notify(notification);
|
||||
}
|
||||
}
|
||||
|
||||
/** Сервер, которому принадлежит комната: в личной беседе его нет. */
|
||||
function guildIdOfChannel(guilds: SessionGuild[], channelId: string): string | null {
|
||||
for (const guild of guilds) {
|
||||
@@ -466,6 +500,10 @@ async function addGuildFromRest(guildId: string): Promise<void> {
|
||||
]);
|
||||
const client = getQueryClient();
|
||||
try {
|
||||
// Список серверов мог быть прочитан раньше (кэш свеж 30 секунд), поэтому
|
||||
// сначала помечаем его устаревшим: без этого возврат в сервер после кика
|
||||
// не появлялся в рейке до перезагрузки (AGENT.md 11.6).
|
||||
await client.invalidateQueries({ queryKey: users.myGuildsQueryKey });
|
||||
const guilds = await client.fetchQuery({
|
||||
queryKey: users.myGuildsQueryKey,
|
||||
queryFn: () => users.fetchMyGuilds(),
|
||||
@@ -475,8 +513,7 @@ async function addGuildFromRest(guildId: string): Promise<void> {
|
||||
useSessionStore.getState().upsertGuild(guild);
|
||||
}
|
||||
} catch {
|
||||
// Сеть подвела — просто помечаем данные устаревшими для следующего рендера.
|
||||
await client.invalidateQueries({ queryKey: users.myGuildsQueryKey });
|
||||
// Сеть подвела — данные уже помечены устаревшими для следующего рендера.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,10 +605,11 @@ async function invalidateCurrentUser(): Promise<void> {
|
||||
import('@/lib/queryClient'),
|
||||
import('@/api/users'),
|
||||
]);
|
||||
const client = getQueryClient();
|
||||
// Именно refetch, а не removeQueries: AuthGuard узнаёт о мёртвой сессии по
|
||||
// 401 на запросе профиля и только тогда уводит на экран входа (AGENT.md 11.6).
|
||||
getQueryClient().removeQueries({ queryKey: currentUserQueryKey });
|
||||
await getQueryClient().refetchQueries({ queryKey: currentUserQueryKey });
|
||||
client.removeQueries({ queryKey: currentUserQueryKey });
|
||||
await client.refetchQueries({ queryKey: currentUserQueryKey });
|
||||
}
|
||||
|
||||
async function refetchCurrentUser(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
notificationFor,
|
||||
notificationsEnabled,
|
||||
previewText,
|
||||
setNotificationsEnabled,
|
||||
} from '@/lib/desktopNotifications';
|
||||
import type { NotificationInput } from '@/lib/desktopNotifications';
|
||||
|
||||
/** Сообщение по умолчанию: обычное сообщение в канале сервера. */
|
||||
function input(overrides: Partial<NotificationInput> = {}): NotificationInput {
|
||||
return {
|
||||
message: {
|
||||
type: 'default',
|
||||
content: 'привет',
|
||||
mentions: [],
|
||||
channel_id: 'c1',
|
||||
author_id: 'u2',
|
||||
},
|
||||
meId: 'u1',
|
||||
isDirect: false,
|
||||
channelName: 'общий',
|
||||
guildId: 'g1',
|
||||
isChannelOpen: false,
|
||||
windowFocused: false,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('notificationFor', () => {
|
||||
it('уведомляет об упоминании в неактивной комнате', () => {
|
||||
const result = notificationFor(input({ message: { ...input().message, mentions: ['u1'] } }));
|
||||
expect(result).toMatchObject({ title: 'Упоминание в #общий', body: 'привет', channelId: 'c1' });
|
||||
});
|
||||
|
||||
it('уведомляет о личном сообщении', () => {
|
||||
const result = notificationFor(input({ isDirect: true, channelName: 'Аня', guildId: null }));
|
||||
expect(result).toMatchObject({ title: 'Личное сообщение — Аня', guildId: null });
|
||||
});
|
||||
|
||||
it('молчит об обычном сообщении в канале', () => {
|
||||
expect(notificationFor(input())).toBeNull();
|
||||
});
|
||||
|
||||
it('молчит о собственных сообщениях', () => {
|
||||
const result = notificationFor(
|
||||
input({
|
||||
isDirect: true,
|
||||
message: { ...input().message, author_id: 'u1' },
|
||||
}),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('молчит, когда открытая комната в фокусе', () => {
|
||||
const result = notificationFor(
|
||||
input({ isDirect: true, isChannelOpen: true, windowFocused: true }),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('уведомляет об открытой комнате, если окно не в фокусе', () => {
|
||||
const result = notificationFor(
|
||||
input({ isDirect: true, isChannelOpen: true, windowFocused: false }),
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('молчит при выключенных уведомлениях', () => {
|
||||
expect(notificationFor(input({ isDirect: true, enabled: false }))).toBeNull();
|
||||
});
|
||||
|
||||
it('пропускает системные сообщения', () => {
|
||||
const result = notificationFor(
|
||||
input({
|
||||
isDirect: true,
|
||||
message: { ...input().message, type: 'system' },
|
||||
}),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('подставляет «Вложение» для сообщения без текста', () => {
|
||||
const result = notificationFor(
|
||||
input({
|
||||
isDirect: true,
|
||||
message: { ...input().message, content: '', mentions: ['u1'] },
|
||||
}),
|
||||
);
|
||||
expect(result?.body).toBe('Вложение');
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewText', () => {
|
||||
it('убирает разметку и лишние переводы строк', () => {
|
||||
expect(previewText('**жирный**\n\n[ссылка](https://example.com) `код`')).toBe(
|
||||
'жирный ссылка код',
|
||||
);
|
||||
});
|
||||
|
||||
it('обрезает длинный текст', () => {
|
||||
const result = previewText('а'.repeat(400));
|
||||
expect(result.length).toBeLessThanOrEqual(140);
|
||||
expect(result.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('предпочтение уведомлений', () => {
|
||||
it('включено по умолчанию и выключается переключателем', () => {
|
||||
expect(notificationsEnabled()).toBe(true);
|
||||
setNotificationsEnabled(false);
|
||||
expect(notificationsEnabled()).toBe(false);
|
||||
setNotificationsEnabled(true);
|
||||
expect(notificationsEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('не падает, если localStorage недоступен', () => {
|
||||
const stub = vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
|
||||
throw new Error('нет доступа');
|
||||
});
|
||||
expect(notificationsEnabled()).toBe(true);
|
||||
stub.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isTauri, platform, routeFromDeepLink, type DesktopMessage } 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;
|
||||
}
|
||||
|
||||
describe('routeFromDeepLink', () => {
|
||||
it('разбирает приглашение', () => {
|
||||
expect(routeFromDeepLink('glchat://invite/AbC123')).toBe('/invite/AbC123');
|
||||
});
|
||||
|
||||
it('разбирает сервер и комнату', () => {
|
||||
expect(routeFromDeepLink('glchat://guild/10/channel/20')).toBe('/app/10/20');
|
||||
});
|
||||
|
||||
it('игнорирует чужие схемы и неизвестные пути', () => {
|
||||
expect(routeFromDeepLink('https://gl.mhspx.su/invite/abc')).toBeNull();
|
||||
expect(routeFromDeepLink('glchat://unknown/1')).toBeNull();
|
||||
expect(routeFromDeepLink('glchat://guild/10')).toBeNull();
|
||||
expect(routeFromDeepLink('не ссылка')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('платформа в браузере', () => {
|
||||
it('определяется как web и не падает на вызовах', () => {
|
||||
expect(isTauri()).toBe(false);
|
||||
expect(platform.kind).toBe('web');
|
||||
expect(() => {
|
||||
platform.notify({ title: 'a', body: 'b' });
|
||||
platform.setBadge(3);
|
||||
platform.ready();
|
||||
platform.openSettings();
|
||||
}).not.toThrow();
|
||||
expect(platform.onMessage(() => {})()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('платформа в desktop-обёртке', () => {
|
||||
it('вызывает команды обёртки и принимает её сообщения', async () => {
|
||||
const calls = stubTauri();
|
||||
vi.resetModules();
|
||||
const desktop = await import('@/lib/platform');
|
||||
|
||||
expect(desktop.isTauri()).toBe(true);
|
||||
expect(desktop.platform.kind).toBe('tauri');
|
||||
|
||||
desktop.platform.notify({ title: 'Упоминание', body: 'привет', channelId: 'c1' });
|
||||
desktop.platform.setBadge(2);
|
||||
desktop.platform.ready();
|
||||
desktop.platform.openSettings();
|
||||
|
||||
expect(calls.map((call) => call.command)).toEqual([
|
||||
'desktop_notify',
|
||||
'desktop_set_badge',
|
||||
'desktop_ready',
|
||||
'desktop_open_settings',
|
||||
]);
|
||||
expect(calls[0]?.args).toEqual({
|
||||
payload: { title: 'Упоминание', body: 'привет', guildId: null, channelId: 'c1' },
|
||||
});
|
||||
expect(calls[1]?.args).toEqual({ count: 2 });
|
||||
|
||||
// Обёртка зовёт страницу через window.__glchatDesktop.receive.
|
||||
const received: DesktopMessage[] = [];
|
||||
const unsubscribe = desktop.platform.onMessage((message) => received.push(message));
|
||||
const bridge = window as unknown as {
|
||||
__glchatDesktop?: { receive: (m: DesktopMessage) => void };
|
||||
};
|
||||
bridge.__glchatDesktop?.receive({ type: 'ptt', active: true });
|
||||
expect(received).toEqual([{ type: 'ptt', active: true }]);
|
||||
|
||||
unsubscribe();
|
||||
bridge.__glchatDesktop?.receive({ type: 'toggleMic' });
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -222,14 +222,20 @@ describe('настройки: окно и навигация', () => {
|
||||
expect(within(dialog).queryByRole('button', { name: 'Инстанс' })).toBeNull();
|
||||
});
|
||||
|
||||
it('«Уведомления» пока заглушка', async () => {
|
||||
it('«Уведомления» переключают уведомления рабочего стола', async () => {
|
||||
installFetch(appRoutes());
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
await goToSection(dialog, 'Уведомления');
|
||||
|
||||
expect(within(dialog).getByText('Настройки уведомлений появятся позже.')).toBeVisible();
|
||||
const toggle = within(dialog).getByRole('checkbox', { name: /Уведомления рабочего стола/ });
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await userEvent.click(toggle);
|
||||
|
||||
expect(toggle).not.toBeChecked();
|
||||
expect(window.localStorage.getItem('glchat.desktop-notifications')).toBe('off');
|
||||
});
|
||||
|
||||
it('«Аудио-видео» без mediaDevices честно сообщает о недоступности', async () => {
|
||||
|
||||
Reference in New Issue
Block a user