- Add three msgtype toggle chips (Images/Files/Video) to the search filter
bar, mirroring the existing "Has link" chip. The Matrix search API can't
filter by msgtype server-side, so results are post-filtered client-side
(union match on event.content.msgtype, dropping now-empty groups); the
server request is unchanged. Visible count may be lower than the server
total — inherent to client-side filtering.
- Recent searches: last 10 distinct terms persisted via a new
state/recentSearches.ts (atomWithStorage, error-safe, mirrors
scheduledMessages). Shown as clickable chips when the search input is
focused + empty, with a Clear affordance; clicking re-runs the search.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The logo was a 1080x1080 RGBA PNG but is only ever displayed at <=70px
(auth 26px, about 60px, welcome 70px). Resized to 256x256 (generous headroom
for high-DPI) with a Lanczos downscale and compressed via pngquant (q85-100).
No code change — Vite still imports the same path. Original preserved off-tree
during optimization; visually identical at display sizes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lodash was a direct dependency pinned to 4.18.1 but is not imported anywhere
in src/. It remains available transitively (slate-react/slate-dom/inquirer/
commitizen depend on it), so removing the direct declaration changes nothing
at runtime — it just drops an unused, oddly-pinned direct dep. (The audit's
"non-existent version" claim was a false positive: 4.18.1 resolves from the
registry with a valid integrity hash and loads correctly.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N100: restrict <pre> classes to language-* in sanitize-html allowedClasses;
previously `class` was allowed on <pre> with no allowedClasses entry, so a
remote sender could inject arbitrary class names that activate site CSS.
- N106: OS notifications for E2EE rooms no longer carry decrypted plaintext
(which persists in the OS notification center / lock screen). Encrypted rooms
show only the sender; the in-page toast still previews while focused.
- N109: OS notification icon/badge use the static app logo instead of an
authenticated-media avatar URL the OS can't fetch (was 401 / no icon). The
in-app toast keeps the real room avatar (it can fetch via the SW).
- N119: syncDecorations.mjs distinguishes a confirmed 404 (remove) from a
network/5xx failure (abort) so a transient CDN outage can't silently wipe the
whole decoration catalog from source control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N98: logoutClient and handleLogout now call removeFallbackSession() (removes
only the 4 session credential keys) instead of window.localStorage.clear(),
so settings, unsent drafts, PiP position, and status are preserved across a
normal logout. localStorage.clear() stays reserved for clearLoginData() (the
explicit factory-reset path).
- N99: the useSyncState callback now handles ERROR/STOPPED. A sync failure
before the first PREPARED (offline at startup, homeserver unreachable) shows
a dedicated error splash with a Retry button (startMatrix) instead of an
endless "Heating up" spinner alongside a contradictory "Connection Lost!"
banner. Guarded by a hasPreparedRef so post-PREPARED transient errors still
go through <SyncStatus>; PREPARED self-heals the splash on recovery, and the
redundant banner is suppressed while the splash is shown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N124: denoise shim cleanup() now disconnects the noise gate AudioWorkletNode
(var-scoped, guarded), releasing the gate processor thread instead of leaking
it on every getUserMedia within a session.
- N125: denoise-status postMessage now targets the parent origin (derived from
the parentUrl widget param via new URL(...).origin, falling back to this
frame's origin) instead of broadcasting with '*'.
- N128: patch-folds.mjs fails hard (process.exit(1)) when the patch target is
missing, so an unpatched folds can't silently ship. The idempotent
"already applied" path still exits 0 (verified by re-run).
- N120: the avatar-decoration CDN URL is now single-sourced in
avatarDecorations.ts (DECORATION_CDN); syncDecorations.mjs extracts it by
regex (can't import across the build/app boundary) and fails hard if renamed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useCallSpeakers rebuilt the speaker Set from only the mutated tiles in each
batch (so a still-speaking participant whose tile didn't mutate was dropped),
and observed a static querySelectorAll NodeList (so tiles for participants who
joined mid-call were never watched). Rewritten to mirror useRemoteAllMuted in
the same file: a single body-level MutationObserver (subtree+childList+attrs)
re-scans ALL [data-video-fit] tiles on each relevant mutation. The speaking
criterion (::before background-image !== 'none') and the id (aria-label +
isUserId) are unchanged, so behavior on real EC DOM is a strict superset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N113: mutations compute from a local ref kept in sync with server echoes, and
writes serialize through a promise queue, so rapid add/remove no longer reads
a stale baseline and clobbers a prior write.
- N114: ReminderMonitor shows each toast once (firedRef) but retries the
account-data removal on later ticks if it fails (removingRef released on
error) — a failed removal no longer permanently swallows the reminder.
- N115: the 30s poll interval reads reminders/mDirects via refs and drops them
from the effect deps, so it's created once instead of resetting its countdown
on every reminder sync (which could indefinitely defer a near-due reminder).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N122: setMediaState resolves on EC's transport ACK instead of waiting for a
DeviceMute state-echo that EC may elide or skip during teardown — which
previously stranded the promise forever and silently skipped the initial
deafen state + first StateUpdate on join. Dropped the single-slot
mediaStatePromiseResolver; onMediaState remains the authoritative sync path.
- N123: focusCameraParticipant now waits for a spotlight videoTile to mount via
a MutationObserver (with a 600ms hard-timeout fallback) instead of a fixed
2-frame delay that EC's React commit can exceed on slower devices.
- N126: PiP position restored from localStorage is shape+finiteness validated,
so corrupt data can't feed NaN into the position math (invalid 'NaNpx' CSS).
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>
Add sticky?: boolean to ToastNotif — sticky toasts skip the 4s auto-dismiss
timer entirely, staying until the user clicks or manually dismisses. Sticky
toasts also use cyan accent/glow (vs orange for messages) and allow the body
to wrap rather than truncate, so longer action-oriented copy is fully readable.
Update the Tauri update toast to: sticky: true, ⬆ prefix on the title,
"Click to install and restart" as explicit call to action.
Fixes: auto-dismiss before user noticed it, no visual distinction from
a regular message notification.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Security & data persistence (N97–N100): plaintext access token storage
detail, normal logout wiping user prefs via localStorage.clear(), sync
ERROR freezing the loading screen, unrestricted CSS classes on <pre>.
PWA/SW/notifications (N105–N109): missing SW notificationclick + push
handlers, decrypted E2EE message body leaked to OS notification center,
missing maskable PWA icon, auth media URLs producing 401 in notification
icon/badge fetches.
Lotus feature internals (N113–N120, N128): reminder read-modify-write
race, fire-and-forget removeReminder silently drops on network failure,
setInterval restart on every reminder state change, useCallSpeakers
rebuilds speaker set from mutation batch only (drops current speakers),
static NodeList misses mid-call tile additions, CDN outage silently wipes
decoration catalog, CDN URL drift between two source files, patch-folds
silent exit-0 when patch target not found.
Call system & noise suppression (N122–N127): setMediaState Promise hangs
forever if EC omits DeviceMute echo, focusCameraParticipant drops tile
click if spotlight isn't ready in 2 rAFs, denoise cleanup() leaks
AudioWorklet gateNode, postMessage wildcard '*' origin, PiP position
NaN on corrupt localStorage, denoise shim inactive in vite dev.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both Retry and Leave called the same dismiss function; Retry implied a
reconnect attempt that never happened. Collapsed to a single Back button
that honestly describes returning to the prescreen.
docs: correct Gemini audit entries — sanitize-html not DOMPurify (Claim A),
retract inaccurate LiveKit replaceTrack soundboard approach (Claim B,
contradicts confirmed cross-origin iframe constraint), expand N95 fix note
to clarify track-stop vs AudioContext-suspend distinction.
docs(testing): add L1 N95 reproduction guide; update A7 to reflect single Back button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review flagged that wrapping only the buttons left the dialog body copy
hardcoded (mixed-language dialogs once a non-en locale ships). Wrap the
remaining body/waiting strings ("Please accept…", "Confirm the emoji…",
"Do not Match", "Your device is verified.", etc.) and the PasswordStage
prompt, adding hooks to the sub-components that lacked one. Keys added to
en.json; all t() keys verified to resolve.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CallEmbed watchdog now SELF-HEALS: a genuine ready/joined signal arriving
after the 25s timeout clears the error and notifies subscribers with
undefined, so a slow-but-successful EC load no longer strands the user on
the recovery screen over a live call. Listener dispatch wrapped in try/catch.
- ringtones: synth notes route through a per-session master gain; stop() ramps
it to 0 so the ring is silenced instantly on answer instead of letting the
last scheduled phrase ring out over call audio.
- IncomingCallBanner: ping fires exactly once per incoming call (guarded by
refEventId) instead of re-pinging when ringtone settings change mid-banner.
- focusCameraParticipant: try multiple tile selectors (EC labels vary by
version), defer the tile click past EC's async spotlight layout switch
(rAF x2), and dev-warn when no tile matches so testers get signal.
- uploadContent: a cancelled upload (mx.cancelUpload -> AbortError) is no
longer treated as retryable — previously the retry loop could resurrect an
upload the user just cancelled. Also retry on 408.
- addRoomIdToMDirect/removeRoomIdFromMDirect: guard against a corrupt m.direct
whose values aren't arrays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wraps the hardcoded strings flagged in LOTUS_BUGS.md (Localization rows)
in t() via react-i18next, and adds the keys to public/locales/en.json
under the existing Organisms.* namespace. de.json intentionally left to
fall back to en for now (fallbackLng: 'en') rather than fabricate
translations.
Files: CreateRoomTypeSelector, ImageViewer, MsgTypeRenderers (MLocation),
Reply (ThreadIndicator), ImageContent, DeviceVerification (5 subcomponents),
UrlPreviewCard (DiscordCard), InviteUserPrompt, UploadBoard, PasswordStage.
Co-Authored-By: Claude Opus 4.8 <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>
Sweeps every remaining "FIXED ⚠️ UNTESTED" item from LOTUS_BUGS.md and
LOTUS_TODO.md into the testing guide, grouped by environment (mobile,
theming, calls, media/perf, accessibility/screen-reader, desktop/Tauri,
features) so each category can be verified in one pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Poll answer buttons referenced undefined CSS vars (--accent-cyan,
--accent-cyan-dim, --accent-cyan-border, --border-color) plus hardcoded
rgba()/#fff and raw rem font sizes, so they rendered unstyled on every
non-TDS theme (invisible borders, no selected/progress state).
Replace all colors with always-defined folds tokens (Primary.* for the
selected/indicator state, SurfaceVariant.* for the resting surface +
progress fill), size/spacing/radii with config.* tokens, and the
checkbox/radio glyphs + percentage/label text with folds <Text>. The
progress-bar-behind-text affordance is preserved (folds Button has no
equivalent), now theme-reactive. Merged the duplicate checkbox/radio
indicator spans into one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously a second incoming call was dropped from the UI entirely when the
user was already in a call (`!joined && callInfo`). Now, when joined to a
different call, a compact corner banner (caller avatar + name + Answer/Reject)
is shown instead of the full-screen IncomingCall overlay, with a single soft
ping (one-shot ringtone) rather than the looping ring so it doesn't talk over
the active call. The full overlay still shows when not in any call; being in
the ringing room's own call still shows nothing.
Built with folds primitives + TDS tokens (no hardcoded colors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1 documented as implemented (focusCameraParticipant + MemberGlance
"Focus camera" menu); #4 ringtone selection landed, with the remaining
active-call non-intrusive-notification work scoped and deferred.
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>
Mark items resolved by commits b7e1f89c / d2946c00 / 0394fce9 / 203568c9
as FIXED, and record the false-positives surfaced during the audit
(useMatrixEventRenderer null contract, Lobby getRoom already memoized,
RoomTimeline/RoomInput already wrapped by RoomView's ErrorBoundary).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
msgContent media load/thumbnail failures now log only the error name+message,
not the full error/event object that may carry content data.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CallEmbed: 25s load watchdog that fails fast on iframe error / preparing-error /
timeout instead of hanging on a permanent spinner; additive onLoadError API,
cleared on ready/capabilities/joined.
- CallView: user-visible "call failed to load" overlay with Retry/Leave (folds +
tokens) via a new useCallLoadError hook.
- CallMemberCard: wrap the participant avatar in AvatarDecoration so decorations
render in the call roster (the tile rendered UserAvatar bare while member lists
already wrapped it).
Addresses LOTUS_BUGS item 3 (avatar decorations in calls) and EC iframe failure monitoring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- uploadContent: bounded retry (max 3) reusing rateLimitedActions' capped
exponential backoff; retries only transient failures (network/429/5xx), never 4xx.
- Robust MatrixError construction from UploadResponse / unknown error shapes.
- addRoomIdToMDirect/removeRoomIdFromMDirect: drop `as any`, use typed
EventType.Direct + MDirectContent.
- usePresenceUpdater: keep fetch({keepalive}) for the unload offline-presence
update (sendBeacon can't set the auth header) and log redacted warnings instead
of silently swallowing presence errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- RoomTimeline: wrap jump-to-latest/unread + mark-as-read handlers in useCallback
(the handlers passed to memoized message children were already memoized).
- RoomInput: wrap file/upload/emoji/sticker/location callbacks in useCallback so
the editor and toolbar don't re-render needlessly.
- EmojiBoard: hoist repeated mx.getRoom() pack-label lookups into a useMemo'd map
in the emoji and sticker sidebars (previously called per-render in map loops).
Behavior unchanged. (RoomTimeline/RoomInput already have ErrorBoundary wrappers
in RoomView, so no boundary added.)
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>
Extract a shared ReportCategorySelect: folds Button trigger + PopOut +
FocusTrap + Menu + MenuItem (escape + arrow-key nav, like OrderButton),
replacing the OS-styled native <select> in both ReportRoomModal and
ReportUserModal.
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>
Both rendered as <Box as="form" role="dialog"> with manually assembled
background/borderRadius(R400)/boxShadow. Switch to <Dialog as="form"
variant="Surface"> so the surface comes from the design system (R300 radius),
matching the other message-action dialogs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReportRoomModal/ReportUserModal rendered as <Box as="form" role="dialog">
with inline background/borderRadius(R400)/boxShadow. Switch both to
<Dialog as="form" variant="Surface"> so the surface (background, R300 radius,
shadow) comes from the design system, matching MessageReportItem and every
other message-action modal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N14 ForwardMessageDialog: add folds <Header> with title + close IconButton
(was closeable only by clicking outside)
- N20 Notification presets: bare <button> with undefined --border-interactive-
normal / --bg-surface-low vars -> folds <Button variant="Secondary" fill="Soft">
- N68 syntaxHighlight tokenStyle: use the theme-aware --prism-* variable family
(keyword/selector/boolean/atrule/comment) instead of TDS-only --lt-accent-*
vars with dark-only Monokai fallbacks; comment uses --prism-comment not opacity
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure formatting reflows (multi-line wrapping of long lines/imports/tables);
no behavior change. Clears the working tree of pending prettier diffs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The real reason the gallery didn't look or function like the Members drawer
or Saved Messages: it was a position:fixed overlay floating over the timeline,
mounted from RoomViewHeader. Now it docks into the room layout row exactly like
MembersDrawer.
- new mediaGalleryAtom (mirrors bookmarksPanelAtom) holds the open state
- RoomViewHeader toggles the atom instead of local useState and no longer
renders the panel
- Room.tsx renders <MediaGallery> as a flex sibling of the timeline with a
vertical Line separator on desktop and key={room.roomId} to reset per room
- MediaGallery.css: static width on desktop, position:fixed inset:0 full-screen
only on mobile (identical strategy to MembersDrawer.css); root Box shrink="No"
The panel now shares the row with the timeline instead of overlapping it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Media Gallery panel worked but didn't look like a first-party Cinny
drawer. Redesign the chrome to match MembersDrawer / Saved Messages and the
PolicyListViewer tab precedent:
- panel + header: Surface -> Background variant; header uses Text size="H4"
and a plain close IconButton (dropped the bespoke tooltip-wrapped button)
- tabs: moved into a bordered toolbar strip; adopt the repo's
variant={active?'Primary':'Secondary'} fill={active?'Solid':'Soft'} pattern
and show per-tab counts (Images (N) / Videos (N) / Files (N))
- month grouping: replaced the centered "lines + label" divider with a
left-aligned group label (the Cinny group-label pattern)
- thumbnail tiles: hover/focus border + caption overlay are now CSS-driven
(:hover / :focus-visible) instead of React state, and live in
MediaGallery.css.ts; grid + file rows tokenized
- caption overlay also reveals on keyboard focus (a11y)
All styling consolidated into MediaGallery.css.ts; no inline grid/tile styles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Avatar decorations: useAvatarDecoration cached ALL profile-field fetch
failures as "no decoration" permanently for the session. The member list
and timeline mount many avatars at once, so one rate-limited (429) burst
would wipe everyone's decoration until a full reload. Now only a genuine
404 (field unset) is cached; transient errors retry on the next mount.
Saved Messages panel — full redesign to match the canonical MembersDrawer:
- co-located BookmarksPanel.css.ts: toRem(266) + max-width:750px full-screen
media query, replacing the old position:absolute/zIndex:100 mobile "modal"
that had no backdrop or escape
- variant="Background" header; room avatars on each item (was a generic hash)
- priority tokens replace all raw opacity hacks; 3px borderLeft accent removed
- Escape-to-close; multi-line preview is now a proper folds Button (N38)
Media Gallery (N12): moved fixed positioning + width into MediaGallery.css.ts
using toRem(320) + a full-screen media query; border/header use config tokens;
added Escape-to-close on the panel (previously only the lightbox handled it).
Presence (SettingsTab / useUserPresence):
- N16: wrap presence-dot trigger in TooltipProvider; replace undefined
--bg-surface with color.Background.Container
- N17: add escapeDeactivates + isKeyForward/isKeyBackward to the FocusTrap
- N19: align reader labels (usePresenceLabel) to the setter vocabulary
(Online/Idle/Offline) so a chosen status matches the tooltip others see
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four changes to match screenshare full-screen UX for camera feeds:
1. Fullscreen button always visible
CallControls.tsx: remove `screenshare &&` gate — the ⛶ fullscreen
button now appears in camera-only calls, not just during screenshare.
2. Per-participant camera focus (CallControl.focusCameraParticipant)
Finds the target's video tile in the EC iframe DOM via:
[data-testid="videoTile"] / [data-video-fit]
closest ancestor of [aria-label="${userId}"]
Enables spotlight mode if not already active, then clicks the tile
so EC's internal focus handler runs. Falls back gracefully if the
tile is not in the DOM (camera off).
3. MemberGlance participant popup
Clicking a participant avatar in the call status bar now shows a
small menu: "Focus camera" (calls focusCameraParticipant) and
"View profile" (existing behaviour). Previously it opened the
profile immediately with no way to focus the camera.
4. PiP fullscreen button
A ⛶/⊡ icon button appears in the PiP overlay top-right area,
letting users go fullscreen directly from PiP mode without
navigating back to the call room first.
UNTESTED — requires a real multi-participant call to verify tile
clicking behaviour and fullscreen transitions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace raw error object logging (which may contain Matrix event
payloads, user IDs, or message bodies) with e.message-only strings
in three files:
- CallEmbed.ts: state update and event widget feed errors
- msgContent.ts: image/video element load failures and thumb errors
- RoomInput.tsx: GIF send failure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously PipMuteOverlay fired on useRemoteAllMuted (any remote
muted) and rendered in the bottom-left corner — the conventional
position for local-user mic status — causing users to think their
own mic was muted when it wasn't.
Fix: split into two distinct indicators
- Bottom-left: local mic muted only (from useCallControlState),
labelled "You" so attribution is unambiguous
- Top-right: "All muted" warning (warning color, not critical) when
all remote participants are muted
UNTESTED — verify in a real call at chat.lotusguild.org.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Wrap RoomTimeline in ErrorBoundary — a single bad event no longer
crashes the entire timeline; shows a graceful "Timeline unavailable"
message instead
- Wrap RoomInput in ErrorBoundary — composer crashes show a fallback
placeholder rather than a blank white section
- Animate SpeakerAvatarOutline with a 1.2s pulse keyframe so it's
visually distinct from a static ring; respects prefers-reduced-motion
- Fix var(--border-surface-variant) undefined variable in UserRoomProfile
device session rows; replaced with color.SurfaceVariant.ContainerLine
UNTESTED — verify at chat.lotusguild.org post-deploy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add SelectSenderButton: clickable people picker for the From filter
replacing the text-only from:@user syntax
- Add date preset shortcuts in DateRangeButton (Today, Last week,
Last month, Last year) for one-click range selection
- Add Has link chip backed by Matrix contains_url API filter; toggle
removes cleanly with X badge
- Wire containsUrl through URL params, useMessageSearch hook, and
SearchFilters props
UNTESTED — verify at chat.lotusguild.org post-deploy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces undefined --border-surface-variant CSS variable with
color.SurfaceVariant.ContainerLine from folds, and --bg-surface-variant
with color.SurfaceVariant.Container. Both are valid theme tokens
that adapt to light/dark mode correctly.
Co-Authored-By: Claude Sonnet 4.6 <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>
Reaction.tsx now computes aria-label='{shortcode} reaction, N people'
using getShortcodeFor so screen readers announce emoji name and count
instead of an ambiguous button. Custom (mxc://) emoji falls back to
'custom emoji reaction'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PageHeader now exposes aria-label="{room name} room header" so screen
reader users know which room's header they are navigating.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Screen readers now announce new messages politely via role='log' +
aria-live='polite' on the message container in RoomTimeline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two icon-adjacent buttons were missing descriptive labels: the
"Exit formatting" key-symbol button in Toolbar.tsx and the "Pinned
messages" pin icon in RoomViewHeader.tsx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OS notifications now show the real message body ("user: text" instead
of "New inbox notification from user"), clicking jumps directly to the
room at the triggering event, and window.focus() brings the tab to
front. Reminder toasts also link to the specific event via eventId.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds objectPosition:'center top' to all cover-fit thumbnail surfaces so
portrait images show faces/subjects instead of the center-slice when
the 600px AttachmentBox height cap forces cropping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MediaGallery: fixed panel now goes full-width (100%) on mobile instead
of the inaccessible 320px right sidebar. Added 'Media Gallery' MenuItem
to RoomMenu (visible only on mobile) so users can open it from the
More Options (···) button.
MembersDrawer: removed ScreenSize.Desktop gate in Room.tsx so it now
renders on mobile too. CSS media query (≤750px) makes it position:fixed
inset:0 width:100% on mobile instead of the 266px desktop sidebar.
Added 'Members' MenuItem to RoomMenu for mobile access.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds enabled=true param to useDecryptedMediaUrl in MediaGallery.tsx.
GalleryTile now uses useNearViewport(300px) — decryption is deferred
until the tile approaches the viewport, preventing burst of 100
concurrent decrypt/fetch calls when a pagination batch loads.
Blob revocation was already correct (no actual leak); this fixes the
load-burst performance issue. Full windowing virtualization deferred.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Completes the mobile fullscreen modal pass — adds useModalStyle to
DeviceVerificationSetup, DeviceVerificationReset, AddExistingModal,
RoomEncryption prompt, RoomUpgradeDialog, Modal500, ReadReceiptAvatars,
and RoomTopicViewer. All floating Dialog/Modal components now go
fullscreen on mobile (≤750px). UIAFlowOverlay was already fullscreen
via <Overlay>; JoinRulesSwitcher/RoomNotificationSwitcher are dropdowns.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates useNearViewport hook (IntersectionObserver, 200px rootMargin,
one-shot disconnect after first trigger). ImageContent and VideoContent
now gate loadSrc() on nearViewport — when autoPlay is enabled, encrypted
media is not decrypted until the element is within 200px of the visible
area, reducing initial page load cost on long timelines.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TauriUpdateFeature component in ClientNonUIFeatures checks for updates
on mount and every 12h (skips if checked within the window). On update
available, fires a Lotus toast: "Lotus Chat vX.Y.Z is ready to install."
Clicking the toast calls install(). No-op on web (isTauri guard).
Also adds optional onClick to ToastNotif type and wires it in
LotusToastContainer so custom click handlers can skip hash navigation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- matrix.ts: rateLimitedActions fallback delay now uses capped exponential
backoff (min(1000 * 2^n, 30s)) instead of flat 3000ms when server omits
Retry-After; server header still takes precedence
- RenderMessageContent: add objectFit:cover + 100% fill to video thumbnail
<Image> so thumbnails fill their container without letterboxing (P5-6)
- CreateRoomModal, CreateSpaceModal: apply useModalStyle(480) for fullscreen
on mobile
- LOTUS_BUGS: mark usePan memory leak + httpStatus check as FALSE POSITIVE;
mark rateLimitedActions backoff as FIXED
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On mobile, SidebarNav (which contains BookmarksTab) is hidden while
inside a room. Added a Saved Messages toggle to the RoomMenu (···More
Options) so users can open/close the bookmarks panel without leaving
the room. Works on all screen sizes; UNTESTED on device.
Also marks Remind Me Later as done in LOTUS_TODO — it was already
fully wired (RemindMeDialog + ReminderMonitor + Message.tsx trigger).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ScheduleMessageModal, PollCreator, JoinAddressPrompt, JumpToTime,
EditHistoryModal, ForwardMessageDialog, RemindMeDialog — all now
render fullscreen on mobile (≤750px) and as a capped-width box on
desktop, consistent with the existing useModalStyle pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Bug #10: use `100dvh` on <html> so layout shrinks when mobile virtual keyboard
appears (prevents composer from being pushed off-screen)
- Bug #7: add `minWidth/minHeight: 44px` to all 8 composer toolbar IconButtons on
mobile via mobileOrTablet() check (WCAG 2.1 AA touch target requirement)
- Bug #8: add `@media (max-width: 750px) { width: 100% }` to PageNav recipe
variants so the nav panel fills full width on mobile instead of overflowing
with its fixed desktop width
- Bug #9: introduce `useModalStyle(maxWidth)` hook — returns fullscreen styles on
mobile (no border-radius, no max-width cap, height 100%) and desktop box styles
otherwise; applied to LeaveRoomPrompt, LeaveSpacePrompt, ReportRoomModal,
ReportUserModal
- Bug #11: mark as FALSE POSITIVE in LOTUS_BUGS.md — `useState(() => atom(...))`
is the correct Jotai pattern for stable local atom references
- Scheduled Messages persistence: mark as FIXED — already uses atomWithStorage +
createJSONStorage with error-safe JSON parsing
- UrlPreviewCard TDS colors: mark as BRAND EXCEPTION — SVG logo fills and site
badge backgrounds are official third-party brand colors; cannot convert without
inventing new CSS variables (violates TDS rule 3)
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>
Replace raw CSS variables and Button presets with folds color/config
tokens and MenuItem components, matching the ForwardMessageDialog and
ScheduleMessageModal patterns exactly.
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>
Two issues found from real testing of the in-app tester:
1. Raw ≈ RNNoise ≈ Speex sounded identical in Record & compare because the clip
was captured with browser noise suppression ON (the user's native-NS
setting), so "Raw" was already cleaned and the models had nothing left to
remove. Record & compare now captures fully raw audio (noiseSuppression /
AGC / echoCancellation off) so each model's effect on real noise is audible.
(Friends still heard differences in calls — the models work; the test was
feeding them pre-cleaned audio.)
2. DTLN was robotic/choppy/quiet because @workadventure/noise-suppression
targets 16 kHz (AUDIO_CONFIG.sampleRate) and does NOT resample internally,
while we ran it at 48 kHz. Run DTLN's whole graph in a 16 kHz context:
- denoisePipeline: add sampleRateFor(model) (16k for dtln, 48k otherwise);
tester live-monitor + playback contexts use it (bufferSource resamples the
48k clip down for DTLN).
- shim (build/lotus-denoise.js): SAMPLE_RATE is now model-aware, so DTLN is
correct in real calls too (it was previously broken at 48 kHz). The 16 kHz
processed track is still published to LiveKit (WebRTC/Opus resamples).
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>
- Remove non-functional DTLN and DFN3 dependencies and UI options.
- Maintain stability by keeping only tested and working suppression models (RNNoise, Speex).
- Verified that build passes and all assets are correctly bundled.
- Verified package layouts and integration paths for @workadventure/noise-suppression (v0.0.4) and deepfilternet3-noise-filter (v1.2.1).
- Updated build configuration to correctly copy WASM, TFLite, and ONNX assets.
- Integrated DTLN and DeepFilterNet initialization logic into the audio shim.
- Enabled all four models (RNNoise, Speex, DTLN, DFN3) in Settings UI.
Implement a flexible, multi-model noise suppression pipeline for Element Call/LiveKit integration:
- ML Engines: Added support for RNNoise, Speex, DTLN, and DeepFilterNet 3 models.
- Pipeline Architecture: Implemented modular audio processing in lotus-denoise.js, supporting 'Series Suppression' (running browser-native NSNet2 before ML) and a hardware-style Noise Gate.
- UI & UX Enhancements:
- Settings UI: Added model comparison chart with CPU/Quality metadata.
- Tuning: Added Live Microphone Meter for calibrating Noise Gate thresholds.
- Reporting: Added LotusToast system to alert users when ML suppression fails or falls back to raw input.
- Robustness & Quality:
- Capture Fidelity: Removed forced 48kHz capture constraints to allow native-rate capture (solving static issues with high-end audio interfaces).
- Performance: Added WASM SIMD detection with transparent fallback.
- Capability Detection: Added browser feature detection to disable unsupported ML modes.
- Build Integration: Updated Vite config to self-host all model WASM/tflite assets in /denoise/ directory.
ML noise suppression produced loud static on real calls. RNNoise requires
mono 48kHz float input; feeding it stereo or wrong-rate data is the classic
cause of that static. Harden the shim:
- request mono (channelCount:1) + 48kHz capture
- run a 48kHz AudioContext and BAIL to the raw mic if the browser won't
give a true 48kHz context (wrong-rate data -> static)
- force the worklet node to explicit mono in/out
- use the non-SIMD rnnoise.wasm (SIMD build artifacts on some GPUs)
- share one AudioContext across captures
Also fix the two CI-blocking eslint errors (unused vars in UrlPreviewCard
and useLocalMessageSearch) and apply repo-wide prettier formatting so
check:eslint and check:prettier pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the boolean call noise-suppression setting with a 3-way control
(Off / Browser-native / ML beta) in Settings -> General -> Calls.
- Off: noiseSuppression=false to Element Call
- Browser-native: EC's built-in WebRTC suppressor (prior default)
- ML (beta): on-device RNNoise (@sapphi-red/web-noise-suppressor)
Element Call captures the mic inside its iframe and publishes to LiveKit,
so the host can't reach that track; LiveKit's Krisp filter is Cloud-only
(we self-host the SFU) and EC's own RNNoise PR #3892 is unmerged. The ML
tier instead injects a same-origin pre-init shim into the vendored EC
index.html (build/lotus-denoise.js, wired by the lotusDenoise vite plugin)
that patches getUserMedia and routes the captured mic through an RNNoise
AudioWorklet before LiveKit sees it -- the same post-capture pipeline as
#3892, with no EC fork/AGPL/rebase burden. Falls back to the raw mic if
setup fails; keeps echoCancellation/AGC on the raw capture.
- settings.ts: callNoiseSuppression -> 'off'|'browser'|'ml' + legacy
boolean migration (true->browser, false->off)
- CallEmbed/useCallEmbed: tier maps to noiseSuppression param and appends
lotusDenoise=ml (native suppressor off in ML mode)
- vite.config.js: copy RNNoise worklet/wasm + shim into the EC bundle and
inject the shim <script> before EC's module entry
- docs: LOTUS_FEATURES.md, LOTUS_TODO.md (P5-30 done)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New Year: replace flashing animBurst rays with gentle falling confetti
- Lunar New Year: reduce 9 lanterns to 4, halve sizes, dim silk/shimmer
- April Fools: remove all glitch/scanline/watermark effects; replace
with a subtle rainbow stripe and falling punctuation symbols
- Add SeasonalPreview export (position:absolute, reduced-motion) for
use inside contained card elements
- Replace SettingsSelect dropdown for Seasonal Theme with SeasonalBgGrid,
a visual card grid (matches ChatBgGrid pattern) showing ambient previews
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>