diff --git a/src/app/components/TauriDesktopFeatures.tsx b/src/app/components/TauriDesktopFeatures.tsx new file mode 100644 index 000000000..fa644d37f --- /dev/null +++ b/src/app/components/TauriDesktopFeatures.tsx @@ -0,0 +1,21 @@ +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'; + +/** + * 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 + return null; +} diff --git a/src/app/features/desktop/TitleBar.css.ts b/src/app/features/desktop/TitleBar.css.ts new file mode 100644 index 000000000..4b21c8196 --- /dev/null +++ b/src/app/features/desktop/TitleBar.css.ts @@ -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, + }, + }, +}); diff --git a/src/app/features/desktop/TitleBar.tsx b/src/app/features/desktop/TitleBar.tsx new file mode 100644 index 000000000..a88f8b687 --- /dev/null +++ b/src/app/features/desktop/TitleBar.tsx @@ -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 = ( + + + +); + +const MAX_GLYPH = ( + + + +); + +const CLOSE_GLYPH = ( + + + +); + +type ControlButtonProps = { + label: string; + glyph: ReactNode; + onClick: () => void; + close?: boolean; +}; + +function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) { + return ( + + ); +} + +/** + * 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): void => { + // Only the drag surface itself toggles maximize, not the brand/children. + if (evt.target !== evt.currentTarget) return; + invokeTauri('window_toggle_maximize'); + }; + + const controls = ( +
+ invokeTauri('window_minimize')} + /> + invokeTauri('window_toggle_maximize')} + /> + invokeTauri('window_close')} + close + /> +
+ ); + + const dragRegion = ( +
+ + + Lotus Chat + + +
+ ); + + return ( +
+ {mac ? ( + <> + {controls} + {dragRegion} + + ) : ( + <> + {dragRegion} + {controls} + + )} +
+ ); +} diff --git a/src/app/features/room/DraftIndicator.css.ts b/src/app/features/room/DraftIndicator.css.ts new file mode 100644 index 000000000..3d3aeaef8 --- /dev/null +++ b/src/app/features/room/DraftIndicator.css.ts @@ -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`, + }, + }, +}); diff --git a/src/app/features/room/DraftIndicator.tsx b/src/app/features/room/DraftIndicator.tsx new file mode 100644 index 000000000..6d4802d33 --- /dev/null +++ b/src/app/features/room/DraftIndicator.tsx @@ -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 ( + + + + Draft saved + + + ); +} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 9b4aa2290..9d2699116 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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( 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(null); const handleShareLocation = useCallback(() => { @@ -954,59 +965,33 @@ export const RoomInput = forwardRef( } - after={ - <> - {showFormat && ( - setToolbar(!toolbar)} - > - - - )} - {(showEmoji || showSticker) && ( - - {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( - - { - setEmojiBoardTab((t) => { - if (t) { - if (!mobileOrTablet()) ReactEditor.focus(editor); - return undefined; - } - return t; - }); - }} - /> - - } - > - {showSticker && !hideStickerBtn && ( + after={(() => { + const formatButton = showFormat ? ( + setToolbar(!toolbar)} + > + + + ) : 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 ? ( + + {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => { + const stickerBtn = + showSticker && !hideStickerBtn ? ( setEmojiBoardTab(EmojiBoardTab.Sticker)} @@ -1020,36 +1005,76 @@ export const RoomInput = forwardRef( filled={emojiBoardTab === EmojiBoardTab.Sticker} /> - )} - {showEmoji && ( - setEmojiBoardTab(EmojiBoardTab.Emoji)} + variant="SurfaceVariant" + size="300" + radii="300" + style={touchTarget} + > + setEmojiBoardTab(EmojiBoardTab.Emoji)} - variant="SurfaceVariant" - size="300" - radii="300" - style={touchTarget} - > - - - )} - - )} + /> + + ) : null; + const emojiFirst = + composerButtonOrder.indexOf('showEmoji') < + composerButtonOrder.indexOf('showSticker'); + return ( + + { + setEmojiBoardTab((t) => { + if (t) { + if (!mobileOrTablet()) ReactEditor.focus(editor); + return undefined; + } + return t; + }); + }} + /> + + } + > + {emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]} + + ); + }} - )} - {!!gifApiKey && showGif && ( - + ) : null; + + const gifButton = + !!gifApiKey && showGif ? ( + {(gifOpen: boolean, setGifOpen) => ( ( )} - )} - {gifError && ( - - {gifError} - - )} - {locationError && ( - - {locationError} - - )} - {showLocation && ( - - {locating ? ( - - ) : ( - - )} - - )} - {showPoll && ( - setPollOpen(true)} - aria-label="Create poll" - variant="SurfaceVariant" - size="300" - radii="300" - title="Create poll" - style={touchTarget} - > - - - )} - {showVoice && ( - { - setLocationError(err); - setTimeout(() => setLocationError(null), 4000); - }} - /> - )} - {charCount > 0 && ( - - {charCount} - - )} - {showSchedule && ( - - - - )} + ) : null; + + const locationButton = showLocation ? ( + {locating ? ( + + ) : ( + + )} + + ) : null; + + const pollButton = showPoll ? ( + setPollOpen(true)} + aria-label="Create poll" + variant="SurfaceVariant" + size="300" + radii="300" + title="Create poll" + style={touchTarget} + > + + + ) : null; + + const voiceButton = showVoice ? ( + { + setLocationError(err); + setTimeout(() => setLocationError(null), 4000); + }} + /> + ) : null; + + const scheduleButton = showSchedule ? ( + - + - - } + ) : 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 && ( + + {gifError} + + )} + {locationError && ( + + {locationError} + + )} + + {charCount > 0 && ( + + {charCount} + + )} + + + + + ); + })()} bottom={ toolbar && (
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 ( +
+ +
{children}
+
+ ); +} + 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 {