The GIF button needs a Giphy key in the client config. Without one (the
desktop app until now), switching "GIF Picker" on did nothing and nothing
said why. With no key the tile now explains it and the switch is disabled;
with a key it is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
When a multi-line paste looks like code, a chip above the composer asks
"That looks like code. Format it as a code block (js)?" with Format as code /
No thanks. It never converts on its own; typing, No, or 8 s dismiss it, and
Settings → Editor → "Offer to Format Pasted Code" turns it off.
- utils/looksLikeCode.ts (pure, tested): ≥ 3 lines and ≥ 2 of indentation
with depth changes, statement terminators, operators + brackets, keywords,
monospace/<pre> clipboard HTML, or SQL clause lines. Prose guards: quoted
replies, URL lists, long sentence-punctuated lines, plain-word lines,
markdown lists. Language guess only when fairly sure (js/ts/python/sql/
rust/c/php).
- Accept rebuilds the pasted paragraphs as one code block from their plain
text (code lines hold text only) and leaves the caret after it.
- Code blocks carry an optional lang → <code class="language-js"> (whitelisted
identifier only).
Verified in Chromium: pasting a JS function shows the chip; Format as code →
sent formatted_body is <pre><code class="language-js"> with indentation intact;
a three-line prose paste shows no chip; typing after a paste dismisses it.
Unit tests: 14 detector fixtures + 2 output tests; chromium e2e 19 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The first time the window is closed, a dialog asks "Keep Lotus Chat
running?" — Keep running in the tray (default, focused) / Quit when I
close the window — with an opt-in "Start Lotus Chat when I sign in"
checkbox in the same moment (per the approved design: one dialog, no
wizard). The choice is saved natively; Settings → General → "When I
close the window" changes it later (tray / quit / ask me).
The dialog is role="dialog" aria-modal, labelled and described, with
focus on the default button. Web-side flow verified with a stubbed
native side: event → dialog → checkbox + Quit sends autostart enable +
resolve_close_request("quit"); the Settings select saves "tray".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Shown only while Launch on login is on, and only when the native side
answers `get_start_minimized` (older desktop builds just don't show it).
Toggling calls `set_start_minimized`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A friend's update failed ten times in a row ("Update check failed: error
sending request for url (…nsis.zip)") before the 11th went through, and
he didn't know what to do. The label was also wrong: the check had
worked; the download failed.
- Progress: "Downloading update… 28% (14.3 MB of 49.9 MB)", "The update
server didn't respond. Trying again in 3 s (attempt 2 of 4)…", from the
native `lotus-update-progress` events (cinny-desktop retries itself).
- Failures name the step (check / download / install, from the native
error prefix) in plain language, with Try again and a Download
installer button (Windows: the setup .exe; else the release page), and
the raw error under "Details".
- Installing from the update toast now shows a "Downloading update"
toast, and on failure a sticky "Update didn't install" toast that
retries on click and points at Settings → General → App Updates.
Before, the toast vanished and the failure was only visible in Settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
utils/haptics.ts: tick('ptt-on' | 'ptt-off' | 'reaction') → 10/10/8 ms
navigator.vibrate, a no-op without the API (iOS), when the system prefers
reduced motion, or when the new Settings → Calls "Haptic Feedback" switch
(default on, only rendered where the API exists) is off. PTT is observed
once through pttActiveAtom so the keyboard, global-hotkey and on-screen
paths all tick; reactions tick where the reaction event is sent in the
room and thread timelines (quick bar, hover bar, sheet and emoji board
all funnel there).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
One tile under Settings → General → Storage: total local usage vs the
browser's quota from navigator.storage.estimate(), the Chromium
usageDetails breakdown when available (IndexedDB = sync cache + encryption
keys + search index; Cache Storage = offline app files), and whether the
browser granted persistent storage — green "Protected" or amber "May be
evicted" with a "Keep my data" button that calls storage.persist(). That
last bit is the useful diagnostic for the KE-1 storage-eviction cluster.
No clear button: media lives in the browser's own HTTP cache (not in the
estimate, and not clearable from a page), the crypto store must never be
casually cleared, and the search-index clear already lives in Message
Search. The About page's "Clear Cache & Reload" now says it deletes this
device's encryption keys too. Hidden entirely when estimate() is missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
While joined, a device-local record {roomId, deviceId, joinedAt,
lastSeen, mic, video} is written and refreshed every 30 s; a deliberate
hangup (HangupCall/Close) and logout clear it. On the next start, once
the first sync is in and the room's MatrixRTC session has reported its
members (waited for up to 10 s — it fills a moment after sync), a fresh
record (< 10 min) for this device with someone still in the call yields
either a sticky "Rejoin voice?" toast (tap to rejoin) or an automatic
rejoin, per the new Settings → Calls → After a Restart (Ask / Rejoin
automatically / Do nothing; default Ask). Skipped when our own membership
is already live from another device, or only our stale one is left.
Mic/camera state comes from the record (camera still gated by
cameraOnJoin); PTT is applied by startCall as usual.
Pure decision in utils/callRejoin.ts with tests. Verified headless:
reload mid-call → toast → tap → back in the call; hangup → reload → no
toast; auto mode → back in without a prompt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Metadata was only dropped as a side effect of opt-in compression, so a
phone photo carried its GPS fix, camera model and timestamp into the room
and the media store. utils/stripImageMetadata.ts now removes it at the
container level, without touching pixels: JPEG drops APP1/APP13/COM
(writing back a minimal EXIF holding only Orientation when it isn't 1,
so sideways-stored photos still display upright), PNG drops eXIf and the
text chunks XMP lives in, WebP drops EXIF/XMP and clears the VP8X flags.
Other types pass through.
Applied before encryption on every composer path (attach, paste, drop,
share target) and to user/room avatar picks; GIF upload is excluded.
Setting → General → Privacy "Remove Photo Metadata Before Sending",
default on. The upload card says "Photo metadata removed".
Unit tests on generated fixtures with a GPS IFD (JPEG orientation 6,
JPEG + comment, PNG with eXIf + XMP, WebP with EXIF); verified end to
end: the bytes stored by Synapse decode fine and carry only Orientation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Host half of the talking-while-muted feature. The fork (0.25.0-lotus.9)
reports speakingWhileMuted for the local participant in io.lotus.call_state —
a level tap on the muted track, so cinny captures no audio. useMutedTalkWarning
polls that flag while muted; after 1.5 s of it being true it shows one sticky
toast, "You're muted — click to unmute", whose click unmutes; it fires at most
once per mute session (re-arms after unmute → mute), is retired the moment the
mic comes back on by any means, and never runs in Push-to-Talk mode. Setting
'Warn me when I talk while muted' (default on) sits next to AFK Auto-Mute.
Verified headless with a tone mic: no toast at +1 s, toast at +4 s, click
unmutes and clears it, second mute re-arms, unmute via the button clears it,
PTT mode → nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Root cause of the 'went deaf while typing' reports: the deafen key (default
M) and Cinny's type-anywhere-to-focus-the-composer both listen on window,
so the first letter of a message typed after clicking the timeline toggled
deafen and was swallowed (reproduced: typing 'mom' → deafened, composer
shows 'om'). Now: (1) Settings → Calls → Push to Deafen has an on/off
switch (deafenHotkey); (2) a letter/digit/Space deafen key only toggles
where no composer is on screen — typing wins; (3) such keys are never
bound system-wide on desktop — only F-keys, numpad and the lock/navigation
cluster qualify (isSafeGlobalToggleKey) — so 'm' typed in another app
can't deafen you. Verified in live calls: M still toggles in the call
view, is ignored on a chat screen, switch off disables it entirely.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
PTT and deafen were DOM key handlers and only fired while Lotus (or the EC
iframe) had focus — alt-tab into a fullscreen game and the voice controls
stopped working.
- useCallHotkeys: while a call is joined and the new device-local
`globalCallHotkeys` setting is on, register {ptt, deafen} bindings with
the desktop (`set_global_hotkeys`, cleared on leave) and act on its
`lotus-global-hotkey` press/release events. Events are ignored while
`document.hasFocus()` so the DOM handlers keep owning the in-focus case
(editable-field and interactive-element checks, no double toggles). PTT
press engages the mic exactly like the DOM path (pttActive set before
unmute), release restores; the existing blur/focus release covers a hold
that spans a focus change. Same modifier rules as the DOM path
(`shouldActOnGlobalHotkey`, tested).
- Settings → Calls: "Hotkeys Work Outside the Window" toggle, Tauri only.
- settingsSync: `globalCallHotkeys` is device-local (never synced).
- LOTUS_FEATURES: desktop section entry.
Native side lands in cinny-desktop (src-tauri/src/native/hotkeys.rs).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Every Lotus setting was localStorage-only, so a user on web + desktop + phone
configured theme, composer toolbar, quiet hours, call keys… three times.
- utils/settingsSync.ts (pure, 7 tests): DEVICE_LOCAL_KEYS denylist (zoom,
media auto-load, animation pause, glassmorphism, denoise tier/model,
bitrates, volumes, notification permission, developer tools, PTT mode,
camera-on-join, drawer state, and the sync toggle itself), pickSyncable,
mergeRemoteSettings (unknown keys, device-local keys and wrong-shaped
values are skipped), buildSyncedContent, shouldApplyRemote (LWW on
updatedAt; equal stamp = our own echo).
- hooks/useSettingsSync.ts: on start applies a newer remote snapshot or
pushes local if it differs; debounced push on any settingsAtom write,
skipped when the syncable subset equals the last pushed/applied snapshot
so a remote apply never echoes back; AccountData listener for live
updates; stamps forced monotonic per device; per-account lastSyncedAt
marker so another user on the same device can't inherit it; failed pushes
roll the marker back so the next change retries. Remote values are re-read
through getSettings() so enum coercion applies.
- Settings → General → Sync: toggle (device-local), "Push now", "Clear
synced copy". AccountDataEvent.LotusSettings registered.
- ClientNonUIFeatures: the #103 tracking-param subscriber moves out of
PageZoomFeature into its own TrackingParamsFeature next to
SettingsSyncFeature.
- Docs: LOTUS_FEATURES entries for #103/#104; LOTUS_TODO links the new
Features 2026-Q4 milestone and #108.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Shared links routinely carry ad/analytics identifiers (utm_*, fbclid, gclid,
YouTube si=, Amazon ref=/tag=, X s=/t=, TikTok _r/_t, …) that tie every
recipient's click back to the person who shared the link. New
src/app/utils/urlTracking.ts is a pure, local stripper: a global list +
utm_/pk_/matomo_ prefixes, plus host-scoped rules so e.g. `si` is only
removed on youtube/spotify. matrix.to and non-http(s) schemes are never
rewritten; unparseable input is returned unchanged; Amazon's `th`/`psc`
variant selectors are deliberately kept. 13 unit tests.
Wired at three points, all behind a new Settings → Privacy toggle
(`stripTrackingParams`, default on):
- paste: plain-text pastes are cleaned and re-inserted through Slate's own
insertData so multi-line pastes still split into paragraphs;
- send: RoomInput submit + schedule paths and MessageEditor saves clean both
`body` and `formatted_body` (the HTML variant unescapes `&` around each
URL and re-escapes it so the markup is untouched);
- render: linkify `formatHref`/`format` and explicit `<a href>` in
formatted_body are cleaned, so links sent from other clients are safe to
click too. LINKIFY_OPTS is spread into memoised per-timeline objects, so
the toggle is a module flag kept current by ClientNonUIFeatures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- seasonalThemeOverride is coerced to 'auto' when the persisted value is
outside the union, like every other enum setting (tested).
- PageZoomInput commits on blur as well as Enter.
- New gifPickerEnabled setting (default off) with a disclosure that GIF
searches go to Giphy; the picker doesn't mount and the composer button
is hidden while off.
Fixes#74Fixes#75Fixes#68
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- useKeyBind refuses Tab/Enter/arrows/Home/End/Page*/Escape and bare
modifier codes, and refuses a code equal to the other call key, with an
inline message (isBindableCallKey, unit-tested).
- PTT and deafen handlers ignore events with Ctrl/Alt/Meta held (deafen
also Shift), so Cmd+M / Ctrl+M no longer toggle deafen.
- PTT only preventDefault()s when the target is not an interactive
control, so Space still activates focused buttons during a call.
Fixes#23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- The "auto" seasonal theme was computed once at mount, so a long-lived session
never crossed a season/holiday-window boundary. SeasonalEffect now re-evaluates
on an hourly ticker (auto mode only) AND refreshes on entering auto — the
interval only runs while auto, so a stale mount-time timestamp would otherwise
resurface on a pinned/off → auto switch (the exact frozen-at-mount bug, caught
in review). The decision is extracted to a pure resolveSeasonTheme(override,
now) in seasonSchedule.ts (removing an unsafe cast) and unit-tested.
- Selecting seasonal "auto" while a chat background was set was a silent no-op:
the seasonal picker only cleared the background for a *specific* theme, and the
overlay is suppressed while a background is set. Now any active seasonal mode
("auto" included) clears the background; only "off" leaves it — symmetric with
the background picker (which sets seasonal "off"). The overlay guard stays as a
backstop for legacy persisted state.
Bug-hunt findings from LOTUS_TODO. Three review passes (the 2nd caught the
switch-into-auto staleness); +2 unit tests. Gate-green (tsc, eslint, prettier,
922 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mobile-audit batch 5. Desktop provably unchanged (two review passes).
- Translate-language control: raw <select> (crowded narrow tiles + broke under
non-default themes) -> the folds-native SettingsSelect used by every other
dropdown in the settings screen (native-cinny; keeps aria-label).
- Ringtone/Soundboard volume sliders: moved from the fixed-width tile `after`
slot (which squeezed the title on phones) to a full-width slider in the tile
body, matching the night-light slider pattern.
- Screenshare-confirm popover: clamp maxWidth to the viewport so it can't run
past the screen edges on a phone (inert on desktop).
- In-call soundboard editor rows wrap on a narrow popout instead of crushing the
clip-name field.
- GifPicker: feed the giphy Grid the measured container width (useElementSize
Observer) instead of a fixed 296px, so it doesn't overflow a <312px phone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add per-message translation that runs entirely on-device via the
Chromium built-in Translator + LanguageDetector APIs. Message text
never leaves the machine and never touches a cloud service, preserving
the E2EE guarantee. When the on-device engine is unavailable
(non-Chromium / mobile) the feature simply hides itself; there is no
network fallback.
- Engine abstraction (utils/translation): TranslationEngine interface
plus a chromeTranslationEngine implementation (feature-detected,
caches translator/detector instances, download-progress monitor).
Pure lang-code helpers (normalize/sameLanguage/curated targets) with
unit tests.
- Settings: translateTargetLang (default English) + autoTranslate
(opt-in), with a Messages settings tile — a target-language select
and an auto-translate switch, disabled with a note where unsupported.
- useMessageTranslation hook + shared per-event toggle atom-family and a
persisted LRU cache so scrollback never re-translates.
- UI: a Translate / Show Original message-menu action, an inline
"Translated from <lang> - Show original" chip, and a body swap in
m.text/m.emote/m.notice that renders the translated text through the
plain-text path (linkify + emoji) inside a dir=auto span for RTL.
- Auto-translate flips foreign messages whose model is already
downloaded; first-time downloads keep the manual chip (user gesture).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From review: equal start/end silently disables the tint (zero-length window).
Show an inline hint so the user isn't left wondering why nothing happens.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Night Light was all-or-nothing. Add an optional schedule with From/To time
inputs so the warm overlay only shows during set hours and toggles itself on/off
automatically (the overlay re-checks every minute; no reload). Overnight windows
that wrap midnight (e.g. 21:00 -> 07:00) are handled.
Window logic is the pure, unit-tested isWithinTimeWindow/parseHHMM in
utils/timeWindow.ts. New settings: nightLightSchedule/Start/End (default
21:00-07:00).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generalize the inline-embed system: parseMediaEmbed() resolves any supported URL
to an embed spec (provider + kind + embed URL), and a single MediaEmbedCard
renders the media-forward click-to-play facade for all of them — landscape 16:9
(YouTube, Vimeo, Dailymotion, Streamable, Twitch), portrait 9:16 (Shorts, TikTok),
and short fixed-height audio players (Spotify, SoundCloud). Same privacy facade as
before: nothing loads from the third party until the user presses play, gated by
the 'Inline Media Players' setting.
Twitch embeds pass the current page hostname as the required parent param.
Non-embeddable fallbacks (e.g. vm.tiktok.com short links, non-media tweets) keep
their existing rich OG cards; X/Twitter intentionally keeps its rich tweet card.
All parsers/builders are pure + unit-tested (videoEmbed.test.ts, 10 cases).
Desktop Tauri CSP frame-src updated for the new hosts (separate commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Video link tiles (YouTube, Shorts, Vimeo) now play in place instead of only
opening a browser tab. Adds a media-forward 16:9 (9:16 for Shorts) tile with a
privacy-friendly click-to-play facade: the homeserver's cached og:image thumbnail
+ a play button, and only on click does it swap in the cookie-less
youtube-nocookie / player.vimeo iframe — so nothing loads from Google/Vimeo until
the user presses play. Gated by a new 'Inline Media Players' setting (default on);
when off it falls back to a link that opens the video in a new tab.
Also sources YouTube thumbnails from the homeserver og:image instead of
img.youtube.com, which fixes the existing broken YouTube thumbnails on the web
build (nginx img-src has no YouTube host) and removes the pre-click Google request.
Pure URL parsing + embed-URL building moved to utils/videoEmbed.ts (unit-tested).
Note: the desktop app's Tauri CSP frame-src must allow the video hosts (separate
commit in cinny-desktop).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
B1 of the Matrix protocol-gaps roadmap, gate-green (688 tests):
- StateEvent.RoomRetention + a shared utils/retention.ts (presets, isExpired,
getRoomRetentionMs) with tests.
- RoomRetention settings control (PL-gated preset buttons Off/1d/1w/1m) in Room
Settings → General → Message Retention.
- Timeline hides events past the room's max_lifetime (gated behind Show Hidden
Events, like redactions) — messages visually disappear, losslessly.
- Opt-in setting enforceRetentionLocally (default OFF) + a headless
RetentionSweeper that permanently redacts the user's OWN expired messages
(own-only, loaded-timeline scope, dedupe + retry). Nothing auto-deletes unless
the user opts in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wave-3 bug-hunt fixes (findings in LOTUS_TODO), reviewed + gate-green:
- 🔴 ACL editor [H1–H4]: block saving an empty allow-list (was a one-click
federation brick), warn on self-ban (case-insensitive glob match of
mx.getDomain() vs allow/deny), accept real globs (1.2.3.*, *.evil.*), and
gate Save behind a confirm dialog.
- 🔴 [P1] room context menu no longer acts on the wrong room after a live
reorder (key by roomId, not list index). 🔴 [P2] status writes no longer
force presence to online over Invisible/DND (shared presenceStateFromSetting).
- 🟠 [P3] timed mutes restored on boot; [P4] custom-status auto-clear now fires
(always-mounted StatusExpiryMonitor); [P5] timezone also PUT to the m.tz
profile field so it's visible to others; [H6] RoomInsights single-pass
min/max (was Math.min(...spread) stack overflow); [H7/H8] mod-log labels.
- 🟡 [P6/P7] favorites collapse+filter, [P8] charCount reset, [P9] DM preview
refresh on decrypt; theming [T-P1] lazy decorations, [T-P2] drop the redundant
always-on body animation, [T-P4] live useReducedMotion, [T-P5] decoration key.
- NATIVE-CINNY LAW: notification presets + Powers permissions use folds icons.
DEFERRED: [H5] invite-QR is fetched from api.qrserver.com (third-party leak);
local generation needs a bundled QR lib (not added). tsc/eslint/prettier clean,
build OK, 677 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Toggling custom chrome expanded the screen and sent the message feed
auto-scrolling into the past. Two causes:
- DesktopChrome used height:100vh while html/#root use 100dvh; in the Tauri
webview 100vh can exceed the visible height after decorations are stripped,
making the timeline's scroll container taller than the viewport → the virtual
paginator runs away paginating backwards. Switched to 100dvh.
- Toggling live reflowed the whole app while the timeline was mounted. The
setting now persists + reloads so the layout is rebuilt cleanly (description
updated: "reloads to apply").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Accessible names for ~15 controls that lacked them: invite/join/create-room/
account-data/image-pack/private-note/power-level inputs (visible <label htmlFor>
where a label exists, else aria-label); the two range sliders (night-light
intensity, noise-gate threshold); the soundboard file input; media <video>
elements; and the Media Gallery (region) + Search (dialog) overlays. Hidden
notification/preview <audio> marked aria-hidden.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Element Call is now consumed as our self-built fork
(@lotusguild/element-call-embedded); wire up its previously-dormant
capabilities and document the fork as live.
Soundboard (P5-15): a call-bar button plays user-uploaded audio clips into the
call as a real published track (io.lotus.inject_audio) plus local playback.
Clips are uploadable like emoji/sticker packs, stored in io.lotus.soundboard
account data (synced across devices). Gated by a Settings toggle + volume.
Quality controls (P5-31): per-user mic/screenshare bitrate + screenshare
framerate (Settings -> Calls), applied via io.lotus.set_quality clamped to any
room cap. Room admins set caps and hard call-permissions (allow_screenshare /
allow_camera) in Room Settings -> Voice; the call bar hides blocked buttons.
- New: CallSoundboard, useSoundboard, soundboardClips; RoomQuality,
useCallQuality, callQuality (+ unit tests).
- Optimistic-write RoomQuality admin UI (no stale-state clobber).
- Docs: mark EC fork live across README/FEATURES/TODO/BUGS/TESTING; add D2
manual-test steps.
Numeric quality caps are client-cooperative; screenshare/camera permissions are
hard-enforced server-side (see LotusGuild/matrix voice-limit-guard).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings never told the user which days "Auto" turns each seasonal theme on.
Extracted the date windows out of getActiveSeason into a shared SEASON_SCHEDULE
(seasonSchedule.ts) — the single source of truth for both the runtime Auto
selector and the settings UI, so displayed dates can't drift from real activation.
- seasonal/types.ts: SeasonTheme + SeasonalOverlayProps (leaf module).
- seasonal/seasonSchedule.ts: priority-ordered SEASON_SCHEDULE with human date
ranges + SEASON_DATE_RANGES + getActiveSeason (behavior-preserving refactor).
- SeasonalEffect.tsx: consume the shared type/selector; re-export SeasonTheme.
- General.tsx: per-theme date caption under each swatch ("Oct 15 – Nov 1"), Auto
reads "By calendar", and the section description explains it.
- seasonSchedule.test.ts (6): representative day per theme, overlap priority
(Deep Space > Autumn, New Year > Lunar), inclusive boundaries, off-season null.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extracted the folds-native dropdown (Button+PopOut+Menu) from General.tsx into a
shared components/settings-select/SettingsSelect.tsx, and used it to replace raw
native <select> elements (which render OS-styled and broke under non-default
themes via colorScheme:'dark'):
- Profile "auto-clear after" select
- PushRuleEditor add-rule mode select (dropped the now-unused handleModeChange)
The form-tied timezone <select> in Profile is left for a follow-up (it's wired
to native form submission + a disabled state and needs more care).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a customAccentColor setting + a HexColorPickerPopOut in Settings →
Appearance. When set (and Lotus Terminal/TDS is OFF), it derives a full folds
Primary palette (Main/hover/active/line, contrasting OnMain, alpha-tiered
Container set, OnContainer) from the chosen color and overrides the folds
Primary CSS variables on document.body — resolving each var name from the
imported folds color.Primary.* token strings (e.g. "var(--oq6d07f)"), the
same body-level injection pattern used for mentionHighlightColor. The theme
class is on document.body, so an inline override on body wins over it.
Reverts to theme defaults when unset or when Lotus Terminal is enabled (TDS
keeps its fixed palette); the picker is disabled with a note in TDS mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add grow="Yes" to ChatBgGrid and SeasonalBgGrid containers so they
expand to fill their flex parent — without it the Box shrank to one
column (~76px wide) because folds Box defaults to display:flex and
the wrapper is a flex-row with no explicit width.
- Mark N4 (PollContent) FIXED ✅ VERIFIED in LOTUS_BUGS.md after
confirmed pass on default Cinny themes and Lotus TDS.
- Mark B1 and B4 PASS in LOTUS_TESTING.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
N53: removed the duplicate lotusTerminal PTT-badge branch (raw <Box> with
--lt-* vars + bespoke rem/animation styling). The standard folds <Chip>
path now renders in all modes; TDS theming still flows through the CSS var
layer. Dropped the now-unused lotusTerminal read.
N81: ChatBgGrid / SeasonalBgGrid containers switched from flex-wrap with
fixed-width cells to a responsive CSS grid (repeat(auto-fill, minmax(76px,
1fr))), so swatches fill the row evenly instead of orphaning a lopsided
last row at arbitrary widths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a ringtoneId setting (classic | chime | soft | retro | none) so the
incoming-call ring is no longer hardcoded to call.ogg. The three synth
styles are generated in-browser via a new utils/ringtones.ts module
(mirroring the existing callSounds.ts WebAudio pattern), so no new binary
assets are bundled; 'classic' keeps the existing call.ogg clip and 'none'
is a silent, visual-only incoming-call UI.
- ringtones.ts: startRingtone() loops until stopped; previewRingtone()
plays a single non-looping preview and auto-cancels the prior preview.
- IncomingCall: ring driven by the setting; <audio> element removed.
- Settings > Calls: Ringtone selector with on-select preview, beside the
existing Ringtone Volume slider.
- settings.ts: persisted value whitelisted back to a known id.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N69: @mention highlight color now uses HexColorPickerPopOut + react-colorful
HexColorPicker behind a folds Button (color swatch); built-in onRemove
replaces the separate Reset, dropping the OS-native <input type="color">
- N10: mentionPulseKeyframes animates only box-shadow (dropped the imperceptible
scale(1.003)) so it no longer fights MsgAppearClass over `transform` on
self-sent @mention messages
- N22: Direct.tsx virtualizer estimateSize 38 -> 52 (two-line DM row height) to
avoid the initial-render jump before measureElement corrects each row
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Selecting a join/leave sound auto-plays a preview, but nothing communicated
that. Add it to the SettingTile description.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds 'Ringtone Volume' slider (0–100, default 70%) to Settings → Calls.
The IncomingCall audio element reads the setting and applies it as
audioElement.volume before playing, replacing the implicit browser
default of 1.0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ReportUserModal.tsx — category dropdown + reason input, calls
POST /_matrix/client/v3/users/{userId}/report via mx.http.authedRequest,
inline success/error feedback, auto-closes 1500ms after success
- Wire Report User button into UserRoomProfile.tsx between UserModeration
and UserDeviceSessions (hidden for own profile)
- Bug #6: enforce mutual exclusion between chat backgrounds and seasonal
themes — ChatBgGrid clears seasonal→'off' on non-'none' pick;
SeasonalBgGrid clears chatBackground→'none' on real theme pick;
SeasonalEffect guards against legacy persisted state at render time
- TDS: strip all hardcoded hex/rgba fallbacks from LotusToastContainer.tsx
(var(--lt-bg-card), --lt-accent-orange, --lt-text-primary/secondary,
--lt-accent-orange-dim/border, --lt-box-glow-orange)
- Mark Bug #6 FIXED, MSC4260 DONE, toast TDS FIXED in LOTUS_BUGS.md and
LOTUS_TODO.md; note EventReaders + CallControls already compliant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move the model comparison out of the always-visible Noise Suppression
description and into the ML-only sub-settings. Add a compact info card
for the selected model (CPU / voice quality / transients / download) plus
a collapsible 4-model comparison. Group ML sub-settings into Model,
Enhancements, and Test & calibrate sections with clear labels and
separators. Fix invented --lt-border-color token and hardcoded
rgba background to real TDS tokens. Build the model dropdown and
DenoiseTester labels/compare buttons from DENOISE_MODELS so
DeepFilterNet 3 is handled correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Integrate DeepFilterNet 3 (deepfilternet3-noise-filter@1.2.1) as a new
client-side denoise model id 'deepfilternet', mirroring the DTLN pattern.
The npm package ships only an ESM whose AudioWorklet processor + wasm-bindgen
glue are inlined as a string (loaded via a Blob URL — no CDN for the worklet).
Its only runtime fetches are a single-threaded df_bg.wasm and an ONNX model
tarball, which previously loaded from an external CDN. We now VENDOR both
(build/denoise-vendor/deepfilternet/v2/...) and self-host them under
denoise/deepfilternet/, overriding the package's cdnUrl so nothing hits the
upstream CDN — keeping it self-hosted / Tauri-CSP safe.
The wasm is single-threaded (no SharedArrayBuffer / atomics / imported shared
memory), so it needs no COOP/COEP cross-origin isolation and runs fine in EC's
non-isolated iframe. Runs at 48 kHz fullband. Any init/runtime failure falls
back to the raw mic, like the other models.
- vite.config.js: copy ESM + vendored wasm/model into the EC denoise dir with a
required-asset guard that aborts the build if any entry is missing.
- build/lotus-denoise.js: 'deepfilternet' branch — dynamic-import the ESM, build
a DeepFilterNet3Core pointed at the self-hosted base, await init, return the
worklet node; 48 kHz; raw-mic fail-safe preserved.
- denoisePipeline.ts: 'deepfilternet' branch for the in-app tester + sampleRate.
- settings.ts: add 'deepfilternet' to DenoiseModelId + getSettings whitelist.
- lotusDenoiseUtils.ts: add the comparison-chart row.
- General.tsx: add the "DeepFilterNet 3 (beta)" dropdown option.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous "Test Microphone" meter only showed a raw 0-100% level bar — it
never ran the gate or any model, and its scale wasn't dBFS, so it couldn't tell
you which threshold to pick or let you hear the models solo. Replace it with a
real tester that reuses the shipped worklets (/public/element-call/denoise/) in
a main-app AudioContext, mirroring the call pipeline (source -> gate -> model).
- denoisePipeline.ts: shared loader for the RNNoise/Speex flat worklets and the
DTLN @workadventure helper, the noise gate, and a dBFS RMS meter helper.
- DenoiseTester.tsx:
- Live monitor: hear yourself through the selected model (+gate) in real time
(headphones) with In/Out dBFS meters and a threshold marker on the In meter
so the gate value is meaningful to calibrate.
- Record & compare: capture a short clip, then A/B the same audio Raw vs
RNNoise vs Speex vs DTLN.
- Wire it into the ML settings block; remove the old raw-only MicMeter. Use real
TDS tokens (--accent-*, --border-color, --bg-card) instead of the invented
--lt-* names + hardcoded hex the old meter used.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prior DTLN attempt (89a2321d) broke the build (missing dep, wrong
`cinny/` asset paths) and typecheck (`'dtln'` not in DenoiseModelId), and was
wired against an API the package doesn't expose. @workadventure/noise-
suppression is not a flat AudioWorklet — it's a self-contained ES module whose
processor name is `workadventure-noise-suppression` and which resolves its own
LiteRT WASM + TFLite models via import.meta.url. Driving it by hand-rolled
addModule + processorOptions cannot work.
- Re-add @workadventure/noise-suppression@0.0.4 (package.json + lockfile).
- vite: copy the package's whole dist/ tree intact to
denoise/workadventure/ (preserving assets/ + vendor/litert) so import.meta
resolution works at runtime; fail the build if the entry module is missing.
- shim: for the DTLN model, dynamic-import denoise/workadventure/audio-worklet
.js and use createNoiseSuppressionAudioWorklet(ctx, { bypassUntilReady })
to build the node; RNNoise/Speex keep their direct flat-worklet path. Async
init errors are logged + reported and fall back to the raw mic.
- Restore 'dtln' in DenoiseModelId (+ settings coercion), the model chart, and
the settings dropdown, labelled "(beta)".
DTLN builds and is fully self-hosted, but its in-call audio is UNVERIFIED in
this environment — needs a real-call test. DeepFilterNet stays excluded (CDN
asset loading, incompatible with self-hosting / Tauri CSP).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit/repair of the multi-model denoise work so it actually builds and only
exposes working, self-hosted models.
- Complete the DTLN/DFN3 revert: uninstall @workadventure/noise-suppression
and deepfilternet3-noise-filter (package.json + lockfile), drop the unused
DTLN asset-copy block from vite.config.js (was shipping ~2MB of unused
tflite/wasm), and narrow DenoiseModelId to the bundled models (rnnoise,
speex). Coerce any retired persisted model value back to the default.
- Fix General.tsx CI typecheck failures introduced by the denoise UI: restore
three imports the rewrite deleted (useDateFormatItems, SequenceCardStyle,
useTauriUpdater), add the missing denoise/sound imports, and correct
hallucinated Folds props (Text has no variant/bold; Box uses
alignItems/justifyContent). tsc now passes with 0 errors.
- Harden the vite denoise plugin: required RNNoise/Speex/gate assets and the
shim now fail the build loudly if missing (instead of a silent warn that
shipped a broken ML feature), and the index.html shim injection is verified.
- CI: move the cinny-desktop submodule bump into ci.yml as a `trigger-desktop`
job gated on `needs: build`, and delete the standalone trigger-desktop.yml.
A failing push no longer kicks off the slow Tauri builds in parallel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>