feat(desktop): Tier A desktop features — web side (P5-46/36/44/43/49/47/55/57)
Web half of the desktop feature wave. A shared bridge (`hooks/useTauri.ts`: invokeTauri/isTauri/useTauriEvent) backs per-feature hooks that no-op in the browser and drive the native Tauri commands (compiled in cinny-desktop): - P5-46 useTauriCallPower — hold system awake while a call is active. - P5-36 useTauriJumpList — Windows jump list of recent rooms → matrix: deep links. - P5-44 useTauriThumbbar — taskbar Mute/Deafen/End; events toggle mic/sound/hangup. - P5-43 useTauriSmtc — SMTC call state + button events. - P5-49 useTauriNetwork — react to native network-change → mx.retryImmediately(). - P5-47 window chrome — opt-in `customWindowChromeAtom` + TDS `TitleBar`; DesktopChrome wrapper in App.tsx (zero layout impact when off) + a desktop-only settings toggle. - P5-55 composer toolbar drag-reorder (settings order[] + pragmatic-drag-and-drop). - P5-57 DraftIndicator — subtle "draft saved" cue in the composer. Client-scoped hooks mount via TauriDesktopFeatures in ClientNonUIFeatures; window chrome mounts at App level. Gates: tsc/eslint/prettier clean, build OK, 556 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
// Tauri v2 injects `__TAURI_INTERNALS__` into the webview at runtime; we use it
|
||||
// directly so cinny doesn't need `@tauri-apps/api` as a dependency. Native Rust
|
||||
// modules push data back to the web by dispatching DOM CustomEvents (see
|
||||
// `emit_to_web` in cinny-desktop's `native` module), which `useTauriEvent`
|
||||
// subscribes to. This module is the single source for the desktop bridge that
|
||||
// every `useTauri*` feature hook builds on.
|
||||
type Invoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
export const tauriInvoke = (): Invoke | undefined =>
|
||||
(window as unknown as { __TAURI_INTERNALS__?: { invoke: Invoke } }).__TAURI_INTERNALS__?.invoke;
|
||||
|
||||
export const isTauri = (): boolean => tauriInvoke() !== undefined;
|
||||
|
||||
/** Fire-and-forget invoke that no-ops (and never throws) outside Tauri. */
|
||||
export const invokeTauri = (cmd: string, args?: Record<string, unknown>): void => {
|
||||
tauriInvoke()?.(cmd, args).catch(() => undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to a CustomEvent dispatched from the Rust side via `emit_to_web`.
|
||||
* The handler is kept in a ref so callers don't need to memoize it to avoid
|
||||
* re-subscribing. No-op outside Tauri.
|
||||
*/
|
||||
export function useTauriEvent<T = unknown>(name: string, handler: (detail: T) => void): void {
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return undefined;
|
||||
const listener = (e: Event): void => handlerRef.current((e as CustomEvent<T>).detail);
|
||||
window.addEventListener(name, listener);
|
||||
return () => window.removeEventListener(name, listener);
|
||||
}, [name]);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { callEmbedAtom } from '../state/callEmbed';
|
||||
import { invokeTauri } from './useTauri';
|
||||
|
||||
/**
|
||||
* P5-46 — keep the system awake during calls (call continuity). Mirrors the
|
||||
* call-embed atom (undefined = no active call) onto the native `set_call_active`
|
||||
* command, which holds a `SetThreadExecutionState` request on Windows while a
|
||||
* voice/video call is active and releases it when the call ends. No-op in the
|
||||
* browser.
|
||||
*/
|
||||
export function useTauriCallPower(): void {
|
||||
const callEmbed = useAtomValue(callEmbedAtom);
|
||||
useEffect(() => {
|
||||
invokeTauri('set_call_active', { active: callEmbed !== undefined });
|
||||
}, [callEmbed]);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { allRoomsAtom } from '../state/room-list/roomList';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { isTauri, invokeTauri } from './useTauri';
|
||||
|
||||
/** Cap the Jump List to a small, glanceable set of rooms. */
|
||||
const MAX_ITEMS = 8;
|
||||
|
||||
/** Wait for room activity to settle before re-publishing the (native) list. */
|
||||
const DEBOUNCE_MS = 1500;
|
||||
|
||||
type JumpItem = { title: string; uri: string };
|
||||
|
||||
/**
|
||||
* Build the `matrix:` deep link the desktop deep-link handler understands (see
|
||||
* `useDeepLinkNavigate`): `matrix:r/<alias>` for a canonical alias, otherwise
|
||||
* `matrix:roomid/<id>`. The sigil is dropped and the remainder is percent-encoded
|
||||
* because the handler decodes each segment with `decodeURIComponent`.
|
||||
*/
|
||||
const roomToUri = (room: Room): string => {
|
||||
const alias = room.getCanonicalAlias();
|
||||
if (alias && alias.startsWith('#')) {
|
||||
return `matrix:r/${encodeURIComponent(alias.slice(1))}`;
|
||||
}
|
||||
return `matrix:roomid/${encodeURIComponent(room.roomId.slice(1))}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* P5-36 — publish a Windows taskbar Jump List of the most recently-active rooms.
|
||||
* Rooms come from `allRoomsAtom` (the joined-room list), sorted by
|
||||
* `getLastActiveTimestamp` (mirroring the sort used elsewhere, e.g. the forward
|
||||
* dialog), with spaces excluded. The list is pushed to the native
|
||||
* `set_jump_list` command, debounced so bursts of activity don't thrash the
|
||||
* shell. No-op outside Tauri.
|
||||
*/
|
||||
export function useTauriJumpList(): void {
|
||||
const mx = useMatrixClient();
|
||||
const allRooms = useAtomValue(allRoomsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return undefined;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const items: JumpItem[] = allRooms
|
||||
.map((roomId) => mx.getRoom(roomId))
|
||||
.filter((room): room is Room => room !== null && !room.isSpaceRoom())
|
||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0))
|
||||
.slice(0, MAX_ITEMS)
|
||||
.map((room) => ({ title: room.name || room.roomId, uri: roomToUri(room) }));
|
||||
|
||||
invokeTauri('set_jump_list', { items });
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [mx, allRooms]);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useTauriEvent } from './useTauri';
|
||||
|
||||
/** Detail shape of the `network-changed` event emitted by the native side. */
|
||||
type NetworkChangedDetail = {
|
||||
online: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* P5-49 — Network awareness (desktop). Subscribes to the native
|
||||
* `network-changed` event (Windows Network List Manager poll, `{ online }`) and,
|
||||
* on a transition back to online, calls `mx.retryImmediately()` so the sync loop
|
||||
* retries its backed-off `/sync` at once instead of waiting out the backoff
|
||||
* timer. Returns the last known connectivity (`undefined` until the first
|
||||
* event). Inert in the browser, since `useTauriEvent` only listens under Tauri.
|
||||
*/
|
||||
export function useTauriNetwork(): boolean | undefined {
|
||||
const mx = useMatrixClient();
|
||||
const [online, setOnline] = useState<boolean | undefined>(undefined);
|
||||
// Track the previous value in a ref so we can detect an offline -> online
|
||||
// transition without adding it to a dependency list.
|
||||
const onlineRef = useRef<boolean | undefined>(undefined);
|
||||
|
||||
useTauriEvent<NetworkChangedDetail>('network-changed', ({ online: next }) => {
|
||||
const previous = onlineRef.current;
|
||||
onlineRef.current = next;
|
||||
setOnline(next);
|
||||
// Only nudge the client when connectivity is (re)gained. The initial event
|
||||
// (previous === undefined) also triggers a retry, which is safe: it's a
|
||||
// no-op if nothing is backed off.
|
||||
if (next && previous !== true) {
|
||||
mx.retryImmediately();
|
||||
}
|
||||
});
|
||||
|
||||
return online;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { callEmbedAtom } from '../state/callEmbed';
|
||||
import { useCallControlState } from '../plugins/call';
|
||||
import { invokeTauri, useTauriEvent } from './useTauri';
|
||||
|
||||
/**
|
||||
* P5-43 — expose the active call to the Windows System Media Transport Controls
|
||||
* (the volume-flyout / media overlay). Mirrors the call-embed atom (undefined =
|
||||
* no active call) and the current mic state onto the native
|
||||
* `set_smtc_call_state` command, and translates SMTC button presses back into
|
||||
* call actions:
|
||||
* - Play/Pause (`smtc-action` → `mute`) toggles the microphone.
|
||||
* - Stop (`smtc-action` → `end`) hangs up the call.
|
||||
* No-op in the browser (the native command and events only fire under Tauri).
|
||||
*/
|
||||
type SmtcAction = { action: 'mute' | 'end' };
|
||||
|
||||
export function useTauriSmtc(): void {
|
||||
const callEmbed = useAtomValue(callEmbedAtom);
|
||||
// `microphone` reflects mic-enabled; muted is its inverse while in a call.
|
||||
const { microphone } = useCallControlState(callEmbed?.control);
|
||||
const active = callEmbed !== undefined;
|
||||
const muted = active && !microphone;
|
||||
|
||||
useEffect(() => {
|
||||
invokeTauri('set_smtc_call_state', { active, muted });
|
||||
}, [active, muted]);
|
||||
|
||||
useTauriEvent<SmtcAction>('smtc-action', ({ action }) => {
|
||||
if (!callEmbed) return;
|
||||
if (action === 'mute') {
|
||||
callEmbed.control.toggleMicrophone().catch(() => undefined);
|
||||
} else if (action === 'end') {
|
||||
callEmbed.hangup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { callEmbedAtom } from '../state/callEmbed';
|
||||
import { useCallControlState } from '../plugins/call';
|
||||
import { invokeTauri, useTauriEvent } from './useTauri';
|
||||
|
||||
type ThumbbarAction = { action: 'mute' | 'deafen' | 'end' };
|
||||
|
||||
/**
|
||||
* P5-44 — Taskbar thumbnail toolbar (call controls). While a call is active,
|
||||
* mirrors the mic/sound state onto the native `set_thumbbar` command (three
|
||||
* Mute / Deafen / End-Call buttons on the Windows taskbar thumbnail toolbar) and
|
||||
* hides them when the call ends. Thumb-button clicks come back as the
|
||||
* `thumbbar-action` event and drive the real call controls. No-op in the browser.
|
||||
*/
|
||||
export function useTauriThumbbar(): void {
|
||||
const callEmbed = useAtomValue(callEmbedAtom);
|
||||
const { microphone, sound } = useCallControlState(callEmbed?.control);
|
||||
|
||||
const active = callEmbed !== undefined;
|
||||
// Muted / deafened only make sense while a call is active; report false
|
||||
// otherwise so the buttons render in a sane (hidden) state.
|
||||
const muted = active && !microphone;
|
||||
const deafened = active && !sound;
|
||||
|
||||
useEffect(() => {
|
||||
invokeTauri('set_thumbbar', { active, muted, deafened });
|
||||
}, [active, muted, deafened]);
|
||||
|
||||
useTauriEvent<ThumbbarAction>('thumbbar-action', ({ action }) => {
|
||||
if (!callEmbed) return;
|
||||
if (action === 'mute') {
|
||||
// toggleMicrophone flips the mic; `microphone === false` means muted.
|
||||
callEmbed.control.toggleMicrophone();
|
||||
} else if (action === 'deafen') {
|
||||
// toggleSound flips local audio; `sound === false` means deafened. It also
|
||||
// mutes the mic while deafened, matching the in-app Deafen control.
|
||||
callEmbed.control.toggleSound();
|
||||
} else if (action === 'end') {
|
||||
callEmbed.hangup();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { customWindowChromeAtom } from '../state/customWindowChrome';
|
||||
import { invokeTauri, isTauri } from './useTauri';
|
||||
|
||||
/**
|
||||
* P5-47 — drive the native window frame from the `customWindowChromeAtom`.
|
||||
*
|
||||
* On mount and whenever the atom changes, pushes the value onto the native
|
||||
* `set_custom_chrome` command: `enabled = true` strips the OS decorations so the
|
||||
* web `<TitleBar/>` can take over, `enabled = false` restores the native frame.
|
||||
* No-op in the browser (`isTauri()` guard), so it's safe to call unconditionally
|
||||
* from the app shell.
|
||||
*/
|
||||
export function useTauriWindowChrome(): void {
|
||||
const enabled = useAtomValue(customWindowChromeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
invokeTauri('set_custom_chrome', { enabled });
|
||||
}, [enabled]);
|
||||
}
|
||||
Reference in New Issue
Block a user