diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index c8916817f..822d68764 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -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 (
+
+ }
+ />
+
+ );
+}
+
type ThemeSelectorProps = {
themeNames: Record
;
themes: Theme[];
@@ -405,6 +442,8 @@ function Appearance() {
/>
+
+
= {
+ 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(null);
+ const handleRef = useRef(null);
+ const [dragging, setDragging] = useState(false);
+ const [closestEdge, setClosestEdge] = useState(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 (
+
+
+
+
+
+ {COMPOSER_TOOLBAR_LABELS[buttonKey]}
+
+ onToggle(buttonKey)}
+ aria-pressed={active}
+ >
+ {active ? 'Shown' : 'Hidden'}
+
+
+ );
+}
+
+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 (
+
+ {order.map((key, index) => (
+
+ ))}
+
+ );
+}
+
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 (
@@ -1082,28 +1291,15 @@ function Editor() {
>
-
- {TOOLBAR_CHIPS.map(({ key, label }) => {
- const active = composerToolbarButtons?.[key] ?? true;
- return (
- toggleToolbarButton(key)}
- aria-pressed={active}
- >
- {label}
-
- );
- })}
+
+
diff --git a/src/app/hooks/useTauri.ts b/src/app/hooks/useTauri.ts
new file mode 100644
index 000000000..e913e8621
--- /dev/null
+++ b/src/app/hooks/useTauri.ts
@@ -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) => Promise;
+
+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): 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(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).detail);
+ window.addEventListener(name, listener);
+ return () => window.removeEventListener(name, listener);
+ }, [name]);
+}
diff --git a/src/app/hooks/useTauriCallPower.ts b/src/app/hooks/useTauriCallPower.ts
new file mode 100644
index 000000000..9d3f4e860
--- /dev/null
+++ b/src/app/hooks/useTauriCallPower.ts
@@ -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]);
+}
diff --git a/src/app/hooks/useTauriJumpList.ts b/src/app/hooks/useTauriJumpList.ts
new file mode 100644
index 000000000..825aa63bf
--- /dev/null
+++ b/src/app/hooks/useTauriJumpList.ts
@@ -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/` for a canonical alias, otherwise
+ * `matrix:roomid/`. 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]);
+}
diff --git a/src/app/hooks/useTauriNetwork.ts b/src/app/hooks/useTauriNetwork.ts
new file mode 100644
index 000000000..cf8f00972
--- /dev/null
+++ b/src/app/hooks/useTauriNetwork.ts
@@ -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(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(undefined);
+
+ useTauriEvent('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;
+}
diff --git a/src/app/hooks/useTauriSmtc.ts b/src/app/hooks/useTauriSmtc.ts
new file mode 100644
index 000000000..4fce7a578
--- /dev/null
+++ b/src/app/hooks/useTauriSmtc.ts
@@ -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('smtc-action', ({ action }) => {
+ if (!callEmbed) return;
+ if (action === 'mute') {
+ callEmbed.control.toggleMicrophone().catch(() => undefined);
+ } else if (action === 'end') {
+ callEmbed.hangup().catch(() => undefined);
+ }
+ });
+}
diff --git a/src/app/hooks/useTauriThumbbar.ts b/src/app/hooks/useTauriThumbbar.ts
new file mode 100644
index 000000000..cc4e65f06
--- /dev/null
+++ b/src/app/hooks/useTauriThumbbar.ts
@@ -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('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();
+ }
+ });
+}
diff --git a/src/app/hooks/useTauriWindowChrome.ts b/src/app/hooks/useTauriWindowChrome.ts
new file mode 100644
index 000000000..0f42c6bf1
--- /dev/null
+++ b/src/app/hooks/useTauriWindowChrome.ts
@@ -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 `` 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]);
+}
diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx
index 16651c0b3..a1a5f5cb2 100644
--- a/src/app/pages/App.tsx
+++ b/src/app/pages/App.tsx
@@ -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,23 @@ 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 . 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();
+ if (!(isTauri() && customChrome)) return <>{children}>;
+ return (
+
+ );
+}
+
function NightLightOverlay() {
const settings = useAtomValue(settingsAtom);
if (!settings.nightLightEnabled) return null;
@@ -160,7 +181,9 @@ function App() {
-
+
+
+
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index b4fc6c4c2..160fbe334 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -33,6 +33,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();
@@ -555,6 +556,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
+
{children}
diff --git a/src/app/state/customWindowChrome.ts b/src/app/state/customWindowChrome.ts
new file mode 100644
index 000000000..ea8d3f7a3
--- /dev/null
+++ b/src/app/state/customWindowChrome.ts
@@ -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 ``; 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(
+ CUSTOM_WINDOW_CHROME,
+ (key) => getLocalStorageItem(key, false),
+ (key, value) => setLocalStorageItem(key, value),
+);
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 6d083c013..7cf16b89f 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -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,37 @@ 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(COMPOSER_TOOLBAR_BUTTON_KEYS);
+ const seen = new Set();
+ const result: ComposerToolbarButtonKey[] = [];
+
+ (order ?? []).forEach((key) => {
+ if (known.has(key) && !seen.has(key)) {
+ seen.add(key);
+ result.push(key);
+ }
+ });
+ DEFAULT_COMPOSER_TOOLBAR_ORDER.forEach((key) => {
+ if (!seen.has(key)) {
+ seen.add(key);
+ result.push(key);
+ }
+ });
+
+ return result;
};
export interface Settings {
@@ -318,6 +383,7 @@ export const getSettings = (): Settings => {
composerToolbarButtons: {
...DEFAULT_COMPOSER_TOOLBAR,
...(saved.composerToolbarButtons ?? {}),
+ order: normalizeComposerToolbarOrder(saved.composerToolbarButtons?.order),
},
};
} catch {