Compare commits

...

6 Commits

Author SHA1 Message Date
jared 258e3ec620 fix(desktop): address code-review findings on the desktop wave
CI / Build & Quality Checks (push) Successful in 10m40s
CI / Trigger Desktop Build (push) Successful in 8s
- fileEntries: a single unreadable file/dir in a dropped folder no longer aborts
  the whole traversal (try/catch per entry, skip failures) — was discarding ALL
  dropped files (incl. the flat-file path) + an unhandled rejection; also add
  .catch in both useFileDrop consumers.
- RoomInput: mirror a localStorage-restored draft into the draft atom so the
  P5-57 indicator reflects a persisted draft after a page reload, not only on
  same-session room re-entry.
- useTauriThumbbar: swallow toggleMicrophone()/hangup() rejections (parity with
  SMTC) — avoids an unhandled rejection when clicked mid-teardown.
- App/DesktopChrome: keep wrapper element types stable across the chrome toggle
  (display:contents when off) so flipping it no longer remounts RouterProvider.
- settings: normalizeComposerToolbarOrder also appends missing keys from the
  canonical key set (safety net if a new button is absent from the default order).

Gates: tsc/eslint/prettier clean, build OK, 559 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:40:31 -04:00
jared 3336abb66f docs: P5-42 done + final Tier C dispositions (P5-51/52/53)
- P5-42 → [~] IMPLEMENTED (pragmatic WebView2 keep-alive) + LOTUS_FEATURES entry.
- P5-51 → [DEFERRED] with a concrete future-work spec (single-session storage map:
  sessions.ts localStorage keys + initMatrix IndexedDB stores; the 6 things true
  per-context isolation needs; multi-account as the smaller intermediate step).
- P5-52 → [DROPPED] (matrix-js-sdk can't do true per-room sync filtering; only
  cosmetic client-side hiding).
- P5-53 → [DEFERRED] with the lighter automation-rules alternative recorded.

Every desktop P5 item is now dispositioned: implemented, won't-fix, or
deferred-with-spec/dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:27:23 -04:00
jared a184ee0221 docs: document desktop features (Tier A + B) across catalog/README/TODO
- LOTUS_FEATURES.md: new "Desktop App Features" section (+ TOC) covering all
  desktop capabilities — no-sleep, jump list, thumbbar, SMTC, network awareness,
  rich notifications, Focus Assist, window chrome, update toast, toolbar reorder,
  draft indicator, recursive folder DnD.
- README.md: "Desktop-Specific Features" bullets under the Desktop App section.
- LOTUS_TODO.md: P5-35/41/56 → [~] IMPLEMENTED (Tier B); P5-48 → [~] (recursive
  folder upload; .lnk/Send-To scoped-out with rationale).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:04:03 -04:00
jared 4509a2b6d3 feat(desktop): Tier B web side — toast actions, Focus Assist gate, folder DnD
- P5-41/35 useTauriToastActions: native rich-toast click → navigate(path) (opens
  the room), quick reply → mx.sendMessage(roomId, m.text). The desktop bridge
  routes message notifications (tag=roomId) to show_rich_toast.
- P5-56 useTauriFocusAssist + focusAssistActiveAtom: a native focus-assist-changed
  event drives the atom, OR'd into the existing quiet-hours gate in
  ClientNonUIFeatures so notifications+sounds suppress during Windows Focus Assist.
- P5-48 recursive folder drag-drop: fileEntries.ts (sync webkitGetAsEntry capture
  → async batched readEntries traversal, path-prefixed names, 500-file cap) wired
  into useFileDrop, reusing the existing upload pipeline. +3 unit tests.

Hooks no-op in the browser. Gates: tsc/eslint/prettier clean, build OK, 559 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:01:10 -04:00
jared 7e38baa7b6 docs(todo): mark desktop Tier A wave; P5-40 done, P5-50 won't-fix
- P5-36/43/44/46/47/49/55/57 → [~] IMPLEMENTED (web verified; native
  CI-compile-pending, runtime-verify on Windows).
- P5-40 → [x] DONE (TauriUpdateFeature already ships the update toast).
- P5-50 → [WON'T FIX] (can't inject Media Foundation into WebView2's WebRTC
  pipeline; Chromium already HW-decodes).
- P5-35 → note the "can't compile-test without Windows" premise is outdated
  (CI compiles Windows now); remains Tier B (rides with P5-41).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:10:04 -04:00
jared aab7e5ae20 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>
2026-07-01 09:07:03 -04:00
27 changed files with 1627 additions and 252 deletions
+59 -1
View File
@@ -25,7 +25,8 @@ Last updated: June 2026.
16. [Notifications](#notifications)
17. [Server Integration](#server-integration)
18. [Infrastructure](#infrastructure)
19. [Key Custom Files](#key-custom-files)
19. [Desktop App Features](#desktop-app-features)
20. [Key Custom Files](#key-custom-files)
---
@@ -1161,6 +1162,63 @@ The `encUrlPreview` setting defaults to `true` rather than `false`. A security a
---
## Desktop App Features
Native capabilities of the Lotus Chat **Tauri v2** desktop app (Windows, macOS, Linux) on top of the shared web client. Web hooks live in `src/app/hooks/useTauri*.ts` (each no-ops in the browser) and call Rust commands in `cinny-desktop/src-tauri/src/native/*`. Windows-only pieces are `#[cfg(target_os = "windows")]`, compile-verified in CI (Windows runners).
### Call Continuity — No-Sleep (P5-46)
Holds the system awake (`SetThreadExecutionState`) while a voice/video call is active; releases on end. `useTauriCallPower``native/power.rs`.
### Windows Jump List (P5-36)
Right-click the taskbar icon → a **Recent Rooms** list of your most-active rooms; each entry opens that room via the `matrix:` deep-link. `useTauriJumpList``native/jumplist.rs` (`ICustomDestinationList`).
### Taskbar Thumbnail Toolbar (P5-44)
Hover the taskbar preview during a call → **Mute / Deafen / End Call** buttons. `useTauriThumbbar``native/thumbbar.rs` (`ITaskbarList3` + a window subclass for `THBN_CLICKED`).
### System Media Transport Controls — SMTC (P5-43)
Exposes call status + a mute control to the Windows volume-flyout / media overlay (WinRT `SystemMediaTransportControls`). `useTauriSmtc``native/smtc.rs`. _Experimental — may require an active audio session to surface._
### Network Awareness (P5-49)
Detects Windows connectivity changes (`INetworkListManager`) and nudges the Matrix client to reconnect (`retryImmediately`). `useTauriNetwork``native/network.rs`.
### Instant Background Sync (P5-42)
Keeps the `/sync` loop + notifications running full-speed while the app is closed to the tray, by disabling Chromium background throttling via WebView2 `additional_browser_args` (`lib.rs`) — no separate background process. Windows/WebView2 only; doesn't block system sleep.
### Native Rich Notifications (P5-41 / P5-35)
Windows toasts with **click-to-open-room** and **inline quick reply** (WinRT `ToastNotification`, in-process `Activated` event). Falls back to the standard toast otherwise. `useTauriToastActions``native/toast.rs`; the desktop notification bridge routes room notifications to it.
### Focus Assist Sync (P5-56)
When Windows Focus Assist / Quiet Hours is active, Lotus suppresses its own notifications + sounds (reuses the quiet-hours gate). `useTauriFocusAssist` + `focusAssistActiveAtom``native/focus_assist.rs` (`SHQueryUserNotificationState`).
### Custom Window Chrome (P5-47)
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome``native/chrome.rs`.
### Proactive Update Toast (P5-40)
Checks for a new desktop release every 12h and offers a one-click update. `TauriUpdateFeature` (ClientNonUIFeatures) + `useTauriUpdater`.
### Cross-platform composer niceties
- **Composer toolbar drag-reorder (P5-55)** — drag to reorder the composer buttons (Settings → General), via `@atlaskit/pragmatic-drag-and-drop`.
- **Draft-saved indicator (P5-57)** — a subtle cue in the composer when the current room has a persisted draft.
- **Recursive folder drag-drop (P5-48)** — drop a folder to upload every file inside it (all nesting levels), `utils/fileEntries.ts`.
### Files
- Web: `src/app/hooks/useTauri*.ts`, `src/app/components/TauriDesktopFeatures.tsx`, `src/app/features/desktop/TitleBar.tsx`, `src/app/features/room/DraftIndicator.tsx`, `src/app/utils/fileEntries.ts`, `src/app/state/{customWindowChrome,focusAssist}.ts`.
- Native (`cinny-desktop`): `src-tauri/src/native/{power,jumplist,thumbbar,smtc,network,chrome,toast,focus_assist}.rs` + `native/mod.rs` (registered in `lib.rs`).
---
## Key Custom Files
| File | Purpose |
+35 -27
View File
@@ -334,16 +334,16 @@ Features:
---
### [ ] P5-35 · Desktop — Notification Click Opens Room (DEFERRED)
### [~] P5-35 · Desktop — Notification Click Opens Room — IMPLEMENTED (Tier B, via the P5-41 WinRT toast: click → open room, reply → send); native CI-compile-pending, runtime-verify on Windows
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
**Status:** Deferred `tauri-plugin-notification` has no Rust click/action callback API. Quick-reply would need a custom WinRT toast activator + COM registration, which can't be compile-tested without a Windows build environment.
**Status:** Deferred (Tier B). Note: the "can't compile-test without a Windows build environment" premise is **outdated** — CI now compiles Windows (Gitea self-hosted `windows` runner + GitHub `windows-latest`), and `windows`-crate/COM code already ships (e.g. `set_badge_count`, and the Tier A jump list). This still depends on P5-41 (the WinRT toast + custom activator), so it rides with that; it was not part of the Tier A wave.
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
**Complexity:** High (platform-specific native code required).
---
### [ ] P5-36 · Desktop — Windows Jump List (DEFERRED)
### [~] P5-36 · Desktop — Windows Jump List — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
@@ -352,78 +352,86 @@ Features:
---
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications
### [~] P5-41 · Desktop — Native WinRT Toast Notifications — IMPLEMENTED (Tier B; ToastNotification + reply input + in-process Activated; falls back to tauri-plugin-notification); native CI-compile-pending. Runtime needs a Start-menu shortcut + matching AppUserModelID to surface
**What:** Replace emulated notifications with native WinRT Toast notifications.
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
### [ ] P5-42 · Desktop — Persistent Background Sync
### [~] P5-42 · Desktop — Persistent Background Sync — IMPLEMENTED (Batch 3, pragmatic keep-alive); native CI-compile-pending, runtime-verify on Windows
**What:** Maintain light connection to homeserver when WebView2 is suspended.
**Approach:** Implement a headless Rust sidecar to fetch unread counts/notifications while the webview is suspended to ensure instant notification delivery.
**What:** Keep receiving messages/notifications instantly while the app is closed to the tray.
**Shipped approach (80/20):** rather than a multi-sprint headless Rust sync client, disable Chromium background throttling via WebView2 `additional_browser_args` (`--disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows`, added to the existing Tauri default args) so the existing JS Matrix `/sync` loop keeps running full-speed in the tray. Windows/WebView2 only; does not block system sleep. See `cinny-desktop/src-tauri/src/lib.rs` (WebviewWindowBuilder).
**Deferred (not needed):** the full headless Rust sidecar — only revisit if WebView2 ever hard-suspends despite these flags (would require a second Matrix client with its own /sync + push-rule eval + E2EE-aware notification content).
### [ ] P5-43 · Desktop — System Media Transport Controls (SMTC)
### [~] P5-43 · Desktop — System Media Transport Controls (SMTC) — IMPLEMENTED (Tier A); CI-compile-pending; SMTC may need an active media/audio session to surface — verify on Windows
**What:** Integrate with Windows SMTC for volume flyout call/media control.
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
### [ ] P5-44 · Desktop — Taskbar Thumbnail Toolbar
### [~] P5-44 · Desktop — Taskbar Thumbnail Toolbar — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
**What:** Add persistent call controls to the taskbar preview.
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
### [ ] P5-46 · Desktop — System Power Management (Call Continuity)
### [~] P5-46 · Desktop — System Power Management (Call Continuity) — IMPLEMENTED (Tier A reference); web verified; native CI-compile-pending (Windows only; macOS/Linux no-op TODO)
**What:** Prevent system sleep/hibernate during active calls.
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
### [ ] P5-47 · Desktop — TDS-Styled Native Window Chrome
### [~] P5-47 · Desktop — TDS-Styled Native Window Chrome — IMPLEMENTED (Tier A, OPT-IN/default-off, runtime-reversible); web verified; native CI-compile-pending
**What:** Replace system titlebar with custom Lotus TDS chrome.
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
### [ ] P5-48 · Desktop — Native File System Drag-and-Drop Improvements
### [~] P5-48 · Desktop — Native File System Drag-and-Drop Improvements — IMPLEMENTED (recursive folder upload, web-verified: tsc/build/tests). SCOPED-OUT: `.lnk` shortcut resolution (webview never exposes a dropped file's OS path → native can't resolve the target) and "Send To" (installer/registry shell integration) — deferred
**What:** Enhance drag-and-drop support for Windows.
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
### [ ] P5-49 · Desktop — Network Awareness (NCSI Integration)
### [~] P5-49 · Desktop — Network Awareness (NCSI Integration) — IMPLEMENTED (Tier A; INetworkListManager poll → mx.retryImmediately); native CI-compile-pending, runtime-verify on Windows
**What:** Proactively detect Windows network connectivity changes.
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
### [ ] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
### [WON'T FIX] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
**What:** Replace standard browser decoding with native Windows Media Foundation.
**Approach:** Leverage DirectShow/Media Foundation to offload video/audio decoding from the CPU to the GPU, significantly reducing power consumption and latency during calls.
**Why won't-fix (researched):** WebRTC media (the call pipeline) lives entirely inside WebView2/Chromium — you cannot inject Media Foundation/DirectShow into WebRTC's decode path from the Tauri host. Chromium already uses the platform's hardware decoders (D3D11VA/MF) where the GPU supports the codec, so there is no separate CPU pipeline to offload. Not actionable as described.
### [ ] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
### [DEFERRED] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts."
**Approach:** Implement a zero-leak boundary for personas (e.g., Work vs. Personal) by isolating `IndexedDB`, filesystem caches, and session persistence per context.
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts" (e.g. Work vs. Personal) with a zero-leak boundary.
**Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
**Future-work spec (why it's big):** the app is currently **single-session**.
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) *without* the hard isolation boundary — much less risky, reuses most of the login flow.
**Priority:** Extreme Low (Multi-sprint/Architectural).
### [~] P5-52 · Desktop — Room-Level Sync Governor (Performance Control) [STILL_CONSIDERING]
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
**What:** Granular sync tuning for individual rooms.
**Approach:** Allow per-room overrides for sync frequency and event type filtering (e.g., disable read receipts/typing in heavy rooms) to optimize performance. Implementation requires careful UX to prevent complexity fatigue.
**What:** Granular per-room sync tuning (frequency, event-type filtering).
**Why dropped (reviewed 2026-07):** matrix-js-sdk can't do **true** per-room sync filtering — all room events still come down the single `/sync` stream, so "disable typing/receipts in heavy rooms" can only be a **cosmetic client-side hide**, not an actual performance/bandwidth win. That, plus the UX-complexity risk flagged originally, makes it not worth building. If per-room quieting is ever wanted, add a simple "mute typing & receipts in this room" toggle to normal room settings — not a "governor."
### [ ] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
### [DEFERRED] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
**Approach:** Implement a WASM-based execution engine that allows users to write local-only, client-side scripts to interact with incoming Matrix events, trigger sounds/notifications, or inject custom UI elements based on event payload rules. Designed for privacy — all logic runs exclusively on the local machine.
**Decision:** Deferred (reviewed 2026-07). A full WASM execution engine + script registry + management UI + security model is a large surface for a very small (power-user) audience.
**Recommended lighter alternative (the ~80/20) if we ever want event automation:** a built-in **automation-rules** feature — declarative "when an incoming event matches X (room / sender / keyword / type) → notify / play sound / highlight / auto-react" rules, configured in Settings. Covers the realistic use cases (custom alerts, keyword pings) with **no arbitrary code execution**, so no sandbox/security burden. Build that instead of a scripting engine if the need arises.
### [ ] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering
### [~] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering — IMPLEMENTED (Tier A); web-verified (tsc/build/tests). Awaiting live UX check
**What:** Allow users to reorder toolbar icons via drag-and-drop.
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
### [ ] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync
### [~] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync — IMPLEMENTED (Tier B; SHQueryUserNotificationState poll → suppresses notifications+sounds via the quiet-hours gate); native CI-compile-pending, runtime-verify on Windows
**What:** Automatically toggle notification state based on Windows Focus Assist.
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
### [ ] P5-57 · Desktop — Visual Draft Persistence Indicator
### [~] P5-57 · Desktop — Visual Draft Persistence Indicator — IMPLEMENTED (Tier A); web-verified. Awaiting live UX check
---
@@ -635,7 +643,7 @@ See shipped implementation in LOTUS_FEATURES.md → "Noise Suppression (Advanced
---
### P5-40 · Desktop — Proactive Update Notifications (Tauri)
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) — DONE (already shipped: `TauriUpdateFeature` in ClientNonUIFeatures.tsx polls every 12h + fires the sticky update toast)
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
+14
View File
@@ -139,6 +139,20 @@ When you first run the installer on Windows, you may see a popup that says **"Wi
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
### Desktop-Specific Features
Beyond the web client, the desktop app adds native OS integration (Windows-focused; graceful no-ops elsewhere). See [`LOTUS_FEATURES.md`](./LOTUS_FEATURES.md#desktop-app-features) for detail.
- **Native rich notifications** — Windows toasts you can click to open the room or reply to inline, right from the toast.
- **Focus Assist sync** — Lotus silences its own notifications while Windows Focus Assist / Quiet Hours is on.
- **Windows Jump List** — right-click the taskbar icon for quick access to your most-active rooms.
- **Taskbar call controls** — Mute / Deafen / End Call buttons on the taskbar thumbnail during a call, plus call status in the volume flyout (SMTC).
- **Stays awake in calls** — the system won't sleep or dim during a voice/video call.
- **Network awareness** — reconnects promptly when Windows connectivity changes.
- **Custom window chrome** (opt-in) — a Lotus-styled title bar in place of the OS one.
- **Recursive folder drag-drop** — drop a whole folder onto the composer to upload everything inside it.
- **Automatic background updates** with a one-click update toast.
---
## For Developers
@@ -0,0 +1,25 @@
import { useTauriCallPower } from '../hooks/useTauriCallPower';
import { useTauriJumpList } from '../hooks/useTauriJumpList';
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
import { useTauriSmtc } from '../hooks/useTauriSmtc';
import { useTauriNetwork } from '../hooks/useTauriNetwork';
import { useTauriToastActions } from '../hooks/useTauriToastActions';
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
/**
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
* `useTauri*` hook no-ops in the browser (guards on `isTauri`), so this is safe
* to render unconditionally. Rendered once by `ClientNonUIFeatures`. App-level
* desktop features (window chrome) live in `App.tsx` instead, so they work
* before login.
*/
export function TauriDesktopFeatures(): null {
useTauriCallPower(); // P5-46 no-sleep during calls
useTauriJumpList(); // P5-36 Windows jump list of recent rooms
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
useTauriSmtc(); // P5-43 system media transport controls
useTauriNetwork(); // P5-49 network-change awareness → sync retry
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
return null;
}
+77
View File
@@ -0,0 +1,77 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
const BAR_HEIGHT = toRem(32);
const CONTROL_WIDTH = toRem(46);
export const TitleBar = style([
DefaultReset,
{
display: 'flex',
alignItems: 'stretch',
flexShrink: 0,
height: BAR_HEIGHT,
width: '100%',
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
borderBottom: `${toRem(1)} solid ${color.SurfaceVariant.ContainerLine}`,
// Sit above app content but never intercept scroll etc. below the bar.
userSelect: 'none',
},
]);
// The draggable region carries `data-tauri-drag-region`; it must expand to fill
// the free space so most of the bar is grabbable.
export const DragRegion = style({
display: 'flex',
alignItems: 'center',
flexGrow: 1,
minWidth: 0,
gap: config.space.S200,
paddingInline: config.space.S300,
});
export const Brand = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
// Children shouldn't swallow the drag; the region itself owns the attribute.
pointerEvents: 'none',
});
export const Controls = style({
display: 'flex',
alignItems: 'stretch',
flexShrink: 0,
});
export const ControlButton = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: CONTROL_WIDTH,
height: '100%',
padding: 0,
border: 'none',
cursor: 'pointer',
backgroundColor: 'transparent',
color: 'inherit',
transition: 'background-color 100ms ease',
selectors: {
'&:hover': {
backgroundColor: color.SurfaceVariant.ContainerLine,
},
},
},
]);
export const ControlButtonClose = style({
selectors: {
'&:hover': {
backgroundColor: color.Critical.Main,
color: color.Critical.OnMain,
},
},
});
+135
View File
@@ -0,0 +1,135 @@
import React, { MouseEvent, ReactNode } from 'react';
import { useAtomValue } from 'jotai';
import { Text } from 'folds';
import { customWindowChromeAtom } from '../../state/customWindowChrome';
import { invokeTauri, isTauri } from '../../hooks/useTauri';
import * as css from './TitleBar.css';
/**
* Detect macOS from the web side (no `tauri-plugin-os` dependency). We only need
* a coarse "is this a Mac" signal to decide which side the window controls sit
* on, so the UA/platform sniff is sufficient and stays cross-platform.
*/
const isMacOS = (): boolean => {
const platform =
(
navigator as unknown as {
userAgentData?: { platform?: string };
}
).userAgentData?.platform ??
navigator.platform ??
navigator.userAgent;
return /mac/i.test(platform);
};
const MIN_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<rect x="1" y="4.5" width="8" height="1" fill="currentColor" />
</svg>
);
const MAX_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<rect x="1" y="1" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="1" />
</svg>
);
const CLOSE_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M1 1 L9 9 M9 1 L1 9" stroke="currentColor" strokeWidth="1" fill="none" />
</svg>
);
type ControlButtonProps = {
label: string;
glyph: ReactNode;
onClick: () => void;
close?: boolean;
};
function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`${css.ControlButton}${close ? ` ${css.ControlButtonClose}` : ''}`}
>
{glyph}
</button>
);
}
/**
* P5-47 — TDS Custom Window Chrome titlebar.
*
* Renders `null` unless we're inside Tauri **and** the user opted into custom
* window chrome. Otherwise it draws a thin (~32px) folds/TDS-styled titlebar: a
* draggable region (`data-tauri-drag-region`) with the app brand, plus
* minimize / maximize / close controls that call the native window commands.
*
* OS-aware: Windows/Linux put the controls on the right; macOS mirrors them to
* the left (the native traffic-light position) since decorations — and thus the
* real traffic lights — are stripped while custom chrome is on.
*/
export function TitleBar() {
const enabled = useAtomValue(customWindowChromeAtom);
if (!isTauri() || !enabled) return null;
const mac = isMacOS();
const handleDoubleClick = (evt: MouseEvent<HTMLDivElement>): void => {
// Only the drag surface itself toggles maximize, not the brand/children.
if (evt.target !== evt.currentTarget) return;
invokeTauri('window_toggle_maximize');
};
const controls = (
<div className={css.Controls}>
<ControlButton
label="Minimize"
glyph={MIN_GLYPH}
onClick={() => invokeTauri('window_minimize')}
/>
<ControlButton
label="Maximize"
glyph={MAX_GLYPH}
onClick={() => invokeTauri('window_toggle_maximize')}
/>
<ControlButton
label="Close"
glyph={CLOSE_GLYPH}
onClick={() => invokeTauri('window_close')}
close
/>
</div>
);
const dragRegion = (
<div className={css.DragRegion} data-tauri-drag-region onDoubleClick={handleDoubleClick}>
<span className={css.Brand}>
<Text as="span" size="T200" truncate>
Lotus Chat
</Text>
</span>
</div>
);
return (
<header className={css.TitleBar}>
{mac ? (
<>
{controls}
{dragRegion}
</>
) : (
<>
{dragRegion}
{controls}
</>
)}
</header>
);
}
@@ -0,0 +1,31 @@
import { keyframes, style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
// A brief, gentle acknowledgement when a draft first becomes persisted.
// Guarded by `prefers-reduced-motion` so it only plays for users who opt in.
const savedPulse = keyframes({
'0%': { opacity: 0.4, transform: 'scale(0.7)' },
'45%': { opacity: 1, transform: 'scale(1.15)' },
'100%': { opacity: 1, transform: 'scale(1)' },
});
export const DraftIndicatorBase = style({
userSelect: 'none',
whiteSpace: 'nowrap',
});
export const DraftDot = style({
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Success.Main,
flexShrink: 0,
});
export const DraftDotPulse = style({
'@media': {
'(prefers-reduced-motion: no-preference)': {
animation: `${savedPulse} 600ms ease-out`,
},
},
});
+64
View File
@@ -0,0 +1,64 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Box, Text, config } from 'folds';
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { toPlainText } from '../../components/editor';
import { DraftDot, DraftDotPulse, DraftIndicatorBase } from './DraftIndicator.css';
const PULSE_DURATION = 600;
type DraftIndicatorProps = {
roomId: string;
};
/**
* Subtle, non-distracting status shown near the composer when the current room
* has a persisted (unsent) message draft. It reacts to the shared draft atom
* (`roomIdToMsgDraftAtomFamily`) — the same source that backs the
* `draft-msg-${roomId}` localStorage persistence — so it never introduces a
* parallel persistence path.
*
* A short "Saved" pulse plays the moment a draft becomes persisted, then the
* indicator settles into a quiet, muted resting state. The pulse is gated behind
* `prefers-reduced-motion` in CSS, so motion-averse users only ever see the
* static label.
*/
export function DraftIndicator({ roomId }: DraftIndicatorProps) {
const draft = useAtomValue(roomIdToMsgDraftAtomFamily(roomId));
// Real content, not just an empty paragraph.
const hasDraft = toPlainText(draft, false).trim().length > 0;
const [pulse, setPulse] = useState(false);
const hadDraft = useRef(false);
useEffect(() => {
if (hasDraft && !hadDraft.current) {
hadDraft.current = true;
setPulse(true);
const timeout = setTimeout(() => setPulse(false), PULSE_DURATION);
return () => clearTimeout(timeout);
}
hadDraft.current = hasDraft;
return undefined;
}, [hasDraft]);
if (!hasDraft) return null;
return (
<Box
className={DraftIndicatorBase}
as="span"
shrink="No"
alignItems="Center"
gap="200"
style={{ padding: `0 ${config.space.S100}` }}
aria-hidden
>
<span className={`${DraftDot}${pulse ? ` ${DraftDotPulse}` : ''}`} />
<Text as="span" size="T200" priority="300">
Draft saved
</Text>
</Box>
);
}
+260 -181
View File
@@ -1,9 +1,11 @@
import React, {
KeyboardEventHandler,
ReactNode,
RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -98,7 +100,11 @@ import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { useAlive } from '../../hooks/useAlive';
import { settingsAtom } from '../../state/settings';
import {
ComposerToolbarButtonKey,
normalizeComposerToolbarOrder,
settingsAtom,
} from '../../state/settings';
import {
getAudioMsgContent,
getFileMsgContent,
@@ -128,6 +134,7 @@ import { PollCreator } from './PollCreator';
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator';
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
const GifPicker = React.lazy(() =>
@@ -219,6 +226,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showPoll = composerToolbarButtons?.showPoll ?? true;
const showVoice = composerToolbarButtons?.showVoice ?? true;
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
const composerButtonOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
);
const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = useCallback(() => {
@@ -358,13 +369,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const nodes = JSON.parse(stored);
if (Array.isArray(nodes) && nodes.length > 0) {
Transforms.insertFragment(editor, nodes);
// Mirror the restored draft into the atom so the draft indicator
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
// after a page reload — not only on same-session room re-entry.
setMsgDraft(nodes);
}
}
} catch {
// Ignore malformed stored draft
}
}
}, [editor, msgDraft, roomId]);
}, [editor, msgDraft, roomId, setMsgDraft]);
useEffect(
() => () => {
@@ -954,59 +969,33 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Icon src={Icons.PlusCircle} />
</IconButton>
}
after={
<>
{showFormat && (
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
aria-pressed={toolbar}
onClick={() => setToolbar(!toolbar)}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
)}
{(showEmoji || showSticker) && (
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
emojiBoardTab === undefined
? undefined
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
}
content={
<React.Suspense fallback={null}>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/>
</React.Suspense>
}
>
{showSticker && !hideStickerBtn && (
after={(() => {
const formatButton = showFormat ? (
<IconButton
key="showFormat"
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
aria-pressed={toolbar}
onClick={() => setToolbar(!toolbar)}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
) : null;
// Emoji and Sticker share a single EmojiBoard PopOut anchored to the
// emoji button, so they are rendered together as one unit. Their
// relative order still follows the saved order.
const emojiStickerBlock =
showEmoji || showSticker ? (
<UseStateProvider key="showEmojiSticker" initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => {
const stickerBtn =
showSticker && !hideStickerBtn ? (
<IconButton
key="showSticker"
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
aria-label="Insert sticker"
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
@@ -1020,36 +1009,76 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
filled={emojiBoardTab === EmojiBoardTab.Sticker}
/>
</IconButton>
)}
{showEmoji && (
<IconButton
ref={emojiBtnRef}
aria-label="Insert emoji"
aria-pressed={
) : null;
const emojiBtn = showEmoji ? (
<IconButton
key="showEmoji"
ref={emojiBtnRef}
aria-label="Insert emoji"
aria-pressed={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon
src={Icons.Smile}
filled={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon
src={Icons.Smile}
filled={
hideStickerBtn
? !!emojiBoardTab
: emojiBoardTab === EmojiBoardTab.Emoji
}
/>
</IconButton>
)}
</PopOut>
)}
/>
</IconButton>
) : null;
const emojiFirst =
composerButtonOrder.indexOf('showEmoji') <
composerButtonOrder.indexOf('showSticker');
return (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
emojiBoardTab === undefined
? undefined
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
}
content={
<React.Suspense fallback={null}>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/>
</React.Suspense>
}
>
{emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]}
</PopOut>
);
}}
</UseStateProvider>
)}
{!!gifApiKey && showGif && (
<UseStateProvider initial={false}>
) : null;
const gifButton =
!!gifApiKey && showGif ? (
<UseStateProvider key="showGif" initial={false}>
{(gifOpen: boolean, setGifOpen) => (
<PopOut
offset={16}
@@ -1101,113 +1130,163 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut>
)}
</UseStateProvider>
)}
{gifError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{gifError}
</Text>
)}
{locationError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{locationError}
</Text>
)}
{showLocation && (
<IconButton
onClick={handleShareLocation}
disabled={locating}
aria-label="Share location"
variant="SurfaceVariant"
size="300"
radii="300"
title="Share location"
style={touchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="100" />
) : (
<Icon src={Icons.SpaceGlobe} size="100" />
)}
</IconButton>
)}
{showPoll && (
<IconButton
onClick={() => setPollOpen(true)}
aria-label="Create poll"
variant="SurfaceVariant"
size="300"
radii="300"
title="Create poll"
style={touchTarget}
>
<Icon src={Icons.OrderList} size="100" />
</IconButton>
)}
{showVoice && (
<VoiceMessageRecorder
onSend={handleVoiceSend}
onError={(err) => {
setLocationError(err);
setTimeout(() => setLocationError(null), 4000);
}}
/>
)}
{charCount > 0 && (
<Text
size="T200"
priority="300"
style={{
padding: `0 ${config.space.S100}`,
alignSelf: 'center',
userSelect: 'none',
minWidth: '2rem',
textAlign: 'right',
}}
>
{charCount}
</Text>
)}
{showSchedule && (
<IconButton
onClick={handleScheduleClick}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Schedule message"
title="Schedule message"
>
<Icon src={Icons.Clock} size="100" />
</IconButton>
)}
) : null;
const locationButton = showLocation ? (
<IconButton
onClick={submit}
key="showLocation"
onClick={handleShareLocation}
disabled={locating}
aria-label="Share location"
variant="SurfaceVariant"
size="300"
radii="300"
title="Share location"
style={touchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="100" />
) : (
<Icon src={Icons.SpaceGlobe} size="100" />
)}
</IconButton>
) : null;
const pollButton = showPoll ? (
<IconButton
key="showPoll"
onClick={() => setPollOpen(true)}
aria-label="Create poll"
variant="SurfaceVariant"
size="300"
radii="300"
title="Create poll"
style={touchTarget}
>
<Icon src={Icons.OrderList} size="100" />
</IconButton>
) : null;
const voiceButton = showVoice ? (
<VoiceMessageRecorder
key="showVoice"
onSend={handleVoiceSend}
onError={(err) => {
setLocationError(err);
setTimeout(() => setLocationError(null), 4000);
}}
/>
) : null;
const scheduleButton = showSchedule ? (
<IconButton
key="showSchedule"
onClick={handleScheduleClick}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Send message"
aria-label="Schedule message"
title="Schedule message"
>
<Icon src={Icons.Send} />
<Icon src={Icons.Clock} size="100" />
</IconButton>
</>
}
) : null;
const orderedButtons: ReactNode[] = [];
let emojiStickerRendered = false;
composerButtonOrder.forEach((key: ComposerToolbarButtonKey) => {
switch (key) {
case 'showFormat':
if (formatButton) orderedButtons.push(formatButton);
break;
case 'showEmoji':
case 'showSticker':
// Rendered once as a combined unit at whichever of the two
// keys comes first in the order.
if (!emojiStickerRendered) {
emojiStickerRendered = true;
if (emojiStickerBlock) orderedButtons.push(emojiStickerBlock);
}
break;
case 'showGif':
if (gifButton) orderedButtons.push(gifButton);
break;
case 'showLocation':
if (locationButton) orderedButtons.push(locationButton);
break;
case 'showPoll':
if (pollButton) orderedButtons.push(pollButton);
break;
case 'showVoice':
if (voiceButton) orderedButtons.push(voiceButton);
break;
case 'showSchedule':
if (scheduleButton) orderedButtons.push(scheduleButton);
break;
default:
break;
}
});
return (
<>
{orderedButtons}
{gifError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{gifError}
</Text>
)}
{locationError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{locationError}
</Text>
)}
<DraftIndicator roomId={roomId} />
{charCount > 0 && (
<Text
size="T200"
priority="300"
style={{
padding: `0 ${config.space.S100}`,
alignSelf: 'center',
userSelect: 'none',
minWidth: '2rem',
textAlign: 'right',
}}
>
{charCount}
</Text>
)}
<IconButton
onClick={submit}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Send message"
>
<Icon src={Icons.Send} />
</IconButton>
</>
);
})()}
bottom={
toolbar && (
<div>
+230 -34
View File
@@ -5,6 +5,7 @@ import React, {
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -34,6 +35,19 @@ import {
import { isKeyHotkey } from 'is-hotkey';
import { HexColorPicker } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import {
draggable,
dropTargetForElements,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder';
import {
attachClosestEdge,
extractClosestEdge,
Edge,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { useAtom } from 'jotai';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
import { Page, PageContent, PageHeader } from '../../../components/page';
@@ -47,12 +61,14 @@ import { useSetting } from '../../../state/hooks/settings';
import {
CallAudioBitrate,
ChatBackground,
ComposerToolbarButtonKey,
ComposerToolbarSettings,
DateFormat,
DenoiseModelId,
MessageLayout,
MessageSpacing,
NoiseSuppressionMode,
normalizeComposerToolbarOrder,
RingtoneId,
ScreenshareBitrate,
ScreenshareFramerate,
@@ -86,12 +102,33 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { isTauri as isTauriEnv } from '../../../hooks/useTauri';
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
/**
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
* browser. Backed by the standalone `customWindowChromeAtom`; `useTauriWindowChrome`
* (mounted in App.tsx) applies `set_decorations` when this flips.
*/
function DesktopChromeSetting() {
const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom);
if (!isTauriEnv()) return null;
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Custom Window Chrome (Beta)"
description="Replace the system title bar with a Lotus-styled one. Desktop only — toggles instantly."
after={<Switch variant="Primary" value={customChrome} onChange={setCustomChrome} />}
/>
</SequenceCard>
);
}
type ThemeSelectorProps = {
themeNames: Record<string, string>;
themes: Theme[];
@@ -405,6 +442,8 @@ function Appearance() {
/>
</SequenceCard>
<DesktopChromeSetting />
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Twitter Emoji"
@@ -1025,6 +1064,165 @@ function DateAndTime() {
);
}
const COMPOSER_TOOLBAR_LABELS: Record<ComposerToolbarButtonKey, string> = {
showFormat: 'Format',
showEmoji: 'Emoji',
showSticker: 'Sticker',
showGif: 'GIF',
showLocation: 'Location',
showPoll: 'Poll',
showVoice: 'Voice',
showSchedule: 'Schedule',
};
const COMPOSER_TOOLBAR_DRAG_TYPE = 'composer-toolbar-button';
type ComposerToolbarButtonRowProps = {
buttonKey: ComposerToolbarButtonKey;
index: number;
active: boolean;
onToggle: (key: ComposerToolbarButtonKey) => void;
};
function ComposerToolbarButtonRow({
buttonKey,
index,
active,
onToggle,
}: ComposerToolbarButtonRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLButtonElement>(null);
const [dragging, setDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
useEffect(() => {
const element = rowRef.current;
const dragHandle = handleRef.current;
if (!element || !dragHandle) return undefined;
return combine(
draggable({
element,
dragHandle,
getInitialData: () => ({ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index }),
onDragStart: () => setDragging(true),
onDrop: () => setDragging(false),
}),
dropTargetForElements({
element,
canDrop: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
getData: ({ input }) =>
attachClosestEdge(
{ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index },
{ element, input, allowedEdges: ['top', 'bottom'] },
),
getIsSticky: () => true,
onDrag: ({ self, source }) => {
if (source.data.buttonKey === buttonKey) {
setClosestEdge(null);
return;
}
setClosestEdge(extractClosestEdge(self.data));
},
onDragLeave: () => setClosestEdge(null),
onDrop: () => setClosestEdge(null),
}),
);
}, [buttonKey, index]);
let boxShadow: string | undefined;
if (closestEdge === 'top') boxShadow = `inset 0 2px 0 0 ${color.Primary.Main}`;
else if (closestEdge === 'bottom') boxShadow = `inset 0 -2px 0 0 ${color.Primary.Main}`;
return (
<Box
ref={rowRef}
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S400}`,
opacity: dragging ? 0.5 : undefined,
boxShadow,
}}
>
<IconButton
ref={handleRef}
size="300"
radii="300"
variant="SurfaceVariant"
style={{ cursor: 'grab' }}
aria-label={`Reorder ${COMPOSER_TOOLBAR_LABELS[buttonKey]}`}
>
<Icon size="100" src={Icons.VerticalDots} />
</IconButton>
<Text style={{ flexGrow: 1 }} size="T300">
{COMPOSER_TOOLBAR_LABELS[buttonKey]}
</Text>
<Chip
variant={active ? 'Primary' : 'Secondary'}
outlined={active}
radii="Pill"
onClick={() => onToggle(buttonKey)}
aria-pressed={active}
>
<Text size="T200">{active ? 'Shown' : 'Hidden'}</Text>
</Chip>
</Box>
);
}
type ComposerToolbarReorderProps = {
order: ComposerToolbarButtonKey[];
buttons: ComposerToolbarSettings;
onReorder: (startIndex: number, finishIndex: number) => void;
onToggle: (key: ComposerToolbarButtonKey) => void;
};
function ComposerToolbarReorder({
order,
buttons,
onReorder,
onToggle,
}: ComposerToolbarReorderProps) {
useEffect(
() =>
monitorForElements({
canMonitor: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) return;
const startIndex = source.data.index;
const indexOfTarget = target.data.index;
if (typeof startIndex !== 'number' || typeof indexOfTarget !== 'number') return;
const closestEdgeOfTarget = extractClosestEdge(target.data);
// Insert relative to the target row, then compensate for the source
// row being removed from its original position.
let finishIndex = closestEdgeOfTarget === 'bottom' ? indexOfTarget + 1 : indexOfTarget;
if (startIndex < finishIndex) finishIndex -= 1;
if (finishIndex === startIndex) return;
onReorder(startIndex, finishIndex);
},
}),
[onReorder],
);
return (
<Box direction="Column">
{order.map((key, index) => (
<ComposerToolbarButtonRow
key={key}
buttonKey={key}
index={index}
active={buttons[key]}
onToggle={onToggle}
/>
))}
</Box>
);
}
function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -1034,20 +1232,31 @@ function Editor() {
'composerToolbarButtons',
);
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
};
const composerToolbarOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
);
const TOOLBAR_CHIPS: Array<{ key: keyof ComposerToolbarSettings; label: string }> = [
{ key: 'showFormat', label: 'Format' },
{ key: 'showEmoji', label: 'Emoji' },
{ key: 'showSticker', label: 'Sticker' },
{ key: 'showGif', label: 'GIF' },
{ key: 'showLocation', label: 'Location' },
{ key: 'showPoll', label: 'Poll' },
{ key: 'showVoice', label: 'Voice' },
{ key: 'showSchedule', label: 'Schedule' },
];
const toggleToolbarButton = useCallback(
(key: ComposerToolbarButtonKey) => {
setComposerToolbarButtons((current) => ({ ...current, [key]: !current[key] }));
},
[setComposerToolbarButtons],
);
const reorderToolbarButtons = useCallback(
(startIndex: number, finishIndex: number) => {
setComposerToolbarButtons((current) => ({
...current,
order: reorder({
list: normalizeComposerToolbarOrder(current.order),
startIndex,
finishIndex,
}),
}));
},
[setComposerToolbarButtons],
);
return (
<Box direction="Column" gap="100">
@@ -1082,28 +1291,15 @@ function Editor() {
>
<SettingTile
title="Composer Toolbar"
description="Tap a button to show or hide it in the message composer."
description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
/>
<Box
wrap="Wrap"
gap="200"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
{TOOLBAR_CHIPS.map(({ key, label }) => {
const active = composerToolbarButtons?.[key] ?? true;
return (
<Chip
key={key}
variant={active ? 'Primary' : 'Secondary'}
outlined={active}
radii="Pill"
onClick={() => toggleToolbarButton(key)}
aria-pressed={active}
>
<Text size="T300">{label}</Text>
</Chip>
);
})}
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
<ComposerToolbarReorder
order={composerToolbarOrder}
buttons={composerToolbarButtons}
onReorder={reorderToolbarButtons}
onToggle={toggleToolbarButton}
/>
</Box>
</SequenceCard>
</Box>
+16 -5
View File
@@ -1,11 +1,16 @@
import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react';
import { getDataTransferFiles } from '../utils/dom';
import { collectDroppedFiles } from '../utils/fileEntries';
export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler =>
useCallback(
(evt) => {
const files = getDataTransferFiles(evt.dataTransfer);
if (files) onDrop(files);
// `collectDroppedFiles` synchronously captures the entry list from the
// DataTransfer before traversing folders asynchronously.
collectDroppedFiles(evt.dataTransfer)
.then((files) => {
if (files) onDrop(files);
})
.catch(() => undefined);
},
[onDrop],
);
@@ -24,8 +29,14 @@ export const useFileDropZone = (
dragCounterRef.current = 0;
setActive(false);
if (!evt.dataTransfer) return;
const files = getDataTransferFiles(evt.dataTransfer);
if (files) onDrop(files);
// Capture entries synchronously (inside the event) then traverse any
// dropped folders asynchronously — the DataTransferItemList is emptied
// once this handler returns.
collectDroppedFiles(evt.dataTransfer)
.then((files) => {
if (files) onDrop(files);
})
.catch(() => undefined);
};
target?.addEventListener('drop', handleDrop);
+35
View File
@@ -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]);
}
+18
View File
@@ -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]);
}
+24
View File
@@ -0,0 +1,24 @@
import { useSetAtom } from 'jotai';
import { focusAssistActiveAtom } from '../state/focusAssist';
import { useTauriEvent } from './useTauri';
/** Detail shape of the `focus-assist-changed` event emitted by the native side. */
type FocusAssistChangedDetail = {
active: boolean;
};
/**
* P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync (desktop). Subscribes to
* the native `focus-assist-changed` event (Windows `SHQueryUserNotificationState`
* poll, `{ active }`) and mirrors it into `focusAssistActiveAtom`, which the
* notification gate reads to suppress notifications while the shell is in Focus
* Assist / Quiet Hours, presenting, gaming full-screen, or busy. Inert in the
* browser, since `useTauriEvent` only listens under Tauri.
*/
export function useTauriFocusAssist(): void {
const setFocusAssist = useSetAtom(focusAssistActiveAtom);
useTauriEvent<FocusAssistChangedDetail>('focus-assist-changed', ({ active }) =>
setFocusAssist(active),
);
}
+58
View File
@@ -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]);
}
+38
View File
@@ -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;
}
+38
View File
@@ -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);
}
});
}
+44
View File
@@ -0,0 +1,44 @@
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.
// Async transport send — swallow rejection (widget mid-teardown), as SMTC does.
callEmbed.control.toggleMicrophone().catch(() => undefined);
} 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().catch(() => undefined);
}
});
}
+39
View File
@@ -0,0 +1,39 @@
import { useNavigate } from 'react-router-dom';
import { MsgType } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useTauriEvent } from './useTauri';
/** Payload of the `lotus-notification-activate` event (a plain body click). */
interface ActivateDetail {
path?: string;
}
/** Payload of the `lotus-notification-reply` event (the inline reply box). */
interface ReplyDetail {
roomId?: string;
text?: string;
}
/**
* P5-41 / P5-35 — wire the native WinRT toast's click + quick-reply back into the
* client. The Rust side (`show_rich_toast`) dispatches DOM CustomEvents via
* `emit_to_web`:
* - `lotus-notification-activate` → route to the room the toast was for, reusing
* the same `useNavigate(path)` mechanism the web `notificationclick` path uses
* (see ClientNonUIFeatures).
* - `lotus-notification-reply` → send the typed reply straight to the room.
* No-op outside Tauri (the events never fire).
*/
export function useTauriToastActions(): void {
const navigate = useNavigate();
const mx = useMatrixClient();
useTauriEvent<ActivateDetail>('lotus-notification-activate', ({ path }) => {
if (path) navigate(path);
});
useTauriEvent<ReplyDetail>('lotus-notification-reply', ({ roomId, text }) => {
if (!roomId || !text) return;
mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => undefined);
});
}
+22
View File
@@ -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]);
}
+38 -2
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { ReactNode, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
import {
@@ -25,6 +25,10 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
import { isTauri } from '../hooks/useTauri';
import { TitleBar } from '../features/desktop/TitleBar';
import { customWindowChromeAtom } from '../state/customWindowChrome';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex';
@@ -88,6 +92,36 @@ function TauriEffects() {
return null;
}
// P5-47 — opt-in TDS window chrome. `useTauriWindowChrome` keeps the native OS
// window decorations in sync with the setting; when a desktop user enables
// custom chrome we replace the OS titlebar with <TitleBar/>. When off (the
// default, and always in the browser) this returns children unchanged, so there
// is zero layout impact for everyone else.
function DesktopChrome({ children }: { children: ReactNode }) {
const customChrome = useAtomValue(customWindowChromeAtom);
useTauriWindowChrome();
const useChrome = isTauri() && customChrome;
// Keep the wrapper element structure STABLE across the toggle so flipping the
// setting never changes the element type in `children`'s ancestry — otherwise
// React would unmount/remount the whole RouterProvider subtree (losing scroll,
// menus, unsaved composer state). When off, both wrappers use `display:contents`
// so they generate no box → zero layout impact (also the browser default path).
return (
<div
style={
useChrome
? { display: 'flex', flexDirection: 'column', height: '100vh' }
: { display: 'contents' }
}
>
{useChrome && <TitleBar />}
<div style={useChrome ? { flexGrow: 1, minHeight: 0 } : { display: 'contents' }}>
{children}
</div>
</div>
);
}
function NightLightOverlay() {
const settings = useAtomValue(settingsAtom);
if (!settings.nightLightEnabled) return null;
@@ -160,7 +194,9 @@ function App() {
<JotaiProvider>
<AppearanceEffects />
<TauriEffects />
<RouterProvider router={createRouter(clientConfig, screenSize)} />
<DesktopChrome>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</DesktopChrome>
<SeasonalEffect />
<NightLightOverlay />
<LotusToastContainer />
+11 -2
View File
@@ -2,6 +2,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { focusAssistActiveAtom } from '../../state/focusAssist';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/lotus.png';
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
@@ -33,6 +34,7 @@ import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
function isInQuietHours(start: string, end: string): boolean {
const now = new Date();
@@ -109,6 +111,7 @@ function InviteNotifications() {
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
@@ -167,7 +170,8 @@ function InviteNotifications() {
useEffect(() => {
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
const quietActive =
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (!quietActive) {
if (showNotifications && notificationPermission('granted')) {
notify(invites.length - perviousInviteLen);
@@ -189,6 +193,7 @@ function InviteNotifications() {
quietHoursEnabled,
quietHoursStart,
quietHoursEnd,
focusAssistActive,
inviteSoundId,
]);
@@ -212,6 +217,7 @@ function MessageNotifications() {
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
@@ -355,7 +361,8 @@ function MessageNotifications() {
return;
}
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
const quietActive =
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (!quietActive) {
if (showNotifications && notificationPermission('granted')) {
const avatarMxc =
@@ -394,6 +401,7 @@ function MessageNotifications() {
quietHoursEnabled,
quietHoursStart,
quietHoursEnd,
focusAssistActive,
messageSoundId,
]);
@@ -555,6 +563,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<MessageNotifications />
<ReminderMonitor />
<TauriUpdateFeature />
<TauriDesktopFeatures />
<LotusDenoiseFeature />
<DeepLinkNavigator />
{children}
+22
View File
@@ -0,0 +1,22 @@
import {
atomWithLocalStorage,
getLocalStorageItem,
setLocalStorageItem,
} from './utils/atomWithLocalStorage';
const CUSTOM_WINDOW_CHROME = 'customWindowChrome';
/**
* P5-47 — TDS Custom Window Chrome opt-in flag (default `false`).
*
* Standalone, `localStorage`-backed boolean atom kept separate from
* `state/settings.ts` on purpose. When `true` (and running inside Tauri) the app
* strips the native window frame and renders its own `<TitleBar/>`; when `false`
* the native OS frame is used. The feature is runtime-reversible, so flipping
* this atom is all it takes to switch back and forth.
*/
export const customWindowChromeAtom = atomWithLocalStorage<boolean>(
CUSTOM_WINDOW_CHROME,
(key) => getLocalStorageItem<boolean>(key, false),
(key, value) => setLocalStorageItem(key, value),
);
+14
View File
@@ -0,0 +1,14 @@
import { atom } from 'jotai';
/**
* P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync (live OS state).
*
* Standalone, non-persisted boolean atom reflecting whether the shell is
* currently suppressing notifications (Focus Assist / Quiet Hours, presentation
* mode, full-screen D3D, or "busy"). It is driven at runtime by
* `useTauriFocusAssist` from the native `focus-assist-changed` event and read by
* the notification gate. Because it mirrors transient OS state — not a user
* preference — it is a plain in-memory atom that defaults to `false` and is
* intentionally NOT written to `localStorage`.
*/
export const focusAssistActiveAtom = atom(false);
+76
View File
@@ -60,6 +60,39 @@ export enum MessageLayout {
Bubble = 2,
}
/**
* Keys of the toggleable composer toolbar buttons. Also used as the identity
* of each button when persisting/restoring a custom drag-and-drop order.
*/
export const COMPOSER_TOOLBAR_BUTTON_KEYS = [
'showFormat',
'showEmoji',
'showSticker',
'showGif',
'showLocation',
'showPoll',
'showVoice',
'showSchedule',
] as const;
export type ComposerToolbarButtonKey = (typeof COMPOSER_TOOLBAR_BUTTON_KEYS)[number];
/**
* The fixed order the composer toolbar rendered before reordering existed.
* Used as the fallback for users without a saved order, and to append any
* new/unknown button keys, so existing users see no change.
*/
export const DEFAULT_COMPOSER_TOOLBAR_ORDER: ComposerToolbarButtonKey[] = [
'showFormat',
'showSticker',
'showEmoji',
'showGif',
'showLocation',
'showPoll',
'showVoice',
'showSchedule',
];
export interface ComposerToolbarSettings {
showFormat: boolean;
showEmoji: boolean;
@@ -69,6 +102,7 @@ export interface ComposerToolbarSettings {
showPoll: boolean;
showVoice: boolean;
showSchedule: boolean;
order: ComposerToolbarButtonKey[];
}
export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
@@ -80,6 +114,47 @@ export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
showPoll: true,
showVoice: true,
showSchedule: true,
order: DEFAULT_COMPOSER_TOOLBAR_ORDER,
};
/**
* Returns a complete, de-duplicated composer toolbar order:
* - drops unknown/duplicate keys from the saved order
* - appends any missing keys (new buttons or existing users with no saved
* order) at the end in their canonical default position
* so a button can never disappear from the toolbar.
*/
export const normalizeComposerToolbarOrder = (
order: ComposerToolbarButtonKey[] | undefined,
): ComposerToolbarButtonKey[] => {
const known = new Set<ComposerToolbarButtonKey>(COMPOSER_TOOLBAR_BUTTON_KEYS);
const seen = new Set<ComposerToolbarButtonKey>();
const result: ComposerToolbarButtonKey[] = [];
(order ?? []).forEach((key) => {
if (known.has(key) && !seen.has(key)) {
seen.add(key);
result.push(key);
}
});
// Append missing keys in their canonical default position…
DEFAULT_COMPOSER_TOOLBAR_ORDER.forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
result.push(key);
}
});
// …then any known key not covered by the default order (safety net so a new
// button added to COMPOSER_TOOLBAR_BUTTON_KEYS but forgotten in the default
// order can still render/reorder rather than being permanently dropped).
COMPOSER_TOOLBAR_BUTTON_KEYS.forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
result.push(key);
}
});
return result;
};
export interface Settings {
@@ -318,6 +393,7 @@ export const getSettings = (): Settings => {
composerToolbarButtons: {
...DEFAULT_COMPOSER_TOOLBAR,
...(saved.composerToolbarButtons ?? {}),
order: normalizeComposerToolbarOrder(saved.composerToolbarButtons?.order),
},
};
} catch {
+70
View File
@@ -0,0 +1,70 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { filesFromEntries } from './fileEntries';
const fileEntry = (name: string): FileSystemFileEntry =>
({
isFile: true,
isDirectory: false,
name,
file: (success: (file: File) => void) => {
success(new File(['x'], name, { type: 'text/plain' }));
},
}) as unknown as FileSystemFileEntry;
/**
* A directory whose reader yields its children in several batches (mirroring
* Chromium's `readEntries`, which caps each call) and finally an empty batch.
*/
const dirEntry = (name: string, children: FileSystemEntry[]): FileSystemDirectoryEntry => {
const batches = [children.slice(0, 1), children.slice(1), [] as FileSystemEntry[]];
return {
isFile: false,
isDirectory: true,
name,
createReader: () => {
let call = 0;
return {
readEntries: (success: (entries: FileSystemEntry[]) => void) => {
const batch = batches[call] ?? [];
call += 1;
success(batch);
},
} as unknown as FileSystemDirectoryReader;
},
} as unknown as FileSystemDirectoryEntry;
};
test('filesFromEntries flattens nested folders and prefixes relative paths', async () => {
const entries: FileSystemEntry[] = [
fileEntry('top.txt'),
dirEntry('photos', [
fileEntry('a.jpg'),
dirEntry('2024', [fileEntry('b.jpg'), fileEntry('c.jpg')]),
]),
];
const files = await filesFromEntries(entries);
const names = files.map((f) => f.name).sort();
assert.deepEqual(names, ['photos/2024/b.jpg', 'photos/2024/c.jpg', 'photos/a.jpg', 'top.txt']);
});
test('filesFromEntries reads directory entries in batches until empty', async () => {
const entries: FileSystemEntry[] = [
dirEntry('docs', [fileEntry('one.txt'), fileEntry('two.txt')]),
];
const files = await filesFromEntries(entries);
assert.equal(files.length, 2);
});
test('filesFromEntries respects the maxFiles cap', async () => {
const entries: FileSystemEntry[] = [
dirEntry('many', [fileEntry('a.txt'), fileEntry('b.txt')]),
fileEntry('c.txt'),
];
const files = await filesFromEntries(entries, 2);
assert.equal(files.length, 2);
});
+134
View File
@@ -0,0 +1,134 @@
import { getDataTransferFiles, renameFile } from './dom';
// Guard against pathological drops (deeply nested / huge trees) that could
// otherwise queue thousands of uploads and freeze the composer.
export const MAX_DROPPED_FILES = 500;
/**
* Synchronously collect the `FileSystemEntry` objects for every item in a
* drop's `DataTransfer`.
*
* This MUST be called synchronously inside the drop event handler: the
* `DataTransferItemList` is emptied once the handler returns, so calling
* `webkitGetAsEntry()` after an `await` yields `null`. Capture the entries
* first, then traverse them asynchronously with {@link filesFromEntries}.
*
* Returns an empty array when `webkitGetAsEntry` is unavailable (non-Chromium
* browsers), signalling the caller to fall back to the flat file list.
*/
export const entriesFromDataTransfer = (dataTransfer: DataTransfer): FileSystemEntry[] => {
const entries: FileSystemEntry[] = [];
const { items } = dataTransfer;
if (!items) return entries;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item && item.kind === 'file' && typeof item.webkitGetAsEntry === 'function') {
const entry = item.webkitGetAsEntry();
if (entry) entries.push(entry);
}
}
return entries;
};
const fileFromFileEntry = (entry: FileSystemFileEntry): Promise<File> =>
new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
/**
* Read every entry from a directory reader.
*
* `readEntries` returns results in BATCHES (Chromium yields at most ~100 per
* call), so it must be called repeatedly until it resolves with an empty array.
*/
const readAllDirectoryEntries = (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> =>
new Promise((resolve, reject) => {
const all: FileSystemEntry[] = [];
const readBatch = () => {
reader.readEntries((batch) => {
if (batch.length === 0) {
resolve(all);
return;
}
all.push(...batch);
readBatch();
}, reject);
};
readBatch();
});
/**
* Recursively walk `FileSystemEntry` objects (as produced by
* {@link entriesFromDataTransfer}) and resolve them into a flat `File[]`,
* descending into every nested directory.
*
* Nested files keep their relative folder path as a name prefix (e.g.
* `photos/2024/pic.jpg`) so uploads remain distinguishable. Traversal stops
* once `maxFiles` files have been collected.
*/
export const filesFromEntries = async (
entries: FileSystemEntry[],
maxFiles: number = MAX_DROPPED_FILES,
): Promise<File[]> => {
const files: File[] = [];
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
if (files.length >= maxFiles) return;
// A single unreadable file/directory (moved between drop and read, a
// permissions/lock error, an OS special file) must NOT abort the whole
// traversal — skip it and keep collecting the rest.
if (entry.isFile) {
try {
const file = await fileFromFileEntry(entry as FileSystemFileEntry);
if (files.length >= maxFiles) return;
files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file);
} catch {
/* skip unreadable file */
}
return;
}
if (entry.isDirectory) {
let childEntries: FileSystemEntry[] = [];
try {
const reader = (entry as FileSystemDirectoryEntry).createReader();
childEntries = await readAllDirectoryEntries(reader);
} catch {
return; /* skip unreadable directory */
}
const childPrefix = `${prefix}${entry.name}/`;
for (const child of childEntries) {
if (files.length >= maxFiles) break;
// eslint-disable-next-line no-await-in-loop
await walk(child, childPrefix);
}
}
};
for (const entry of entries) {
if (files.length >= maxFiles) break;
// eslint-disable-next-line no-await-in-loop
await walk(entry, '');
}
return files;
};
/**
* Extract dropped files, descending into any dropped folders.
*
* Captures the `FileSystemEntry` list synchronously (required — see
* {@link entriesFromDataTransfer}) then traverses it asynchronously. Falls back
* to the flat `dataTransfer.files` list when the directory API is unavailable
* (non-Chromium) or when no entries are exposed.
*/
export const collectDroppedFiles = (dataTransfer: DataTransfer): Promise<File[] | undefined> => {
const entries = entriesFromDataTransfer(dataTransfer);
if (entries.length === 0) {
return Promise.resolve(getDataTransferFiles(dataTransfer));
}
return filesFromEntries(entries).then((files) => (files.length > 0 ? files : undefined));
};