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>
Web fixes from the Wave-2 bug-hunt (findings in LOTUS_TODO):
- F1 (security): wipe the decrypted-plaintext search index on SERVER-FORCED
logout too (token expiry / remote sign-out) — only manual logout did before.
F4: the delete no longer reports success while onblocked (waits, 3s cap).
- M1/M2 (data-loss): useBookmarks + useUserNotes account-data writes are now
serialized at MODULE scope (single queue + latestRef per client, echo-driven),
fixing the cross-instance lost-update clobber (useBookmarks mounts per message
row, so a per-instance queue was insufficient — caught in review).
- M6: room-history export gets a 200-page cap + Cancel + unmount-abort +
correct date-range early-break (raw paginated ts). M4: image compression
skips PNG (was flattening transparency to black), bakes EXIF orientation via
createImageBitmap, .jpg-renames, and falls back to the original on decode
failure instead of dropping the file. M5: MediaGallery lightbox opens the
right item (shared thumb guard). M8: audio speed survives async decrypt.
- Desktop web wiring: D2 badge sums leaf rooms only (space double-count, like
the favicon fix); D3 useTauriDnd re-hydrates from get_tray_dnd on mount; D5
updater has a terminal state.
Reviewed; M7 reverted (past-time clamp is an intentional, tested contract).
tsc/eslint/prettier clean, build OK, 678 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- C-H1: forceState only on FIRST join; on EC reconnect re-arm the fork handlers
(resendForkState — deafen+quality only) instead of clobbering live mic/video/
deafen back to the join-time snapshot.
- C-H2: AFK auto-mute reads the fork's io.lotus.call_state VAD of the LOCAL
published track instead of getUserMedia on the browser DEFAULT mic (which could
measure silence while the user spoke on another device → auto-mute an active
speaker). Fails safe (never mutes) when call_state is null OR empty.
- C-H3: control observer re-binds after EC re-renders (body subtree:true + 100ms
debounce) with an early-return so unchanged state doesn't re-render.
- C-M3 setQuality join-gated; C-M4 hangup 4s fallback dispose (idempotent);
C-M5 PTT no longer silently un-deafens; C-M6 screenshare-audio mute resets on
stop; C-L4 deafen key works in the iframe; C-L6 setState-after-unmount guards.
Reviewed (C-H2 [] fail-safe + C-H3 re-render guard applied). tsc/eslint/prettier
clean, build OK, 677 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two federated-room bugs surfaced by the desktop build:
1. markAsRead only sent one unthreaded receipt at the main-timeline tail. With
threadSupport enabled, thread replies leave the main timeline, so a reply
newer than that tail was never covered — its per-thread notification count
(which the room dot sums) lingered, so the unread dot never cleared even
after reading. It also early-returned when the main timeline was already
read. Now also send a threaded receipt at each unread thread's latest reply.
2. useAvatarDecoration never cached non-404 failures, so every avatar mount
re-requested io.lotus.avatar_decoration for federated users whose homeserver
403s/502s the field — a refetch storm that spammed the console and hammered
our homeserver's federation. Now cache definitive rejections (400/403/404)
and give up after ~2 transient (429/5xx) attempts per session.
Gates: tsc/eslint/prettier clean, build OK, 665 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- emojibase (~965 KB) is now fully lazy: plugins/emoji.ts loads compact data +
shortcode maps via a memoized dynamic import (rejections reset the memo so a
mid-deploy chunk 404 can retry); reaction labels degrade to the raw glyph
until loaded. Consumers get FRESH array references on load (the module arrays
populate in place — same-ref state updates would skip re-render and leave
emoji search empty; reviewer-caught). Verified out of the eager graph.
- Service worker precaches hashed assets (workbox precacheAndRoute, 82 entries
~10.8 MB incl. the crypto wasm): repeat visits stop re-downloading the app.
index.html is NOT precached — navigations stay network-first so deploys are
picked up immediately; the media-auth fetch handler is untouched.
- ReactPrism: curated 21-language set — chunk 574 KB → 71 KB.
- Timeline inline images get loading="lazy".
- Removed dead dompurify (+types); sanitize-html is the real sanitizer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
threadSummary.ts (pure helpers) and ThreadSummary.tsx (chip component) lived in
the same directory differing only by case. On the case-insensitive Windows
release runner, RoomTimeline's extensionless import of ./thread/ThreadSummary
resolved .ts BEFORE .tsx and matched the helper module → rolldown
MISSING_EXPORT "ThreadSummary" — invisible on every Linux/macOS build (and the
cause of the earlier masked pdf.worker failure). Helper module renamed to
threadSummaryData.ts (+ test), 3 importers updated.
Prevention: new caseCollision.test.ts walks src/ and fails on any same-directory
names differing only by case (extensionless compare, so Foo.tsx vs foo.ts is
caught) — verified it fails on the pre-rename tree. Runs in the hard CI gate.
Gates: tsc clean, eslint/prettier clean, build OK, 658/659 tests (1 IDB skip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Soundboard v2 — a near-parallel of the custom-emoji image-pack system for
in-call audio clips.
- Data model: 3-tier packs mirroring MSC2545 — room/space pack (state event
io.lotus.soundboard, inherited by child rooms via parent-space aggregation),
global refs (io.lotus.soundboard_rooms), and the personal pack
(io.lotus.soundboard account data; the v1 flat-list content is migrated to the
pack shape on read). New plugins/soundboard/ (readers, SoundboardPack, utils) +
hooks/useSoundboardPacks (useRelevantSoundboardPacks = user U global U room,
deduped). Unit-tested (migration + slug).
- Management: reusable SoundboardPackEditor (name + emoji + per-clip volume +
delete + upload + batched save), power-level-gated for room packs like emoji
packs; a Soundboard page wired into Room + Space settings.
- In-call: CallSoundboard rewritten as a Discord-style grid grouped by pack
(emoji + name tiles), sourcing room+parent-space U personal clips; a Manage
toggle embeds the editors; per-clip volume x master volume on playback.
- Spam guard: host gates on a playing key (fork enforces one clip at a time).
- Control bar: Mute-Screenshare moved next to the Screenshare button.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- useRoomsListener now PREPENDS the emitting Room (was appended): the SDK emits
RoomEvent.UnreadNotifications with VARIABLE arity (0/1/2 args), so a trailing
extra arg landed in the wrong positional slot on the most common room-count
sync path — room.isSpaceRoom() threw inside the SDK emit loop and the badge
PUT never ran. Both consumers updated (CONFIRMED HIGH review finding).
- roomToUnread: SpaceChild RESET now passes the thread prefs so muted-thread
subtraction survives space-child state changes.
Reviewer also verified: badge subtraction math exact (no double-subtraction),
encrypted thread replies caught by the timeline guard (m.relates_to is
cleartext), fresh prefs flow to handlers, single-owner wiring load-bearing.
Documented-acceptable: hasCurrentUserParticipated can lag until the server
bundle refreshes after your first reply; dedupe maps grow per-session only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Default = Participating: thread replies notify only when you've posted in the
thread or are @mentioned; per-thread override to All / Mentions-only / Mute via
a bell menu in the thread panel header. Modes sync across devices in
io.lotus.thread_notifications account data (pruned on write: left rooms, >180d,
cap 200/room). Muted threads: no notifications/sounds, chip badge suppressed
(+BellMute glyph), and their counts are subtracted from the room's sidebar
badge (client-side; clamped ≥0).
Also fixes the thread notification path itself: thread replies are now owned by
exactly ONE handler (room-level ThreadEvent.NewReply via a new useRoomsListener
hook, with per-thread dedupe, panel-aware focus suppression, and per-thread OS
tag coalescing) — the existing RoomEvent.Timeline handlers in the notifier and
the unread binder are explicitly thread-guarded, eliminating the previously
un-gated/double path. Room badges now also refresh live on
RoomEvent.UnreadNotifications (surgical per-room PUT; fixes thread-badge lag).
Pure decision core shouldNotifyThreadReply (13-case matrix) + prune + unread
subtraction: +32 tests (648 total). E2EE caveat documented: mentions-only may
under-notify pre-decryption (same class as the existing path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Right-side thread drawer (MembersDrawer pattern; mobile fullscreen):
- ThreadPanel: header + close/Escape, ThreadTimeline, its own RoomInput
(threadRootId prop; drafts/replies/uploads isolated per roomId::threadId;
schedule + slash-commands off in threads v1) and threaded mark-as-read.
- ThreadTimeline: lean reimplementation over thread.liveTimeline — copied
useTimelinePagination pattern (/relations back-pagination + decryption),
virtualized, root event emphasized + "N replies" divider, reactions/edits/
redactions, and a pending strip (chronological local echo never enters the
thread timelineSet — rendered from LocalEchoUpdated instead).
- ThreadSummary chips on root messages (server-aggregated bundle or live
Thread; unread badge via getThreadUnreadNotificationCount) keep threads
discoverable now that replies leave the main timeline.
- Reply-in-Thread menu + thread indicators open the panel; deep links to
thread events redirect into it.
- State: roomIdToActiveThreadIdAtomFamily + getThreadDraftKey (+18 tests).
Gates: tsc clean, eslint 0 errors, build OK, 616/617 tests (1 IDB skip).
Awaiting live QA; release note: threaded replies no longer render inline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session now persists as ONE atomic cinny_session_v1 JSON write (blob-wins read,
transparent migration from the ~10 legacy keys, dual-write kept one release for
rollback). subscribeSessionChanges + useSessionSync reload a tab whose session
was changed/removed by another tab (logout/login/token rotation). OIDC refresher
already routes through setFallbackSession, so rotations stay atomic. Tests 7→22.
Full token-protection redesign remains tracked in LOTUS_BUGS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- fileEntries: a single unreadable file/dir in a dropped folder no longer aborts
the whole traversal (try/catch per entry, skip failures) — was discarding ALL
dropped files (incl. the flat-file path) + an unhandled rejection; also add
.catch in both useFileDrop consumers.
- RoomInput: mirror a localStorage-restored draft into the draft atom so the
P5-57 indicator reflects a persisted draft after a page reload, not only on
same-session room re-entry.
- useTauriThumbbar: swallow toggleMicrophone()/hangup() rejections (parity with
SMTC) — avoids an unhandled rejection when clicked mid-teardown.
- App/DesktopChrome: keep wrapper element types stable across the chrome toggle
(display:contents when off) so flipping it no longer remounts RouterProvider.
- settings: normalizeComposerToolbarOrder also appends missing keys from the
canonical key set (safety net if a new button is absent from the default order).
Gates: tsc/eslint/prettier clean, build OK, 559 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Toward MSC3861/MSC2965 next-gen-auth login (P4-6), client-only.
- cs-api.ts: type the stable `m.authentication` well-known key + getOidcIssuer()
(stable preferred over the unstable msc2965 key; {} for non-OIDC servers).
- useParsedLoginFlows.ts: getOidcCompatibilityFlag() (MSC3824 oauth_aware_preferred
/ delegated_oidc_compatibility) as a secondary OIDC hint.
- New pages/auth/oidc/oidcConfig.ts: dynamic-registration client metadata + the
non-hash callback URL (redirect_uris can't contain a fragment).
- paths.ts: OIDC_CALLBACK_PATH.
- 8 unit tests for the pure helpers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Switch to @lotusguild/element-call-embedded@0.20.1-lotus.1 (our self-built
fork) and turn on the source-level features it adds:
- #1 denoise CUTOVER: in-source ML denoise (lotusDenoiseSource=1) replaces
the build-time getUserMedia shim — removed the shim injection from
vite.config.js (denoise/ assets still shipped; the processor loads them).
Survives reconnects (fixes A7).
- #2 call-state: CallEmbed consumes io.lotus.call_state; useCallSpeakers /
useRemoteAllMuted prefer it over scraping EC's DOM (DOM fallback kept;
empty payloads ignored).
- #4 focus: CallControl.focusCameraParticipant sends io.lotus.focus_participant
(works during screenshare), replacing the DOM tile-click hack.
- #5 theming: lotusTransparent=1 (native transparent background).
- #6 decorations: LotusDecorationPusher sends each member's decoration URL
via io.lotus.decorations -> rendered on in-call tiles.
#3 soundboard / #7 quality ship dormant (EC-ready; no host UI sends them yet).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three issues from live testing:
- A1: the 'classic' ringtone (call.ogg, mastered near full scale) was much
louder than the synthesized styles. Attenuate it (CLASSIC_GAIN 0.45) so all
ringtones sit at a comparable level.
- A3/A4: the caller had no indication when a DM/group callee declined — their
UI kept "ringing" until the notification lifetime expired. IncomingCallListener
now listens for RTCDecline events for a call we're hosting in the room and
toasts the caller ("<name> declined your call").
- G1: the PiP "All muted" badge fired when any single remote participant muted.
useRemoteAllMuted now returns true only when there is >=1 remote and every
remote participant is muted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useAfkAutoMute opened its own getUserMedia capture for the whole call and only
stopped it on unmount, so the OS recording indicator stayed lit even when the
user was muted. The capture is now gated on the reactive mic-on state: it runs
only while unmuted (there's nothing to auto-mute when already muted), so muting
tears down the stream and clears the indicator, and unmuting re-acquires it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Five complete vanilla-extract themes registered in useTheme (useThemes +
useThemeNames), each spreading darkThemeData so Success/Warning/Critical keep
their semantic colors and only Background/Surface/Primary/Secondary are
recolored. A code-review pass computed WCAG contrast for every theme; all body
and accent pairs clear AA except Midnight's Primary.OnMain which was 4.49:1 —
fixed by changing OnMain #0d1320 -> #000000 (5.07:1).
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>
- 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>
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>
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>
- 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>
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>
256×256 APNG overlays from avatardecoration.com (open CORS CDN).
Stored in the user's Matrix profile as io.lotus.avatar_decoration via
MSC4133 so all Lotus Chat users see each other's decorations.
- avatarDecorations.ts: curated catalog of 110 original-IP decorations
across 9 categories (Gaming, Cyber, Space, Fantasy, Elements,
Japanese, Nature, Spooky, Cozy)
- useAvatarDecoration: per-user profile fetch with module-level cache
and in-flight deduplication so concurrent renders for the same userId
share one HTTP request
- AvatarDecoration: position:relative wrapper that overlays the APNG
8px beyond the avatar on all sides; renders nothing when no decoration
is set (zero cost for undecorated users)
- ProfileDecoration: scrollable grid picker in Settings → Profile,
grouped by category with live preview; Save button appears only when
the selection differs from what's saved
- Applied at all five avatar display sites: message timeline, members
drawer, knock list, @mention autocomplete, notifications inbox
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- usePresenceUpdater: replace stale closure with readStatus() called at
invocation time so changing custom status in Profile Settings is never
silently overwritten by subsequent activity events
- CallEmbedProvider: fix m.space.parent state-key lookup by switching
getStateEvent → getStateEvents (plural); space channel voice rooms no
longer trigger the incoming-call ring/animation
- Add useUserNotes hook (io.lotus.user_notes account data, reactive via
useAccountDataCallback, 500-char limit, cross-device sync)
- UserRoomProfile: add UserPrivateNotes textarea with 800ms debounced
auto-save, saving indicator, char counter when <100 chars remain;
shown only when viewing another user's profile
- LOTUS_FEATURES.md: add Private Notes section, Status Revert fix note,
animation improvements subsection, Seasonal Themes section
- LOTUS_BUGS.md: mark presence revert + voice ringing bugs as resolved
- README.md + landing/index.html: document all new June 2026 features
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends useTauriNotificationBadge to also invoke set_tray_unread (mirror the
unread-mention state onto the tray icon, visible when minimized) and flash_window
(flash the taskbar button when new mentions arrive while the window is unfocused).
No-op outside Tauri.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds useDeepLinkNavigate (mounted in ClientNonUIFeatures): listens for the
'lotus-deeplink' DOM CustomEvent that the Tauri shell dispatches when a
matrix:/matrix.to link opens the app, converts matrix: URIs to matrix.to, and
navigates via the same path as useMentionClickHandler (reusing matrix-to.ts
parsers + useRoomNavigate). No-op outside Tauri.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sounds (P5-16): browsers block the Web Audio context until a user gesture
starts it, so join/leave sounds — which fire later with no gesture — were
silent. unlockCallSounds() now primes/resumes the shared AudioContext inside
the Join click (centralized in useCallStart so every join path is covered),
making the per-client sounds reliably audible to everyone in the call.
Voice limit (P5-10): the limit is now a hard, cross-client server-side cap
enforced by the voice-limit-guard sidecar (matrix repo) that fronts
lk-jwt-service and refuses LiveKit tokens when a room is full. Updated
LOTUS_FEATURES.md / README.md / LOTUS_TODO.md to reflect that the client
'Channel Full' check is UX only and the server is authoritative.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
P5-10 Voice Channel User Limit:
- New StateEvent.LotusVoiceLimit (io.lotus.voice_limit) with { max_users }
- RoomVoiceLimit admin control in Room Settings > General > Voice
(power-level gated via permissions.stateEvent)
- CallPrescreen reads the limit reactively and disables Join with a
'Channel Full (N/N)' message at capacity; existing members can rejoin
P5-16 Custom Join/Leave Sound Effects:
- useCallJoinLeaveSounds hook wired into CallUtils; detects participant
join/leave via MatrixRTCSession membership changes (sender|deviceId),
filters out self, only fires while joined
- Sounds synthesized in-browser with Web Audio (callSounds.ts) - no
assets bundled; styles Off/Chime/Soft/Retro
- 'Join & Leave Sounds' selector in Settings > Calls (previews on change)
Docs: LOTUS_FEATURES.md, README.md, LOTUS_TODO.md (P5-10/P5-16 marked done)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
P4-3 — Knock-to-join Notifications for Admins:
- usePendingKnocks() hook reactively tracks knocking members via
RoomMemberEvent.Membership; returns empty array if user lacks invite power
- Members icon in RoomViewHeader shows a Warning badge with the knock count
when there are pending requests; badge updates in real time without
needing to open the drawer; aria-label updated to describe pending count
P5-11 — AFK Auto-Mute in Voice:
- useAfkAutoMute() hook opens a monitoring-only getUserMedia stream,
connects it to an AnalyserNode, and polls RMS every 500ms
- If mic is on and RMS stays below threshold for the configured timeout,
calls callEmbed.control.setMicrophone(false) and shows an in-app toast
- Hook is called inside CallControls so monitoring is only active during calls
- Settings: afkAutoMute toggle + afkTimeoutMinutes select (1/5/10/20/30 min,
default 10) added to Settings → Calls
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Status message: Synapse clears status_msg when a user goes offline/reconnects.
Fix by caching to localStorage and re-sending on setOnline(). The sync
effect no longer overwrites the local value with an empty server event.
Timezone: PUT /profile/{userId}/m.tz is MSC1769 (unstable) and not
supported by standard Synapse — save/load silently fails. Fix by using
Matrix account data (im.lotus.timezone) instead, which is fully
supported. Own profile falls back to account data; other users still
try the m.tz profile endpoint (for federated servers that support it).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Presence: subscribe on MatrixClient (mx) instead of individual User objects.
User EventEmitters have a 10-listener default; the same user appears in many
mounted components simultaneously (member list, avatars, presence rings) and
was accumulating 11+ listeners per event, causing MaxListenersExceededWarning
on User.presence, User.currentlyActive, and User.lastPresenceTs.
Fonts: download JetBrains Mono and Fira Code latin subsets to public/fonts/
and replace Google Fonts CDN links (two external origins) with a local CSS
file. Windows WebView2 tracking prevention was blocking googleapis.com and
gstatic.com, logging 18+ "blocked" warnings per session. VT323 remains on
Google Fonts as it's decorative and not a default option.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a Tauri command (set_badge_count) that draws a red circle badge
with a highlight count onto the Windows taskbar button overlay icon,
matching Discord's behavior. Badge shows @mention/highlight count only
(not total messages), clears to zero when all highlights are read.
Frontend: useTauriNotificationBadge hook reads roomToUnreadAtom and
calls set_badge_count via window.__TAURI_INTERNALS__.invoke whenever
the unread map changes. No-ops silently in the browser (non-Tauri).
Mounted as TauriEffects inside JotaiProvider in App.tsx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove status_msg from setOnline/setUnavailable so server preserves
any custom status message the user has set
- Fix manifest icons array paths (./public/android/ -> ./res/android/)
Junior had it backwards: shortcut icon was correct, main icons were wrong
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- useFileDrop: reset drag overlay when mouse leaves browser window
(relatedTarget === null signals viewport exit, counter was getting stuck)
- useDeviceVerificationStatus: add member count to useMemo deps so new
room members' devices get checked, not just initial joined members
- index.css: define --bg-surface-variant used by VoiceMessageRecorder,
MessageSearch, SearchFilters, UserRoomProfile (was falling back to transparent)
- syntaxHighlight: fix Python inline comments — # after space/tab was
treated as plain text; only start-of-line was recognised
- usePresenceUpdater: replace internal baseUrl cast with mx.getHomeserverUrl()
- useLocalMessageSearch: scan all linked timelines via getUnfilteredTimelineSet()
not just the live window, so scrolled-back history is included in E2EE search
- RoomViewHeader: show search button in encrypted rooms — local search is
implemented and handles them; the guard was a holdover from before it existed
- recent-emoji: return emojis in recency order (array is already unshifted on
use) instead of sorting by total usage count
Skipped: media gallery memory leak (needs virtualization refactor),
bookmark race condition (needs queue/lock), Night Light portal coverage
(position:fixed already covers full viewport — not a real bug).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P3-1: Message Bookmarks — right-click any message to bookmark; saved to
io.lotus.bookmarks account data (max 500, syncs across devices); star
icon in sidebar opens BookmarksPanel with filter, Jump-to-message, and
remove buttons; reactive to AccountData events
P3-2: Message Scheduling (MSC4140) — clock button next to send opens
ScheduleMessageModal with datetime-local picker; validates ≥1 min future;
calls PUT org.matrix.msc4140 delayed event API; collapsible
ScheduledMessagesTray above composer lists pending messages with cancel;
local Jotai atom tracks scheduled messages per room
P3-3: File Upload Compression — opt-in checkbox per JPEG/PNG file ≥200KB
in upload preview; canvas API compresses at 0.82 quality; shows before/
after size estimate; compressed blob used in upload when checked
P3-7: Room Insights — new Insights tab in room settings; top 5 active
members (bar chart), top 5 reactions (chips), media breakdown (4 tiles),
24-hour activity heatmap (CSS bar chart); all from local cache only with
disclaimer banner; never the first tab shown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P2-7: Domain-specific URL preview cards — YouTube shows mqdefault.jpg
thumbnail with ▶ play overlay + title; GitHub shows inline SVG icon +
owner/repo parsed from og:title + star/language info from og:description;
generic cards get a Google favicon when og:image is absent; empty cards
(no title or description) are suppressed entirely
P2-9: Live local time in user profiles — useLocalTime(timezone, hour12)
uses Intl.DateTimeFormat with the user's m.tz IANA zone; updates every
60s; shows clock icon + formatted time + timezone abbreviation (EST/JST
etc.) in dim text; respects existing hour24Clock setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>