Compare commits

...

46 Commits

Author SHA1 Message Date
jared 79f8fabb1b fix(ui): GIF picker surface tokens + background swatch chrome (N8, N70)
CI / Build & Quality Checks (push) Successful in 10m46s
CI / Trigger Desktop Build (push) Successful in 6s
- N8: GifPicker non-TDS container used undefined var(--bg-surface) + raw
  rgba/12px/boxShadow. Switch to folds tokens (color.Surface.Container,
  config.radii.R400, color.Surface.ContainerLine, color.Other.Shadow).
  TDS branch keeps its --lt-* glow chrome.
- N70: ChatBgGrid/SeasonalBgGrid swatch buttons moved chrome (radius, border,
  hover, keyboard :focus-visible ring, selected via data-selected) into shared
  BgSwatch.css.ts using design tokens; only per-swatch size + live preview
  background stay inline (custom preview tiles, not MenuItem/Chip candidates).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:01:57 -04:00
jared dfd2c9c49e fix(ui): mention color picker, send-animation conflict, DM virtualizer (N69,N10,N22)
- 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>
2026-06-19 20:54:32 -04:00
jared 5470e25bb0 fix(ui): report category dropdown uses folds menu, not native select (N56)
CI / Build & Quality Checks (push) Successful in 10m52s
CI / Trigger Desktop Build (push) Successful in 7s
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>
2026-06-19 20:41:46 -04:00
jared 374d6dc396 fix(ui): note join/leave sound preview affordance (N82)
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>
2026-06-19 20:37:55 -04:00
jared d0715774a8 fix(ui): ScheduleMessageModal + PollCreator use folds Dialog shell (N15, N29)
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>
2026-06-19 20:32:16 -04:00
jared 6f544e2b1f fix(ui): report modals use folds Dialog shell (N63)
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>
2026-06-19 20:26:17 -04:00
jared e713d47319 fix(ui): forward-dialog header, notification presets, syntax-highlight tokens
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 8s
- 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>
2026-06-19 18:22:25 -04:00
jared b361d43088 fix(ui): native inputs/checkboxes, QR fallback, focus + report modal cleanup
- N23 RoomServerACL: raw text input -> folds Input; raw checkbox -> folds Checkbox
- N24 PolicyListViewer: raw room-id input -> folds Input (Critical variant on error)
- N25 ExportRoomHistory: raw <input type="date"> x2 -> folds Input
- N26 RoomShareInvite: QR <img> gets loading="lazy" + onError fallback card
  ("QR code unavailable") instead of a broken-image icon
- N27 GifPicker: FocusTrap returnFocusOnDeactivate:false (matches EmojiBoard)
- N76 Report modals: drop redundant Cancel button (dismiss via header x /
  click-outside, like MessageReportItem)
- N5 ReadReceiptAvatars: hover/focus moved to co-located css :hover/:focus-visible
  (removed JS onMouseEnter/Leave .style mutation)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:12:25 -04:00
jared a33d28a7ae style: apply Prettier formatting to remaining files
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>
2026-06-19 16:41:57 -04:00
jared 4a4dede105 fix(media-gallery): dock as a flex sibling like MembersDrawer (was floating)
CI / Build & Quality Checks (push) Successful in 10m34s
CI / Trigger Desktop Build (push) Successful in 6s
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>
2026-06-19 16:14:50 -04:00
jared b818d3fc5a refactor(media-gallery): redesign chrome to match native folds conventions
CI / Build & Quality Checks (push) Successful in 10m25s
CI / Trigger Desktop Build (push) Successful in 11s
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>
2026-06-19 13:07:51 -04:00
jared cf839e7345 fix(ui): avatar-decoration reliability, Saved Messages + Media Gallery redesign
CI / Build & Quality Checks (push) Successful in 10m30s
CI / Trigger Desktop Build (push) Successful in 9s
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>
2026-06-19 11:21:29 -04:00
jared c54cb126ff fix(ui): settings modal sizing regression + 17 more folds audit findings
CI / Build & Quality Checks (push) Successful in 10m47s
CI / Trigger Desktop Build (push) Successful in 5s
Fix settings modal regression: Modal500 was wrapped in useModalStyle(560),
forcing maxWidth 560px and squishing the two-pane Settings layout (folds
size="500" is ~50rem). Restore desktop width to the folds recipe while
keeping mobile fullscreen.

N-series fixes:
- N13 ScheduledMessagesTray header: <Box as="button"> -> folds <Button>
- N28 composer char counter: drop undefined --tc-surface-low + opacity,
  use priority="300" and config.space token
- N31 collapsible "Read more" toggle: padded <Button> -> flush inline-button
  pattern matching (edited) link
- N41 UserPrivateNotes "Saving..." now shows a folds <Spinner>
- N43 Night Light slider: add accentColor; label opacity -> priority
- N44 mention-highlight Reset: bare <button> -> folds <Button> (drops
  undefined --border-interactive-normal); Boot button kept (TDS-only)
- N45 SelectTheme trigger variant -> Secondary to match SettingsSelect
- N49 RoomInsights StatTile emoji -> folds <Icon> (Photo/VideoCamera/
  Headphone/File)
- N54/N57 PiP overlay badges + fullscreen button: token discipline
  (config.radii/space, folds Text); dark scrim kept for video legibility
- N60 knock badge: match Pinned Messages pattern (no wrapper div, toRem
  insets, no hardcoded size overrides)
- N62 unverified-device banner: 3px left-accent -> standard border via
  color.Warning.ContainerLine; drop opacity hacks
- N65 Edit History: real "Load more" pagination (accumulate next_batch,
  de-dupe by id, re-sort by ts) replacing passive text
- N66 search date fields: raw <input type="date"> -> folds <Input>
- N67 SeasonalEffect z-index 9999 -> 9997 (below Night Light + modals)
- N73 Pending Requests header uses css.MembersGroupLabel
- N74 remove raw em-sized emoji <span> in RoomNavItem name
- N85/N86 RemindMeDialog: <Box role="dialog"> -> folds <Dialog>; preset
  MenuItems -> Buttons (fixes invalid menuitem-in-dialog ARIA)

Document deliberate WON'T FIX rationale for N9, N51, N61, N71, N75, N77.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:15:35 -04:00
jared 8dc4c4d072 fix(ui): resolve 29 native UI/UX inconsistencies from folds design audit
CI / Build & Quality Checks (push) Successful in 10m25s
CI / Trigger Desktop Build (push) Successful in 6s
Fixes N1–N94 findings from LOTUS_BUGS.md audit pass. Key changes:

- ProfileDecoration: raw <button> → folds <Button> for save/remove; remove
  undefined --accent-cyan var
- UserRoomProfile: textarea border uses color.SurfaceVariant.ContainerLine
  and config tokens instead of undefined --border-interactive var
- LotusToastContainer: z-index raised from 9997 → 10001 so toasts appear
  above Night Light overlay (9998) and modals (9999)
- Message.tsx: DeliveryStatus replaces Unicode glyphs with Icon components;
  MessageQuickReactions returns null instead of <span />; forward menu item
  gets correct size="100" on after icon
- AudioContent: speed chip variant/radii now matches Play chip (Secondary/300)
- ReadReceiptAvatars: pill border/radius/padding → folds config tokens;
  remove dead receipt-pill-btn className
- EventReaders: Header size 600→500; close button gets radii="300";
  borderBottom shorthand → borderBottomWidth token; remove raw fontSize
- General.tsx: selected background/seasonal picker border uses
  color.Primary.Main instead of color.Critical.Main (error red)
- RoomInsights: SectionHeader drops textTransform/letterSpacing/opacity;
  chart borderRadius → config tokens; remove raw fontSize:9;
  warning banner → SequenceCard
- RoomProfile.tsx: formatting toolbar raw <button> → folds <Button>;
  topic read-mode renders formatted_body via sanitizeCustomHtml
- MsgTypeRenderers: location Open button Chip→Button; opacity:0.65→priority
- UploadCardRenderer: caption raw <input> → folds <Input>
- VoiceMessageRecorder: replace undefined --bg-surface-variant/--tc-*
  vars with color.* tokens; replace bare <audio controls> with
  IconButton play/pause toggle
- App.tsx: mention highlight uses WCAG 2.1 relative luminance (gamma
  linearization) instead of simplified approximation; border now rgba
  semi-transparent instead of same color as background
- RoomNavItem: Mute MenuItem icon moved to before prop
- SearchFilters: HasLink chip variant="Success" outlined to match filter bar
- RoomViewHeader: Server Notice chip radii Pill→300; fix jotai import order
- Fix ESLint import/order errors in DeviceVerificationSetup, RoomTopicViewer,
  MediaGallery, and RoomViewHeader

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:46:19 -04:00
jared 9742eaea28 feat(call): full-screen camera broadcast parity
CI / Build & Quality Checks (push) Successful in 10m22s
CI / Trigger Desktop Build (push) Successful in 7s
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>
2026-06-18 20:39:58 -04:00
jared fb66c0ed90 fix(privacy): sanitize console error/warn to prevent PII leakage
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 7s
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>
2026-06-18 19:24:52 -04:00
jared 9deeef6e8d fix(pip): correctly identify whose mic is muted in PiP overlay
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>
2026-06-18 19:23:12 -04:00
jared e2b957b6bd fix: resilience, speaker animation, CSS variable fixes
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 6s
- 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>
2026-06-18 19:19:54 -04:00
jared abf15391f6 feat(search): sender picker, date presets, has:link filter
- 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>
2026-06-18 19:14:42 -04:00
jared 44e36f7dd2 fix(search): use proper folds color tokens for date range picker (P4-9)
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>
2026-06-18 19:03:21 -04:00
jared a77c4b6db5 feat(calls): configurable ringtone volume in Settings (Bug #4 partial)
CI / Build & Quality Checks (push) Successful in 10m29s
CI / Trigger Desktop Build (push) Successful in 15s
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>
2026-06-18 18:56:04 -04:00
jared cb3d2c40e5 fix(a11y): descriptive aria-label on reaction buttons (P3-4)
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>
2026-06-18 18:50:19 -04:00
jared f50e14d7a5 fix(a11y): label room page header with room name for screen readers (P3-4)
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>
2026-06-18 18:46:30 -04:00
jared 0ead519a80 fix(a11y): add role=log aria-live to message timeline (P3-4)
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>
2026-06-18 18:43:50 -04:00
jared 7d98b49a30 fix(a11y): add aria-label to exit-formatting and pin-menu buttons (P3-4)
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>
2026-06-18 18:41:20 -04:00
jared f054abfbd2 fix(notifications): click navigates to specific message, not inbox
CI / Build & Quality Checks (push) Successful in 10m30s
CI / Trigger Desktop Build (push) Successful in 7s
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>
2026-06-18 18:37:19 -04:00
jared 2b5c6fd606 perf(media): center-top focal point on cover-fit thumbnails (P5-6)
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>
2026-06-18 18:33:36 -04:00
jared ffa490e767 fix(mobile): make media gallery and members panel accessible on mobile
CI / Build & Quality Checks (push) Successful in 10m30s
CI / Trigger Desktop Build (push) Successful in 9s
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>
2026-06-18 18:21:58 -04:00
jared 8ac42cdbad perf(gallery): gate tile decryption until near-viewport
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>
2026-06-18 18:16:15 -04:00
jared 1b4c6cab6d fix(a11y): add missing aria-labels to message buttons
- FallbackContent: '(edited)' button now has aria-label='View edit history'
- Reply: ThreadIndicator as=button gets aria-label='View thread'
- Reply: ReplyLayout as=button gets aria-label='Jump to original message'

Addresses WCAG 2.1 SC 4.1.2 (Name, Role, Value) for icon-only buttons
and polymorphic div-as-button patterns in the message timeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:14:01 -04:00
jared 176d5d0bb7 fix(mobile): apply useModalStyle to remaining dialog files (Bug #9)
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>
2026-06-18 18:11:24 -04:00
jared 3df95adc52 perf(media): defer image/video decryption until near-viewport (P5-5)
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>
2026-06-18 17:56:34 -04:00
jared a6bf4eb7e7 feat(tauri): proactive update notifications via toast (P5-40)
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>
2026-06-18 15:39:23 -04:00
jared baa12823f7 fix: exponential backoff for 429 retries, cover thumbnails, 2 more modal dialogs
- 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>
2026-06-18 15:32:17 -04:00
jared 8c711f5f4a feat(mobile): Saved Messages accessible from room header More Options menu
CI / Build & Quality Checks (push) Successful in 10m29s
CI / Trigger Desktop Build (push) Successful in 15s
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>
2026-06-18 15:18:38 -04:00
jared c4f00ed483 fix(mobile): apply useModalStyle to 7 more modal dialogs
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>
2026-06-18 15:14:30 -04:00
jared f5c301d5c6 fix(mobile): useModalStyle on 3 more dialogs, GPU hint animated bg, document untested fixes
- DeviceVerification, InviteUserPrompt, LogoutDialog: apply useModalStyle for
  fullscreen on mobile, capped box on desktop
- chatBackground: inject willChange + contain:paint on animated variants to
  promote compositor layer and prevent descendant repaint flickering (Bug #2)
- LOTUS_BUGS.md: mark #7-#10 FIXED/UNTESTED, #2 FIXED/UNTESTED, chatBackground
  backgroundColor values as PATTERN CONTENT EXCEPTION

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 14:51:28 -04:00
jared c395f7d16e fix(mobile): touch targets, keyboard viewport, PageNav overflow, modal fullscreen
CI / Build & Quality Checks (push) Successful in 10m27s
CI / Trigger Desktop Build (push) Successful in 5s
- 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>
2026-06-18 13:34:40 -04:00
jared 26f900870b feat: MSC4260 Report User, Bug #6 mutual exclusion, TDS toast compliance
CI / Build & Quality Checks (push) Successful in 10m28s
CI / Trigger Desktop Build (push) Successful in 6s
- 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>
2026-06-18 10:37:44 -04:00
jared bb99ad5611 fix(remind-me): match modal aesthetic to existing dialogs
CI / Build & Quality Checks (push) Successful in 10m28s
CI / Trigger Desktop Build (push) Successful in 10s
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>
2026-06-17 20:49:24 -04:00
jared 6c58e25211 style: fix prettier formatting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 20:37:54 -04:00
jared b24ab838f8 feat: Remind Me Later, mobile bookmarks, bug fixes, and doc cleanup
CI / Build & Quality Checks (push) Successful in 10m37s
CI / Trigger Desktop Build (push) Successful in 8s
Features:
- Remind Me Later: message context menu item opens a preset time picker
  (20 min / 1 hr / 3 hr / tomorrow 9am); reminders persist to Matrix
  account data (io.lotus.reminders); ReminderMonitor fires a Lotus Toast
  when due, checks every 30s and on tab focus
- Mobile Bookmarks: BookmarksPanel now renders on all screen sizes;
  passes isMobile prop for full-screen absolute overlay on mobile

Bug fixes:
- usePan.ts: memory leak from stale closure in document listener cleanup
- EventReaders.tsx: replace hardcoded hex colors with TDS CSS variables
- CallControls.tsx: replace hardcoded hex colors with TDS CSS variables
- CustomHtml.css.ts: replace hardcoded yellow/black highlight with theme tokens

Docs:
- LOTUS_TODO.md: restore deleted content (Confirmed facts table, Pending
  Audits, P5-30 completed status, full feature descriptions), keep new
  additions (P4-7/8/9, P5-41–57, Implementation Reference), eliminate
  duplicate sections
- LOTUS_BUGS.md: merge RESILIENCE_AUDIT.md findings into Architectural &
  Resilience Audit table; delete RESILIENCE_AUDIT.md
- Remove stale LOTUS_DENOISE_ENGINEERING_REVIEW.md and LOTUS_TODO_REFERENCE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 20:26:43 -04:00
jared cf7c66b99a Reorganize ML noise suppression settings UI
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>
2026-06-17 20:02:16 -04:00
jared 04b56ffacd feat(denoise): add self-hosted DeepFilterNet 3 ML noise-suppression model
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>
2026-06-17 19:57:08 -04:00
jared abb7f743b8 fix(calls): DTLN at 16kHz + raw-capture A/B; explains weak/robotic results
CI / Build & Quality Checks (push) Successful in 10m23s
CI / Trigger Desktop Build (push) Successful in 5s
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>
2026-06-17 17:27:15 -04:00
jared 14cfa021c5 feat(calls): in-app denoise tester to audition models + calibrate gate
CI / Build & Quality Checks (push) Successful in 10m24s
CI / Trigger Desktop Build (push) Successful in 17s
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>
2026-06-16 17:53:57 -04:00
112 changed files with 4697 additions and 1909 deletions
+460 -66
View File
@@ -8,86 +8,480 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
## 🚩 Critical & UI Bugs
### 1. Avatar Decoration Displacement in Profile
### 12. PiP Mute Icon Misidentifies Whose Mic Is Muted
**File:** `src/app/components/user-profile/UserHero.tsx`
**Status:** **OPEN**
- **Issue:** Avatar decorations appear displaced left of the avatar when viewing the profile modal.
- **Root Cause:** The `AvatarPresence` badge sticking out to the right shifts the center of the `inline-flex` container. The decoration centers on the container, not the avatar.
- **Recommended Fix:** Wrap only the `Avatar` component with `AvatarDecoration`.
### 2. Inconsistent Settings Dropdown Styling
**Files:** `Profile.tsx`, `SystemNotification.tsx`
**Status:** **OPEN**
- **Issue:** Dropdowns for Status Expiry and Notification Sounds use raw HTML `<select>` elements.
- **Recommended Fix:** Replace with the custom-styled `Menu` + `PopOut` pattern used in `General.tsx`.
### 3. Ringing Modal Fires in Voice Rooms
**File:** `src/app/components/CallEmbedProvider.tsx`
**Status:** **OPEN**
- **Issue:** Joining a static voice room triggers the "Incoming Call" ringing.
- **Recommended Fix:** Check `notification_type` in the Matrix RTC event. Only 'ring' should trigger the modal.
### 4. No Camera Focus During Screenshare
**File:** `src/app/features/call/CallControls.tsx`
**Status:** **OPEN**
- **Issue:** When someone is screensharing and another participant turns on their camera, there is no way to switch the primary display to the camera or go fullscreen on it.
- **Recommended Fix:** Implement a "Focus" toggle on participant tiles that overrides the automatic screenshare spotlight.
### 5. Chat Background Animation Flickering
**File:** `src/app/features/lotus/chatBackground.ts`
**Status:** **OPEN**
- **Issue:** Some animated backgrounds (like Fireflies) cause flickering/flashing of the message text and composer area on certain browsers/GPUs.
- **Recommended Fix:** Ensure animations are scoped strictly to background properties (`background-position`, `background-size`) and do not use properties like `filter` or `opacity` on the main container.
### 6. Avatar Decorations not displaying in Element Call UI
- **Issue:** When in a voice call or room with users it displays the avatar of the users and their mute status etc... but doesn't display their avatar decorations on their pictures.
### 7. DM and Group Message Calls
- **Issue:** Call ring is very loud and not customizable, also if the user is already in an active call/or voice room it won't ring at all until the user leaves.
### 8. Seasonal Themes and Chat Backgrounds need EXTREME design improvements.
- **Issue:** Basic css or random moving lines are not good artwork or design theory. Requires extensive research on css backgrounds wallpapers and app theming, these should be multi-day projects PER background and theme. As if a whole team spent a entire project sprint on a single one.
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with at least one other participant who mutes/unmutes
- **Issue:** The muted-mic badge in the Picture-in-Picture window used `useRemoteAllMuted` (fires when ANY remote participant is muted) and rendered in the bottom-left corner — the conventional position for "YOUR" mic status. Users read it as their own mic being muted.
- **Root Cause:** `PipMuteOverlay` was triggering on remote-mute events while displaying in a position that implies local-user status.
- **Fix Applied:**
- **Bottom-left badge** now shows only when the LOCAL user's mic is muted (checked via `!controlState.microphone` from `useCallControlState`). Includes "You" label to make it unambiguous. Uses `color.Critical.Main`.
- **Top-right badge** (new) shows "All muted" in `color.Warning.Main` when all remote participants are muted — positioned and labeled so it's clearly about other people, not the local user.
- Both badges use `aria-label` / `title` for accessibility.
---
## 📱 PWA & Mobile Issues
### 1. No Camera Focus During Screenshare
### 1. Exclusive Background vs. Seasonal Choice
- **File:** `cinny/src/app/features/call/CallControls.tsx`
- **Status:** **OPEN**
- **Issue:** Automatic screenshare spotlighting forces primary display override, preventing users from manually focusing on camera feeds.
- **Root Cause:** Current spotlighting logic prioritizes active screenshare streams over manual participant selections, effectively ignoring or overriding user-initiated focus states.
- **Proposed Fix:** Introduce a manual 'Focus' state that takes precedence over automatic screenshare spotlighting, implemented via a toggle/click UI on participant tiles. Update the video renderer to respect this manual override.
**Status:** **OPEN**
### 2. Chat Background Animation Flickering
- **Issue:** Users can have both a Chat Background and a Seasonal Theme active, causing visual clutter and excessive GPU usage on mobile.
- **Recommended Fix:** Implement a "Choose One" toggle in Settings.
- **File:** `cinny/src/app/features/lotus/chatBackground.ts`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on a real device with an animated background active
- **Issue:** Animated background properties cause visible flickering on message text and the composer area, particularly on browsers/GPUs susceptible to repaint-induced artifacts.
- **Root Cause:** Animation triggers excessive repaints or layout recalculations on descendant elements, likely due to animating non-GPU accelerated properties on parent containers without proper rendering context isolation.
- **Fix Applied:** `getChatBg()` now injects `willChange: 'background-position'` and `contain: 'paint'` for any animated variant. This promotes the element to its own compositor layer and isolates repaints from descendants. Background-position animation is already GPU-hinted on modern browsers; `contain: paint` prevents descendant elements from being invalidated during each frame.
### 3. Avatar Decorations in Element Call
- **File:** `cinny/src/app/components/avatar-decoration/AvatarDecoration.tsx`
- **Status:** **OPEN**
- **Issue:** Avatar decorations are failing to render within the call/room interface member lists.
- **Root Cause:** Likely a mismatch between the expected `member` object structure required by the `AvatarDecoration` component and the data actually provided by the call/room UI components. Matrix event data for decorations might not be propagating correctly to these UI member objects.
- **Proposed Fix:** Analyze the data propagation chain from Matrix events to the member object in `cinny/src/app/components/call` and `room`, ensuring that decoration-related properties are correctly mapped and passed to the `AvatarDecoration` component.
### 4. DM and Group Message Calls
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
- **Status:** **PARTIALLY FIXED ⚠️ UNTESTED** — Volume control added. Remaining: ringtone selection, suppression during active calls.
- **Issue:** Incoming call ringtone is hardcoded, lacks volume control, and is suppressed if the user is already in an active call.
- **Root Cause:** Ringing logic is tightly coupled to `RTCNotification` events in `CallEmbedProvider.tsx`, using a hardcoded audio file path. It lacks an abstraction for sound management or user-configurable settings for ringtones/volumes.
- **Fix Applied:** Added `ringtoneVolume` setting (0100, default 70). `IncomingCall` reads this setting and applies `audioElement.volume = ringtoneVolume / 100` before `play()`. Slider added to Settings → General → Calls section.
- **Remaining:** (a) Ringtone selection (still hardcoded to `call.ogg`); (b) Suppression during active calls — not investigated.
### 5. Seasonal Themes and Chat Backgrounds Design
- **File:** `cinny/src/app/hooks/useTheme.ts`, `cinny/src/app/features/lotus/chatBackground.ts`
- **Status:** **OPEN**
- **Issue:** Basic CSS or random moving lines are insufficient for high-fidelity wallpaper/theming. They lack professional design theory, coherence, and aesthetic depth.
- **Root Cause:** Current implementation relies on basic CSS, lacks advanced design theory, and does not leverage modern, performant CSS wallpaper techniques.
- **Proposed Fix (Extreme Depth Redesign):**
- **Research-Backed Implementation:** Implement advanced design techniques (layered `oklch` gradients, `backdrop-filter` for refractive "liquid glass" effects, GPU-accelerated `transform` animations) to create living, breathing backgrounds.
- **Performance Optimization:** Ensure all animations strictly use compositor-thread properties (`transform`, `opacity`) and apply `contain: paint` / `will-change: transform` to prevent layout thrashing/flickering.
- **Design Resources (Examples/Inspiration):**
- [Uiverse.io Patterns](https://uiverse.io/patterns)
- [MagicPattern CSS Backgrounds](https://www.magicpattern.design/tools/css-backgrounds)
- [Prismic Blog: CSS Background Effects](https://prismic.io/blog/css-background-effects)
- [CSS-Pattern.com](https://css-pattern.com) (Pure CSS pattern library)
- [BGJar](https://bgjar.com) (Performance-focused generators)
- **Goal:** Treat each theme/background as a week-long development sprint to ensure professional polish, WCAG AA contrast compliance for overlaying UI, and seamless integration with the Lotus TDS.
### 6. Exclusive Background vs. Seasonal Choice
- **File:** `cinny/src/app/state/settings.ts`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification: (a) pick a background, confirm seasonal theme auto-clears; (b) pick a seasonal theme, confirm background auto-clears; (c) set both via old localStorage data and reload, confirm SeasonalEffect guard suppresses the overlay
- **Issue:** Concurrent application of both Chat Backgrounds and Seasonal Themes causes visual clutter and high GPU usage.
- **Root Cause:** These are currently handled as independent settings in the `settingsAtom` and applied simultaneously without mutual exclusion.
- **Fix Applied:** Mutual exclusion enforced at two layers: (1) `General.tsx` — ChatBgGrid clears seasonalThemeOverride→'off' when any non-'none' background is picked; SeasonalBgGrid clears chatBackground→'none' when any real seasonal theme is selected. (2) `SeasonalEffect.tsx` — runtime guard returns null if `chatBackground !== 'none'`, protecting against legacy persisted state.
### 7. Tiny Touch Targets in Composer Toolbar
- **File:** `cinny/src/app/features/room/RoomInput.tsx`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on a real mobile device: open composer, confirm all toolbar buttons are tappable without mis-taps
- **Issue:** Toolbar buttons have hit areas smaller than the WCAG-recommended 44x44px for touch, hindering mobile accessibility.
- **Fix Applied:** Added `touchTarget = { minWidth: '44px', minHeight: '44px' }` computed from `mobileOrTablet()` and applied as `style={touchTarget}` to all 8 composer toolbar `IconButton` elements (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
### 8. Horizontal Overflow in Room Settings
- **File:** `cinny/src/app/components/page/style.css.ts`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification: open Room Settings on a narrow mobile screen, confirm nav panel fills full width and no horizontal scrollbar appears
- **Issue:** Wide tables and input elements in room settings cause horizontal overflow on mobile viewports.
- **Fix Applied:** Added `@media (max-width: 750px) { width: '100%' }` to both `'400'` and `'300'` size variants of the `PageNav` vanilla-extract recipe in `style.css.ts`.
### 9. Modal Float-Style Responsiveness
- **File:** Multiple modal files
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification by opening each modal on a real mobile device
- **Issue:** Modals appear as floating boxes on mobile, creating navigation and readability challenges.
- **Fix Applied:** Created `useModalStyle(desktopMaxWidth)` hook (`src/app/hooks/useModalStyle.ts`) that returns fullscreen styles on mobile (no border-radius, no max-width, `height: 100%`) and desktop box styles otherwise. Applied to all 22+ modal files: `LeaveRoomPrompt`, `LeaveSpacePrompt`, `ReportRoomModal`, `ReportUserModal`, `DeviceVerification`, `InviteUserPrompt`, `LogoutDialog`, `DeviceVerificationSetup`, `DeviceVerificationReset`, `JoinAddressPrompt`, `JumpToTime`, `EditHistoryModal`, `ForwardMessageDialog`, `RemindMeDialog`, `CreateRoomModal`, `CreateSpaceModal`, `ScheduleMessageModal`, `PollCreator`, `AddExistingModal`, `RoomEncryption`, `RoomUpgrade`, `Modal500`, `ReadReceiptAvatars`, `RoomTopicViewer`.
- **Note:** `UIAFlowOverlay` already fullscreen via `<Overlay>` — no change needed. `JoinRulesSwitcher`/`RoomNotificationSwitcher` are dropdowns, not modals.
### 10. Composer Keyboard Obscurity
- **File:** `src/index.css`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on iOS Safari specifically (the worst offender); on Android Chrome `100dvh` has been standard since Chrome 108
- **Issue:** The chat composer is often partially or fully obscured by the virtual keyboard on mobile.
- **Fix Applied:** Added `height: 100dvh` (dynamic viewport height) to `html` alongside the existing `height: 100%` fallback. `dvh` updates when the software keyboard appears, ensuring the layout shrinks correctly and the composer stays visible.
### 11. Inline Jotai atom creation
- **File:** `cinny/src/app/hooks/useSpaceHierarchy.ts`
- **Status:** **FALSE POSITIVE — CLOSED**
- **Issue:** Inline Jotai atom creation in a hook risks re-rendering components unnecessarily.
- **Resolution:** `useState(() => atom(...))` IS the correct Jotai pattern for local stable atom references. The factory function form of `useState` ensures the atom is created only once per component mount. No change warranted.
---
## 📦 Barrel File Audit
| File Path | Note | Status |
| :------------------------------------------ | :------------------------- | :----- |
| `cinny/src/app/plugins/call/index.ts` | Extensive `export *` usage | OPEN |
| `cinny/src/app/plugins/text-area/index.ts` | Extensive `export *` usage | OPEN |
| `cinny/src/app/components/message/index.ts` | Extensive `export *` usage | OPEN |
---
## 🔍 Technical & Performance Refinements
### 1. Decrypted Media Memory Leak (Gallery & Lightbox)
| Category | Issue Description | File Path | Status |
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| State Sync | Fire-and-forget network call to set offline presence during `pagehide` event may not complete reliably, potentially causing UI drift in presence status. | `cinny/src/app/hooks/usePresenceUpdater.ts` | OPEN |
| State Sync | Fire-and-forget network call `setPresence().catch(...)` suppresses errors, meaning the app may falsely assume presence update success. | `cinny/src/app/hooks/usePresenceUpdater.ts` | OPEN |
| Memory Leak | Decrypted Media Memory Leak (Gallery & Lightbox) due to missing virtualization and blob revocation. | `cinny/src/app/features/room/MediaGallery.tsx` | PARTIALLY FIXED ⚠️ UNTESTED — Blob revocation was already correct; added `enabled` param to `useDecryptedMediaUrl` and `useNearViewport(300px)` to each `GalleryTile` to gate decryption until near-viewport, reducing burst on pagination. True virtualization (windowing) deferred — requires significant refactor. |
| Data Persistence | Scheduled Messages are ephemeral (lost on refresh) due to fragile `localStorage` parsing. | `cinny/src/app/state/scheduledMessages.ts` | FIXED — now uses `atomWithStorage` + `createJSONStorage` (Jotai's built-in persistence with error-safe JSON parsing) |
| Memory Leak | Potential memory leak due to uncleaned `handleMouseMove` listener in `usePan`. | `cinny/src/app/hooks/usePan.ts` | FALSE POSITIVE — `usePan` already uses `attachedRef` to track listeners and cleans them up in an unmount `useEffect`. No change needed. |
| Asset Optimization | Large unoptimized media asset (213KB) found in `public/res`. | `public/res/Lotus.png` | OPEN |
| Data Persistence | Non-atomic `localStorage` updates in session management can lead to inconsistent state. | `cinny/src/app/state/sessions.ts` | OPEN |
| Data Persistence | Lack of cross-tab synchronization for `localStorage` updates in session management risks race conditions. | `cinny/src/app/state/sessions.ts` | OPEN |
| Network Resilience | `uploadContent` lacks retry logic, failing immediately upon network error. | `cinny/src/app/utils/matrix.ts` | OPEN |
| Network Resilience | `rateLimitedActions` uses basic retry logic without exponential backoff, which may exacerbate 429 issues. | `cinny/src/app/utils/matrix.ts` | FIXED — fallback delay now uses capped exponential backoff (`min(1000 * 2^retryCount, 30_000)ms`) when server doesn't send `Retry-After`; server header still takes precedence via `getRetryAfterMs()`. |
| Matrix Event Robustness | `useMatrixEventRenderer` handles unknown events gracefully by returning `null`, which may hide potentially important unrendered data. | `cinny/src/app/hooks/useMatrixEventRenderer.ts` | OPEN |
| Data Contract | `MatrixError` instantiation with `UploadResponse` might be brittle. | `cinny/src/app/utils/matrix.ts` | OPEN |
| Type Safety | `addRoomIdToMDirect` uses `as any` cast for `AccountDataEvent.Direct`, bypassing type contract validation. | `cinny/src/app/utils/matrix.ts` | OPEN |
| Robustness | `rateLimitedActions` relies on `MatrixError.httpStatus` which might not exist on all error variants. | `cinny/src/app/utils/matrix.ts` | FALSE POSITIVE — `MatrixError.httpStatus` is defined as `readonly httpStatus?: number` in `matrix-js-sdk/lib/http-api/errors.d.ts`. It is optional (not on all instances) but the `?.` optional chain already guards against undefined. No change needed. |
| Type Contract | Custom types in `cinny/src/types/matrix` mirror SDK types instead of using them, risking drift and contract mismatches. | `cinny/src/types/matrix/` | OPEN |
**File:** `src/app/features/room/MediaGallery.tsx`
**Status:** **OPEN**
## 🏗️ Architectural & Hygiene Audit
- **Issue:** Every image in the gallery history is decrypted and converted to a Blob URL simultaneously.
- **Recommended Fix:** Implement virtualization for the gallery grid.
| Category | Issue Description | File Path | Status |
| :------- | :--------------------------------------------------------------- | :-------- | :----- |
| Hygiene | No stale development notes or TypeScript strictness issues found | N/A | OPEN |
### 2. Scheduled Messages are Ephemeral
---
**File:** `src/app/state/scheduledMessages.ts`
**Status:** **OPEN**
## 🏗️ TDS Compliance & Styling Issues
- **Issue:** Refreshing the page clears the "Scheduled" tray, making it impossible for users to see or cancel messages they have already scheduled.
- **Recommended Fix:** Persist the scheduled message metadata in `localStorage`.
| Issue Description | File Path |
| :------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Hardcoded inline style `cursor: 'pointer'` | `cinny/src/app/plugins/react-custom-html-parser.tsx` |
| Hardcoded color `#00D4FF`, `#FFB300`**VERIFIED COMPLIANT** | `cinny/src/app/components/event-readers/EventReaders.tsx` |
| Hardcoded color `#EE1D52`, `#9146ff`, `#ff4500`, `#cb3837`, `#f48024` ⚠️ **BRAND EXCEPTION** | `cinny/src/app/components/url-preview/UrlPreviewCard.tsx` + `UrlPreview.css.tsx` — official third-party brand colors in SVG logos and site badge backgrounds; cannot convert to CSS variables without inventing new tokens (violates TDS rule 3) |
| Massive number of hardcoded `backgroundColor` values ⚠️ **PATTERN CONTENT EXCEPTION** | `cinny/src/app/features/lotus/chatBackground.ts` — each background's base color is aesthetic content that defines the pattern identity; converting requires inventing 40+ CSS variables (violates TDS rule 3) or using CSS4 `relative-color-syntax` in inline styles (insufficient browser support); these are visual content, not UI chrome |
| Hardcoded colors `#00FF88`, `#FF6B00`**VERIFIED COMPLIANT** | `cinny/src/app/features/call/CallControls.tsx` |
| Hardcoded fallback hexes in toast colors ✅ **FIXED** | `cinny/src/app/features/toast/LotusToastContainer.tsx` |
---
## 🌐 Localization, Accessibility & Performance
| Category | Issue Description | File Path | Status |
| :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Localization | Hardcoded UI string: "Chat Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
| Localization | Hardcoded UI string: "Messages, photos, and videos." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
| Localization | Hardcoded UI string: "Voice Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
| Localization | Hardcoded UI string: "Live audio and video conversations." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
| Localization | Hardcoded UI string: "Download" | `src/app/components/image-viewer/ImageViewer.tsx` | OPEN |
| Localization | Hardcoded UI string: "Open Location" | `src/app/components/message/MsgTypeRenderers.tsx` | OPEN |
| Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | OPEN |
| Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
| Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
| Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
| Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | OPEN |
| Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | OPEN |
| Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | OPEN |
| Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | OPEN |
| Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | OPEN |
| Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | OPEN |
| Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
| Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
| Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
| Localization | Hardcoded UI string: "Password" | `src/app/components/uia-stages/PasswordStage.tsx` | OPEN |
| Bundle Size | Large unoptimized media asset (213KB) | `public/res/Lotus.png` | OPEN |
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/features/lobby/Lobby.tsx` | OPEN |
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/components/emoji-board/EmojiBoard.tsx` | OPEN |
| Performance | Numerous event handlers (e.g., handleUserClick, handleReplyClick) lack `useCallback`, leading to unnecessary re-renders of message components. | `cinny/src/app/features/room/RoomTimeline.tsx` | OPEN |
| Performance | The `submit` function and file handling callbacks (e.g., handleSendUpload) are re-created on every render, causing re-renders of the editor and toolbar components. | `cinny/src/app/features/room/RoomInput.tsx` | OPEN |
| Accessibility | `button` for edit history lacks `aria-label` | `cinny/src/app/components/message/content/FallbackContent.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View edit history"` |
| Accessibility | `button` for reaction lacks `aria-label` | `cinny/src/app/components/message/Reaction.tsx` | **FIXED ⚠️ UNTESTED**`Reaction` component now computes `aria-label="{shortcode} reaction, N people"` internally using `getShortcodeFor`; custom (mxc://) emoji falls back to "custom emoji reaction". |
| Accessibility | `button` for ThreadIndicator lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View thread"` |
| Accessibility | `button` for ReplyLayout lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="Jump to original message"` |
---
## 🔧 Infrastructure, DevEx & Type Safety
| Category | Issue Description | File Path | Status |
| :------------- | :----------------------------------------------------------------------------------------------------------- | :--------------------------------------------------- | :----- |
| Dependencies | `lodash` pinned to non-existent version `4.18.1` | `cinny/package.json` | OPEN |
| Dependencies | Various pinned versions of `@atlaskit`, `matrix-js-sdk` | `cinny/package.json` | OPEN |
| Dependencies | `matrix-js-sdk` pinned to Release Candidate (`41.6.0-rc.0`) | `cinny/package.json` | OPEN |
| Dependencies | Unstable/experimental versions for build tools (`vite` 8.0.14, `typescript` 6.0.3, `eslint` 9.39.4) | `cinny/package.json` | OPEN |
| CI/CD | `package-manager-cache` set to `false` | `cinny/.github/workflows/build-pull-request.yml` | OPEN |
| CI/CD | Inefficient sequential execution in deployment | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
| CI/CD | Aggressive 1-minute timeout for Netlify deploy | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
| DevEx | Stale upstream bug tracker link/donations/CLA | `cinny/CONTRIBUTING.md` | OPEN |
| DevEx | Alignment issue between README and CONTRIBUTING | `cinny/README.md` | OPEN |
| Testing | No evident automated testing configuration/files | `cinny/src/` | OPEN |
| Type Safety | Extensive use of `as any` type assertions | `cinny/src/` | OPEN |
| Security | Hardcoded public CDN URL; consider moving to environment variable | /root/code/cinny/scripts/syncDecorations.mjs | OPEN |
| Architecture | Modifying node_modules directly is brittle; use patch-package instead | /root/code/cinny/scripts/patch-folds.mjs | OPEN |
| Robustness | Missing security headers (HSTS, CSP, etc.) and inefficient asset serving using rewrites instead of try_files | /root/code/cinny/contrib/nginx/cinny.domain.tld.conf | OPEN |
| Robustness | Incomplete documentation/placeholder path in Caddyfile | /root/code/cinny/contrib/caddy/caddyfile | OPEN |
| Matrix SDK | Inefficient listener management (`setMaxListeners: 150`) and incomplete SDK state transition handling. | `src/client/initMatrix.ts` | OPEN |
| PWA Robustness | Service worker lacks caching strategy for application assets, resulting in no offline capability. | `cinny/src/sw.ts` | OPEN |
| PWA Integrity | `manifest: false` in `vite.config.js` might prevent correct PWA installation if not handled externally. | `cinny/vite.config.js` | OPEN |
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/plugins/call/CallEmbed.ts` | OPEN |
| PII Leakage | Potential PII exposure via console.warn (parameter imgError/videoError/thumbError object). | `cinny/src/app/features/room/msgContent.ts` | OPEN |
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/features/room/RoomInput.tsx` | OPEN |
## 🏗️ Architectural & Resilience Audit
| Category | Issue Description | File Path | Status |
| :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------ | :----- |
| Element Call Integration | Lacks robust iframe failure monitoring beyond initial 'preparing' event; can result in a permanently hung 'Loading...' state with no user-visible error or recovery path. | `src/app/plugins/call/CallEmbed.ts` | OPEN |
| Component Resilience | `RoomTimeline` has no `ErrorBoundary` wrapper — a single malformed event crashing the renderer takes down the entire timeline with no fallback UI. | `src/app/features/room/RoomTimeline.tsx` | OPEN |
| Component Resilience | `RoomInput` has no `ErrorBoundary` wrapper — a crash in the composer leaves users unable to send messages. | `src/app/features/room/RoomInput.tsx` | OPEN |
| Fallback Logic | No explicit empty/error fallback for Matrix SDK data calls in `RoomTimeline`; relies purely on SDK internal error propagation, meaning silent failures show a blank timeline. | `src/app/features/room/RoomTimeline.tsx` | OPEN |
| Dependency | Potential for complex dependency chains due to deep nesting in `src/app/features/` and `src/app/hooks/`. | `src/app/` | OPEN |
| Hydration/Race Condition | The SyncState listener registered by useSyncState may miss the initial 'PREPARED' event if the client initializes synchronously from IndexedDB before the effect runs, leading to an infinite loading state. | `cinny/src/app/pages/client/ClientRoot.tsx` | OPEN |
| Structure | High number of small, highly coupled utility hooks (`src/app/hooks/`) may obscure dependency graphs. | `src/app/hooks/` | OPEN |
| Dead Code | Potential for unused CSS modules or UI components in `src/app/features/`. | `src/app/` | OPEN |
| Security | Sensitive session data (access tokens, device ID) stored in `localStorage` is vulnerable to XSS. | `src/app/state/sessions.ts` | OPEN |
| Privacy | Sensitive user status messages and expiry timestamps are persisted in `localStorage`. | `src/app/features/settings/account/Profile.tsx` | OPEN |
| Privacy | Unsent composer drafts stored in `localStorage` without encryption could leak info on shared devices. | `src/app/features/room/RoomInput.tsx` | OPEN |
| Persistence | Scheduled messages relying on fragile `localStorage` parsing are prone to data loss on session expiry or error. | `src/app/state/scheduledMessages.ts` | OPEN |
| Bundle Bloat | Inefficient `lodash` import; risks including entire library instead of necessary utilities. | `cinny/package.json` | OPEN |
| Bundle Bloat | Large `matrix-js-sdk` (RC version) dependency; high potential for tree-shaking overhead. | `cinny/package.json` | OPEN |
| Build-Time Overhead | `lotusDenoise` plugin performs heavy, sequential `fs` operations during `closeBundle`, significantly slowing build times. | `cinny/vite.config.js` | OPEN |
| Build-Time Overhead | Complex manual `viteStaticCopy` configuration requiring multiple renames and path manipulations; risks redundant processing. | `cinny/vite.config.js` | OPEN |
| Architectural Debt | Redundant style variant logic in `SpacingVariant` could be simplified. | `cinny/src/app/components/message/layout/layout.css.ts` | OPEN |
| Overhead Analysis | Potential CSS bloat from `DropTarget` composition across multiple recipes (`SidebarItem`, `SidebarFolder`). | `cinny/src/app/components/sidebar/Sidebar.css.ts` | OPEN |
## 🏗️ Git Workflow & History Audit
| Category | Issue Description | File Path | Status |
| :------- | :------------------------------------------------------------------------------------------------------ | :---------- | :----- |
| Workflow | Monolithic "Fix all bugs" commits (e.g., `10f6544e`, `aa48c9ef`) make `git bisect` difficult. | Git History | OPEN |
| Workflow | Inconsistent commit message prefixes (e.g., `fix`, `feat`, `docs`, `assets`). | Git History | OPEN |
| Workflow | Use of `fix` or `feat` for large-scale changes affecting multiple disparate systems (e.g., `938ead79`). | Git History | OPEN |
---
## 🎨 Native UI/UX Consistency — Lotus vs. Cinny Baseline
> Audit of every Lotus-custom UI feature against Cinny's native folds design-system conventions. "Native pattern" means the `folds` component library, vanilla-extract tokens (`color.*`, `config.radii.*`, `config.space.*`), and established Cinny component patterns. 52 findings, organized by severity.
---
### 🔴 Major — Broken Styling / Functional Regressions
#### N1. `ProfileDecoration` Save Button — Undefined `--accent-cyan` Variable (border invisible on all non-TDS themes)
- **File:** `src/app/features/settings/account/ProfileDecoration.tsx`, lines 191213
- **Status:** **FIXED** — replaced raw `<button>` with `<Button size="400" variant="Success" fill="Solid" radii="300">`, removed undefined `--accent-cyan` reference
- **Issue:** The save button is a raw `<button>` with `border: '1px solid var(--accent-cyan)'` and `color: 'var(--accent-cyan)'`. The variable `--accent-cyan` (without the `--lt-` prefix) is never defined in any theme file — the correct prefixed form is `--lt-accent-cyan`. On all non-TDS themes the border is **invisible** and the text has no color.
- **Root Cause:** Missing `--lt-` prefix. Additionally, the raw `<button>` should be a folds `<Button>` to match every other save button in the same `Profile.tsx` settings panel (e.g., `ProfileDisplayName` save at `Profile.tsx:303`).
- **Fix:** Replace raw `<button>` with `<Button size="400" variant="Success" fill="Solid" radii="300">`. Remove the `--accent-cyan` reference.
#### N2. `UserPrivateNotes` Textarea — Undefined `--border-interactive` Variable (border invisible on all themes)
- **File:** `src/app/components/user-profile/UserRoomProfile.tsx`, lines 246265
- **Status:** **FIXED** — replaced undefined CSS vars with `color.SurfaceVariant.ContainerLine`, `config.radii.R300`, `config.space.S200/S300`
- **Issue:** The notes textarea sets `border: '1px solid var(--border-interactive)'`. This variable is never defined anywhere in the codebase — the correct equivalents are `--bg-surface-border` (`src/index.css`) or `color.SurfaceVariant.ContainerLine` (folds token). The border is **invisible on all themes**.
- **Root Cause:** Invented CSS variable name. Also uses raw pixel sizing (`borderRadius: '6px'`, `padding: '8px 10px'`, `fontSize: '14px'`) instead of folds tokens.
- **Fix:** Replace inline style with `border: \`1px solid ${color.SurfaceVariant.ContainerLine}\``, `borderRadius: config.radii.R300`, `padding: config.space.S200`.
#### N3. `LotusToastContainer` — Z-Index Places Toasts Below Night Light Overlay and All Modals
- **File:** `src/app/features/toast/LotusToastContainer.tsx`, lines 184211; `src/app/pages/App.tsx`
- **Status:** **FIXED** — raised toast `zIndex` from `9997` to `10001` (above Night Light at 9998 and modals at 9999)
- **Issue:** The toast container uses hardcoded `zIndex: 9997`. The Night Light overlay is at `z-index: 9998`. The folds `Overlay`/`Dialog` components used for all modals resolve to `z-index: 9999`. Result: (a) toasts render **under** the Night Light tint and take on the warm orange filter; (b) any open modal covers toasts entirely, making notifications invisible.
- **Root Cause:** The toast container does not use the `folds` `OverlayContainerProvider` portal that manages z-index correctly — it is a plain `position: fixed` div injected directly in `App.tsx`.
- **Fix:** Either route the toast portal through `OverlayContainerProvider` (matching how all other floating UI works), or raise `zIndex` above all overlay layers (10001+). Also audit Night Light's z-index (9998) relative to toasts.
#### N4. `PollContent` Vote Buttons — Entirely Outside the Folds Design System
- **File:** `src/app/components/message/content/PollContent.tsx`, lines 250358
- **Status:** **OPEN**
- **Issue:** Each poll answer is a native `<button>` with ~15 hardcoded inline style properties using undefined CSS variables (`--accent-cyan`, `--accent-cyan-dim`, `--accent-cyan-border`). Checkbox/radio indicators, percentage spans, and the poll label use raw pixel font sizes (`0.68rem`, `0.78rem`, `0.88rem`) and hardcoded `borderRadius: '8px'`. None of these variables exist in any theme — the entire component will render unstyled on non-TDS themes. All other interactive message content (audio, file, image) uses folds `Chip` or `Button` variants.
- **Root Cause:** Custom implementation that bypasses folds primitives entirely.
- **Fix:** Rewrite using folds `Button` or `Chip` for answers; replace `--accent-cyan*` with `color.Secondary.*` folds tokens; use `Text size="T300"` for labels.
---
### 🟠 Moderate — Interaction Pattern or Visual Deviations
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :------------------------- | :---------------------------------------- | :---------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N5 | Read Receipts | `ReadReceiptAvatars.tsx` | 62137 | Trigger button is raw `<button>` with `onMouseEnter`/`onMouseLeave` JS style mutation for hover state — **FIXED**: hover/focus emphasis moved to co-located `ReadReceiptAvatars.css.ts` (`:hover`/`:focus-visible`), no JS `.style` mutation | All interactive elements use `useHover` from `react-aria` and folds variant system for hover; direct `.style` mutation used nowhere else on buttons |
| N6 | Read Receipts | `ReadReceiptAvatars.tsx` & `Message.tsx` | 3256 / 268283 | Two code paths open `EventReaders`: avatar-pill path uses `useModalStyle(360)` for mobile fullscreen; context-menu path (`MessageReadReceiptItem`) does not — on mobile the context menu path opens a fixed-size non-fullscreen modal for the same content | All modals that share a layout variant use `useModalStyle` consistently; `MessageReadReceiptItem` was not updated when `useModalStyle` was added |
| N7 | Delivery Status | `Message.tsx` | 89148 | `DeliveryStatus` renders Unicode glyphs (`⟳ ✓ ✕`) in a `<span>` with `fontSize: '10px'` instead of folds `<Icon>` components — **FIXED**: replaced with `Icons.Check/Cross/Send` via `<Icon size="100">` | `Icons.Check`, `Icons.Cross`, etc. are used for all other status glyphs; folds `Text` size tokens for all supplementary text |
| N8 | GIF Picker | `GifPicker.tsx` | 83124 | GIF picker container uses fully bespoke inline styles (`borderRadius: '12px'`, `boxShadow: '0 8px 32px rgba(0,0,0,0.4)'`, raw `rgba` border) — two separate style sets for TDS and non-TDS paths — **FIXED**: non-TDS path now uses folds tokens (`color.Surface.Container`, `config.radii.R400`, `color.Surface.ContainerLine`, `color.Other.Shadow`), dropping the undefined `var(--bg-surface)`; the TDS branch keeps its `--lt-*` glow chrome (valid TDS styling) | `EmojiBoard` has no caller-applied container styling; folds components handle their own surface internally via design tokens |
| N9 | GIF Button | `RoomInput.tsx` | 10761087 | GIF toolbar button renders `<Text size="T200">` with hand-rolled `fontWeight`/`fontSize`/`letterSpacing` instead of `<Icon>`**WON'T FIX (deliberate)**: folds has no GIF icon, and "GIF" is a widely-recognized text affordance (Slack/Discord/Element all use a text label). Converting to an arbitrary icon would be less clear, not more. | All 8 other toolbar buttons (`Smile`, `Sticker`, `Location`, `Poll`, etc.) use `<Icon src={...} />` exclusively |
| N10 | Send Animation | `Message.tsx` + `Animations.css.ts` | 979998 / 6071 | `MsgAppearClass` and `MentionHighlightPulse` both animate `transform: scale` on the same `MessageBase` DOM node — on self-sent mention messages both classes apply simultaneously and fight over the `transform` property — **FIXED**: `mentionPulseKeyframes` now animates only `box-shadow` (dropped the imperceptible `scale(1.003)`), so the appear-scale and the mention glow no longer contend for `transform` | Pre-existing `highlightAnime` only animates `backgroundColor`; no prior `transform` animation on `MessageBase` |
| N11 | AvatarDecoration | `AvatarDecoration.tsx` | 5 / 3841 | Fixed 8px inset on all sides regardless of avatar size — at folds size `"200"` (~32px) the decoration bleeds 50% of the avatar diameter, clipping against `overflow: hidden` parent containers in member lists. **Inset issue still OPEN.** _Related regression fixed in `useAvatarDecoration.ts`_: the decoration fetch cached **all** failures (including transient 429/5xx) as "no decoration" permanently for the session, so a single rate-limited burst (member list / timeline mount many avatars at once) would make decorations vanish until a full reload. Now only a genuine 404 is cached; transient errors retry on the next mount. | Folds `Avatar` and `PresenceRingAvatar` do not emit overflow outside their bounding box |
| N12 | MediaGallery Drawer | `MediaGallery.tsx` | 651661 | Drawer uses `position: 'fixed'` with hardcoded `width: '320px'` as inline styles on a `<Box>`**FIXED**: moved positioning/width into co-located `MediaGallery.css.ts` using `toRem(320)` + a `max-width: 750px` full-screen media query (mirrors `MembersDrawer`); border/header now use `config.borderWidth`/`config.space` tokens. Added Escape-to-close on the panel (previously only the lightbox handled Escape). **Full chrome redesign (round 2)** to match native conventions: panel + header switched from `Surface` to `Background` variant (matching `MembersDrawer`/Saved Messages); header now `Text size="H4"` + plain close `IconButton` (dropped the bespoke tooltip-wrapped button); tabs moved to a bordered toolbar strip with the `variant={active?'Primary':'Secondary'} fill={active?'Solid':'Soft'}` pattern from `PolicyListViewer` and now show per-tab counts; the centered "lines + label" month divider replaced with a left-aligned group label (Cinny group-label pattern); thumbnail tiles moved hover/focus styling to CSS `:hover`/`:focus-visible` (no JS hover state) and into `MediaGallery.css.ts`; file rows + grid tokenized. **Docking fix (round 3)** — the core of the finding: the gallery was a `position: fixed` overlay floating over the timeline, mounted from `RoomViewHeader`. It is now a **docked flex sibling** in the room layout row, exactly like `MembersDrawer`: open state lifted to a `mediaGalleryAtom` (mirrors `bookmarksPanelAtom`), rendered in `Room.tsx` with a vertical `Line` separator on desktop and `key={room.roomId}` to reset per room; the CSS is static-width on desktop and only `position: fixed; inset: 0` full-screen on mobile (identical strategy to `MembersDrawer.css`). It now shares the row with the timeline instead of overlapping it. | `MembersDrawer` uses a vanilla-extract class with `width: toRem(266)` and is placed by the layout system, not `position: fixed`. 54px width discrepancy also breaks visual rhythm if both panels could be open |
| N13 | ScheduledMessagesTray | `ScheduledMessagesTray.tsx` | 108126 | Collapsible tray header is `<Box as="button">` with `cursor: 'pointer'` inline style and no folds variant — no hover state, no focus ring — **FIXED**: replaced with folds `<Button variant="Secondary" fill="None" radii="0">` using `before`/`after` icon props (gains design-system hover/focus) | All clickable header/toggle elements in the room view use folds `<Button>` or `<IconButton>` with explicit variants for hover/focus; `<Box as="button">` with no variant is used nowhere else |
| N14 | ForwardMessageDialog | `ForwardMessageDialog.tsx` | 137154 | Dialog uses `<Modal>` but has no `<Header>` component and no close `<IconButton>` — only way to close is clicking outside — **FIXED**: added a folds `<Header variant="Surface" size="500">` with the title + close `<IconButton radii="300">`, matching every other modal | Every other modal using `<Modal>` or `<Box role="dialog">` includes a `<Header>` with a close `<IconButton>` in the top-right (EditHistoryModal, LeaveRoomPrompt, ScheduleMessageModal, RemindMeDialog, etc.) |
| N15 | ScheduleMessageModal | `ScheduleMessageModal.tsx` | 180193 | Modal root is `<Box as="form" role="dialog">` with manually assembled `borderRadius: config.radii.R400`/`boxShadow`**FIXED**: shell is now `<Dialog as="form" variant="Surface">`; removed inline surface styles | `ForwardMessageDialog` uses folds `<Modal size="400">` with `R500` radius; the R400 vs R500 mismatch is visible when both dialogs appear in the same session |
| N16 | Presence Picker | `SettingsTab.tsx` | 118144 | Presence trigger dot is raw `<button>` with `position: absolute; bottom: 2; right: 2` inline and no folds focus ring; no tooltip — **FIXED**: wrapped the trigger in a folds `TooltipProvider` (shows "Status: …"); replaced the undefined `var(--bg-surface)` with `color.Background.Container`. Kept the absolute-positioned `<button>` (it overlays the avatar corner; a full `IconButton` would be too large for the dot). | Every other sidebar icon button uses folds `IconButton` with `SidebarItemTooltip` and `TooltipProvider` |
| N17 | Presence Picker | `SettingsTab.tsx` | 8086 | `PresencePicker` `FocusTrap` missing `escapeDeactivates: stopPropagation` and `isKeyForward`/`isKeyBackward`**FIXED**: added all three options, matching the theme selector / sort menus | Every other `PopOut`+`FocusTrap`+`Menu` combo supplies both (theme selector `General.tsx:143160`, `SettingsSelect`, sort menus) — without it Escape bubbles past the trap and arrow-key navigation is absent |
| N18 | Profile Selects | `Profile.tsx` | 547575 / 816848 | `ProfileStatus` auto-clear and `ProfileTimezone` selectors are native `<select>` elements with hardcoded `colorScheme: 'dark'` — will render in dark mode on light themes | General.tsx uses folds `SettingsSelect<T>` (`Button`+`PopOut`+`Menu`) for all dropdowns; `colorScheme: 'dark'` breaks light/custom theme appearance |
| N19 | Presence Labels | `useUserPresence.ts` vs `SettingsTab.tsx` | 5562 / 3642 | `PresenceBadge` tooltip shows "Active / Busy / Away"; `PresencePicker` options read "Online / Idle / Do Not Disturb / Invisible" — a DND user shows tooltip "Busy", not "Do Not Disturb" — **FIXED**: aligned `usePresenceLabel` reader vocabulary to the setter (online→"Online", unavailable→"Idle", offline→"Offline") | Within the same Lotus feature set the user-facing vocabulary is inconsistent between the setter UI and the reader tooltip |
| N20 | Notification Presets | `Notifications.tsx` | 57107 | Gaming/Work/Sleep preset buttons are bare `<button>` elements with Lotus-specific CSS vars (`--border-interactive-normal`, `--bg-surface-low`) not defined in all themes — **FIXED**: converted to folds `<Button variant="Secondary" fill="Soft" radii="300">` (auto height) wrapping the emoji/label/description column; undefined vars removed | Grouped preset/action buttons elsewhere use folds `Chip variant="Primary/Secondary" outlined radii="Pill"` (e.g., Composer Toolbar toggles in `General.tsx:11001113`) |
| N21 | Notification Sound Selects | `SystemNotification.tsx` | 111305 | Message sound, invite sound, and quiet-hours time pickers are bare `<select>`/`<input type="time">` with `colorScheme: 'dark'` workaround | All other dropdowns in settings use the `Button`+`PopOut`+`Menu`+`MenuItem` folds pattern; the native select renders OS-styled on all platforms |
| N22 | DM Preview Virtualizer | `RoomNavItem.tsx` / `Direct.tsx` | 608627 / 232 | DM preview adds a second text row to each DM item, making it taller than 38px, but `useVirtualizer` in `Direct.tsx` still uses `estimateSize: () => 38` — causes layout jump/overlap on initial render — **FIXED**: bumped `estimateSize` to 52 (the two-line DM-row height) so the initial estimate matches the common case; `measureElement` still corrects each row exactly | Non-DM rooms in Home.tsx also estimate 38px; DM items with a preview are now a different height, creating two visual densities in the same nav column |
| N23 | RoomServerACL | `RoomServerACL.tsx` | 100115 / 298309 | Server-name text input is a raw `<input type="text">` with inline style object; "Allow IP literal addresses" is a raw `<input type="checkbox">` with `style={{ width: 16, height: 16 }}`**FIXED**: text input → folds `<Input variant={error?'Critical':'Secondary'}>`; checkbox → folds `<Checkbox variant="Primary">` | All other text/boolean controls in room settings use folds `Input` and `Checkbox` components (`RoomAddress.tsx:163`, `RoomAddress.tsx:330`) |
| N24 | PolicyListViewer | `PolicyListViewer.tsx` | 245264 | Room-ID add input is a raw `<input type="text">` with manually replicated folds token values — **FIXED**: replaced with folds `<Input variant={error?'Critical':'Secondary'} size="400" radii="300">` | Native pattern: folds `<Input variant="Secondary" size="300" radii="300">` — no inline style needed |
| N25 | ExportRoomHistory Inputs | `ExportRoomHistory.tsx` | 258292 | Both date range pickers are raw `<input type="date">` with inline styles — **FIXED**: replaced with folds `<Input type="date" variant="Secondary" size="400" radii="300">` | Native pattern: folds `Input` component; `<input type="date">` renders OS-native date picker, unstyled relative to the rest of the settings panel |
| N26 | RoomShareInvite QR | `RoomShareInvite.tsx` | 6673 | QR code `<img>` has no `onError` handler and no loading state — broken-image placeholder shown when the external API is unreachable — **FIXED**: added `loading="lazy"` + `onError` that swaps to a folds "QR code unavailable" placeholder card | Cinny avatar components and MediaGallery use `onError` handlers; this is the only settings element making a request to a third-party server with no graceful degradation |
---
### 🟡 Minor — Cosmetic / Token Discipline
| # | Area | File | Lines | Issue | Native Pattern |
| :------ | :--------------------------------- | :------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N27 | GIF Picker | `GifPicker.tsx` | 103110 | `FocusTrap` omits `returnFocusOnDeactivate: false` — focus returns to GIF button on dismiss instead of staying in the editor — **FIXED**: added `returnFocusOnDeactivate: false` (matches EmojiBoard) | `EmojiBoard` in `RoomInput.tsx:978` explicitly sets `returnFocusOnDeactivate={false}`; GIF picker dismiss behaviour is inconsistent with emoji picker |
| N28 | Character Counter | `RoomInput.tsx` | 11591174 | Composer character counter rendered with `color: 'var(--tc-surface-low)'` and raw pixel padding — a CSS variable not used anywhere else in the codebase — **FIXED**: removed undefined var and raw opacity; now `<Text priority="300">` with `config.space.S100` padding | Use `color.*` folds tokens or `priority="300"` on a `Text` component |
| N29 | PollCreator Modal | `PollCreator.tsx` | 103116 | Modal root is `<Box as="form" role="dialog" aria-modal="true">` with manually assembled surface styles instead of folds `<Dialog variant="Surface">`**FIXED**: shell is now `<Dialog as="form" variant="Surface">`; removed inline surface styles | `MessageDeleteItem` and `MessageReportItem` in `Message.tsx:506,635` use `<Dialog variant="Surface">` inside `OverlayCenter > FocusTrap` |
| N30 | Playback Speed Chip | `AudioContent.tsx` | 163189 | Speed chip uses `variant="SurfaceVariant" radii="Pill"` while adjacent Play/Pause chip uses `variant="Secondary" radii="300"` — mismatched shape and variant within the same `leftControl` row — **FIXED**: changed speed chip to `variant="Secondary" radii="300"` | Controls grouped in the same row should share variant and radii |
| N31 | Collapsible Message Toggle | `MsgTypeRenderers.tsx` | 97105 | "Read more ↓" / "Show less ↑" uses `<Button size="300" variant="Secondary" fill="None">` — visually a padded form button — **FIXED**: replaced with the native flush inline-button pattern (`background:none;border:none;padding:0`) + `<Text size="T200">` tinted `color.Primary.Main`, matching `(edited)` in FallbackContent | Inline text toggles in message content (e.g. `(edited)` in `FallbackContent.tsx:74`) use bare `<button>` with `background: none; border: none; padding: 0` to stay flush with text |
| N32 | ReadReceiptAvatars Pill | `ReadReceiptAvatars.tsx` | 95103 | Pill border is `'1px solid rgba(0,212,255,0.30)'` hardcoded raw rgba string; `borderRadius: '999px'` not a folds radii token; padding in raw pixels — **FIXED**: replaced with `config.borderWidth.B300`, `config.radii.Pill`, `config.space.S100/S200` | Use `color.*` folds tokens and `config.radii.Pill` / `config.space.S*` |
| ~~N33~~ | ~~ReadReceiptAvatars Class~~ | ~~`ReadReceiptAvatars.tsx`~~ | ~~67~~ | ~~`className="receipt-pill-btn"` references a class never defined~~**FIXED**: removed dead className | All custom CSS goes through co-located vanilla-extract `*.css.ts` files |
| N34 | EventReaders Header Size | `EventReaders.tsx` | 70 | `Header size="600"` (56px tall) while all peer message-action modals use `size="500"` (48px) — **FIXED**: changed to `size="500"` | `EditHistoryModal`, `LeaveRoomPrompt`, `MessageDeleteItem`, `MessageReportItem` all use `size="500"`; `size="600"` is reserved for full-page panel headers |
| N35 | EventReaders Close Button | `EventReaders.tsx` | 96 | Close `IconButton` missing explicit `radii="300"` prop — **FIXED**: added `radii="300"` | Every peer modal close button explicitly sets `radii="300"` (EditHistoryModal:184, LeaveRoomPrompt:75, MessageDeleteItem:517) |
| N36 | EventReaders Header Border | `EventReaders.tsx` | 7277 | Lotus-mode header sets `borderBottom: '1px solid var(--lt-border-color)'` as a CSS shorthand string — **FIXED**: changed to `borderBottomWidth: config.borderWidth.B300` | Native modals use `borderBottomWidth: config.borderWidth.B300` to avoid overriding the border-color set by the folds variant system |
| N37 | EventReaders Timestamp | `EventReaders.tsx` | 143151 | Lotus path sets `fontSize: '0.72rem'` inline — a raw relative unit between folds `T200` and `T100` scale steps — **FIXED**: removed raw `fontSize`, added `priority="300"` | Use folds `Text size="T200" priority="300"` for subdued secondary text |
| N38 | BookmarksPanel Header | `BookmarksPanel.tsx` | 155196 | Header uses `variant="Surface"` and close button uses `size="300" radii="300"`; also has a SurfaceVariant search bar strip with no equivalent in any native drawer — **FIXED (full redesign)**: rebuilt the whole "Saved Messages" panel 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/escape), `variant="Background"` header, room **avatars** on each item (was a generic hash icon), `priority` tokens replacing all raw `opacity` hacks, the `borderLeft:3px` accent removed, and Escape-to-close added. | `MembersDrawer` header uses `variant="Background"` and default-size close button; the extra search+count strip creates a structurally different component family |
| N39 | Forward Menu Icon | `Message.tsx` | 1150 | Forward context menu item's `after` icon has no `size="100"` prop — **FIXED**: added `size="100"` to the `ArrowRight` icon | Every other after-icon in the same menu block explicitly uses `size="100"` (Reply, Reaction, Edit, Remind Me, Bookmark); missing size causes the Forward icon to render larger |
| N40 | ProfileDecoration Remove Button | `ProfileDecoration.tsx` | 185 | "Remove" link is a raw `<button>` with `background: 'none'; color: 'var(--tc-surface-low-contrast)'` — an undefined CSS variable — **FIXED**: replaced with `<Button variant="Critical" fill="None" size="300" radii="300">` | Use folds `<Button variant="Critical" fill="None">` or a `Text`-styled inline link |
| N41 | PresenceBadge / UserNotes Saving | `UserRoomProfile.tsx` | 240244 | "Saving…" indicator is `<Text opacity={0.5}>` without a spinner — **FIXED**: now shows a folds `<Spinner variant="Success" fill="Solid" size="100">` beside the "Saving…" text | Every other save operation in `Profile.tsx` shows a folds `<Spinner variant="Success" fill="Solid" size="300">` alongside the save button |
| N42 | Character Counter Convention | `UserRoomProfile.tsx` vs `Profile.tsx` | 243 / 479490 | `UserPrivateNotes` shows remaining count `"N left"`, appears only under 100; `ProfileStatus` shows `"current / 64"` always with color progression | Two Lotus features in the same settings flow use different counter conventions; neither matches a pre-existing Cinny pattern |
| N43 | Night Light Slider | `General.tsx` | 554565 | Night Light intensity slider is a raw `<input type="range">` with no `accentColor` token — renders in browser-default blue on all themes — **FIXED**: added `accentColor: color.Primary.Main`; the intensity label `opacity` hack also replaced with `priority="300"` | The Gate Threshold slider at `General.tsx:1456` at minimum sets `accentColor: 'var(--accent-orange)'`; the Night Light slider does neither |
| N44 | Mention Highlight & Boot Button | `General.tsx` | 597677 | `<input type="color">` for mention highlight uses raw pixel dimensions (`width: '36px'`, `height: '28px'`, `borderRadius: '4px'`); Reset and Boot buttons are bare `<button>` with Lotus CSS vars — **PARTIALLY FIXED**: the mention-highlight Reset button (renders on all themes) is now a folds `<Button variant="Secondary" fill="Soft">`, removing the undefined `--border-interactive-normal` var. The Boot button is **deliberately kept** as-is: it only renders when `lotusTerminal` is active, i.e. exactly when the `--accent-orange*` TDS vars are defined. The `<input type="color">` itself is tracked separately as N69. | Adjacent settings controls use folds `IconButton`/`Button`; there is no other `<input type="color">` in the Cinny settings UI |
| N45 | SettingsSelect vs SelectTheme | `General.tsx` | 126 vs 197 | `SettingsSelect` trigger uses `variant="Secondary"` while `SelectTheme` uses `variant="Primary" outlined fill="Soft"` for the same `Button`+`PopOut` dropdown pattern — adjacent rows in the same Appearance section have different visual weight — **FIXED**: `SelectTheme` trigger changed to `variant="Secondary"` to match `SettingsSelect` | Dropdown triggers should share the same variant within the same settings section |
| N46 | RoomInsights SectionHeader | `RoomInsights.tsx` | 2437 | `SectionHeader` adds `textTransform: 'uppercase'`, `letterSpacing: '0.06em'`, `opacity: 0.6` to `Text size="L400"`**FIXED**: simplified to `<Text size="L400" priority="300">` | Every other settings panel uses bare `<Text size="L400">Label</Text>` with no transforms (`General.tsx:5272`, `ExportRoomHistory.tsx:220,246`) |
| N47 | RoomInsights Chart Radii | `RoomInsights.tsx` | 350356 / 415436 | Bar chart uses `borderRadius: 3` and histogram bars use `borderRadius: '2px 2px 0 0'` as raw pixel integers — **FIXED**: replaced with `config.radii.R300` | All other rounded corners use `config.radii.*` tokens |
| N48 | RoomInsights Font Size | `RoomInsights.tsx` | 448 | Hour-axis labels set `style={{ fontSize: 9 }}` as a raw pixel integer — overrides the folds `Text size="T200"` applied on the same element — **FIXED**: removed raw `style={{ fontSize: 9 }}` | Use only folds `Text` size props; never override with raw `fontSize` |
| N49 | RoomInsights Emoji Icons | `RoomInsights.tsx` | 4165 / 292295 | `StatTile` uses literal Unicode emoji (`🖼️ 🎬 🎵 📎`) in `<Text size="H4">` as icons — **FIXED**: `StatTile` now takes an `icon: IconSrc` and renders `<Icon>` using `Icons.Photo/VideoCamera/Headphone/File` | All other iconographic elements use `<Icon src={Icons.*} />` from folds — emoji rendering varies between Windows/macOS/Linux and cannot be tinted by the theme |
| N50 | RoomInsights Warning Banner | `RoomInsights.tsx` | 168192 | Disclaimer banner uses raw `<Box style={{ border: color.Warning.Main, background: color.Warning.Container }}>`**FIXED**: replaced with `<SequenceCard variant="SurfaceVariant">` with `<Icon>` colored via `color.Warning.Main` | Settings panel informational cards use `<SequenceCard variant="SurfaceVariant">` throughout RoomServerACL, ExportRoomHistory, PolicyListViewer |
| N51 | ExportRoomHistory Progress | `ExportRoomHistory.tsx` | 311314 | Export progress shows as a plain `Text` string ("Exporting… N messages") — **WON'T FIX (deliberate)**: unlike `BackupRestore` (which has a known total to drive a determinate `ProgressBar`), export has no known total — it counts messages as they stream. The operation already shows a folds `Spinner` in the button plus a live count, which is the correct affordance for an indeterminate task. | `BackupRestore.tsx:72,90` uses a folds `<ProgressBar variant="Secondary" size="300">` for the same kind of long async operation |
| N52 | MessageQuickReactions Empty Return | `Message.tsx` | 160 | `if (recentEmojis.length === 0) return <span />;` — injects an invisible DOM node into the hover action bar flex container — **FIXED**: changed to `return null` | Universal convention for empty renders in Cinny is `return null`; 144+ instances across the codebase; the empty `<span>` can affect flex spacing |
---
### Round 2 — Additional Feature Areas
#### 🔴 Additional Major Findings
**N53 — PTT Badge (Lotus Terminal path): Raw `<div>` tree with `--lt-*` CSS vars instead of folds `<Chip>`**
- **File:** `src/app/features/call/CallControls.tsx`, lines 242282
- **Status:** **OPEN**
- **Issue:** When `lotusTerminal` is true the PTT badge renders as a bare `<Box>` with inline styles referencing `--lt-accent-green-dim`, `--lt-accent-green-border`, `--lt-accent-green` — variables absent outside TDS mode — hardcoded rem padding, `borderRadius: '99px'` (non-token), a raw monospace `fontFamily` string, non-token `letterSpacing`, and a raw `animation:` CSS string for the live-pulse dot. The live `●` dot is a raw `<span>` with inline style.
- **Root Cause:** Two entirely separate component trees for the same badge depending on a theme boolean. The non-terminal path (lines 284301) uses the correct `<Chip variant="Success"|"Warning" fill="Soft" radii="400" outlined>`.
- **Fix:** Remove the terminal branch. The standard `<Chip>` path already exists and TDS theming can be applied via the CSS variable layer without a separate component tree.
**N54 — PiP Mute Overlay Badges: Raw `<div>` instead of folds `<Badge>`/`<Chip>`**
- **File:** `src/app/components/CallEmbedProvider.tsx`, lines 438477
- **Status:** **FIXED** — replaced hardcoded `borderRadius`/`padding`/`fontSize` with `config.radii.R300`, `config.space.S100/S200` tokens; replaced raw `<span>` text with folds `<Text size="T200">`; color now applied to the `Icon`/`Text` via `color.Critical/Warning.Main`. The dark translucent scrim (`rgba(0,0,0,0.65)`) is **deliberately retained**: these badges overlay arbitrary video, where a theme `Chip`/`Badge` surface token would not guarantee legibility. They are also non-interactive (`pointerEvents: 'none'`), so an interactive `Chip` (a `<button>`) is semantically wrong.
- **Issue:** Both the "You muted" (bottom-left) and "All muted" (top-right) PiP badges are raw `<div>` elements with hardcoded `background: 'rgba(0,0,0,0.65)'`, `backdropFilter: 'blur(4px)'`, `borderRadius: '6px'`, `padding: '3px 7px'`, `fontSize: '12px'`. Color is set as `color: color.Critical.Main` directly on the wrapper `<div>`, not via a folds `variant` prop. Text is `<span style={{ fontSize: '11px', fontWeight: 600 }}>`.
- **Root Cause:** `CallView.tsx` line 127 uses `<Badge variant="Critical" fill="Solid" size="400">` in the same file for the "N Live" indicator — the native pattern exists and is unused here.
**N55 — Chat Background / Seasonal Theme Selected State Uses `color.Critical.Main` (Error Red)**
- **File:** `src/app/features/settings/general/General.tsx`, lines 16601661 and 17261728
- **Status:** **FIXED** — replaced all 4 instances of `color.Critical.Main` with `color.Primary.Main` in `General.tsx`
- **Issue:** The selected-state border for both `ChatBgGrid` and `SeasonalBgGrid` is `border: \`2px solid ${color.Critical.Main}\``and the label color is also`color.Critical.Main`. `color.Critical.Main` is the semantic token for **destructive/error states** — it is used for "Leave Room", "Delete Message", "Report Room" in the same file. A normal selection indicator rendered in error red is semantically wrong and visually alarming.
- **Root Cause:** Wrong semantic token for an active/selected state.
- **Fix:** Replace `color.Critical.Main` with `color.Primary.Main` (or `color.Success.Main` to match how other settings selections are styled) for both the border and label color.
**N56 — Report Modal Category Dropdown: Native `<select>` Instead of folds `Chip`+`PopOut`+`Menu`**
- **File:** `src/app/features/room/ReportRoomModal.tsx` lines 138163; `src/app/features/room/ReportUserModal.tsx` lines 144169
- **Status:** **FIXED** — extracted a shared `ReportCategorySelect` component (`src/app/features/room/ReportCategorySelect.tsx`) using the folds `Button` trigger + `PopOut` + `FocusTrap` + `Menu` + `MenuItem` pattern (with `escapeDeactivates`/arrow-key nav, matching `OrderButton`); both modals now use it instead of the native `<select>`.
- **Issue:** Both report modals render the "Category" field as `<Box as="select">` with hand-rolled inline styles (padding, border, background, color, fontSize, fontFamily). No other selector in the message-action modal context uses `<select>` — the established pattern for all dropdowns in both message modals and search filters is `Chip onClick → setMenuAnchor → PopOut → FocusTrap → Menu → MenuItem` (reference: `OrderButton` in `SearchFilters.tsx` lines 63114).
---
#### 🟠 Additional Moderate Findings
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :--------------------------------------------------------------------------- | :-------------------------------------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| N57 | PiP Fullscreen Button | `CallEmbedProvider.tsx` | 929951 | PiP fullscreen toggle is a raw `<button>` with `background: 'rgba(0,0,0,0.65)'`, `color: '#fff'`, `fontSize: '13px'`, Unicode ⛶/⊡ glyph — no focus ring, no tooltip — **FIXED (token discipline)**: `borderRadius`/`padding`/gap replaced with `config.radii.R300` + `config.space.*` tokens (also on the "Return to call" label). The dark scrim and `#fff` text are **deliberately kept** for legibility over arbitrary video; the glyph stays because folds has no fullscreen icon. `aria-label`/`title` tooltip already present. | `Controls.tsx` fullscreen button uses `<IconButton variant="Surface" fill="Soft" radii="400" size="400" outlined>` with `<TooltipProvider>`; hardcoded `#fff` fails on light themes |
| N58 | Screenshare Confirm Popup | `CallControls.tsx` | 303360 | "Share your screen?" popup is a raw `<Box>` with `--bg-surface`/`--bg-surface-border` vars (undefined outside TDS), `borderRadius: '0.75rem'`, `boxShadow: '0 8px 32px rgba(...)'`, no `FocusTrap` | Cinny's confirmation dialogs use folds `<Menu>` + `<FocusTrap>` + `<PopOut>`; the non-FocusTrap popup is not keyboard-accessible |
| N59 | ML Noise Suppression Panel | `General.tsx` | 13031487 | Sub-panel uses `var(--border-color)`, `var(--bg-card)`, `var(--bg-input)` (undefined in folds default theme), raw `<details>`/`<summary>` (UA-styled), `accentColor: 'var(--accent-orange)'` (TDS-only) | All other settings sub-sections use `<SettingTile>` rows inside `<SequenceCard>`; no other settings component uses `<details>` |
| N60 | Knock Badge on Members Button | `RoomViewHeader.tsx` | 744782 | Knock count badge wrapped in extra `<div style={{ position: 'relative' }}>` with hardcoded `fontSize: '9px'`, `minWidth: '14px'`, `height: '14px'`, `padding: '0 3px'` overriding folds `size="200"`**FIXED**: removed wrapper div, put `position: 'relative'` directly on the `IconButton`, `<Badge size="400">` with `toRem(3)` insets and `<Text size="L400">` — now matches the Pinned Messages badge pattern exactly | Pinned Messages badge (same header, lines 651677) uses `position: 'relative'` directly on `<IconButton>` + `toRem()` for inset; no extra wrapper div |
| N61 | Knock Member Rows | `MembersDrawer.tsx` | 441487 | Knock requester rows use raw `<Box>` with manually duplicated padding; no `<MenuItem>` wrapper → no hover/focus/active states — **WON'T FIX (deliberate)**: unlike a `MemberItem` (a clickable navigation row), a knock row contains two action buttons (Approve / Deny) and is **not itself clickable**. Wrapping it in `<MenuItem>` (a `<button>`) would nest interactive controls inside a button — invalid HTML/ARIA. The row has no interactive state to express. | Every joined/invited member uses `<MemberItem>` which wraps `<MenuItem variant="Background" radii="400">` with baked-in spacing and all interactive states |
| N62 | Unverified Device Banner | `RoomInput.tsx` | 860883 | Warning callout above composer uses inline `background: color.Warning.Container`, `borderLeft: '3px solid color.Warning.Main'` — a custom left-border accent pattern not present anywhere else in the folds system — **FIXED**: replaced the `borderLeft: '3px'` accent with a standard full `border` using `color.Warning.ContainerLine` + `config.borderWidth.B300`; removed the `opacity` hacks (folds `OnContainer` already meets contrast) | Warning indicators in the same codebase use `<Chip variant="Warning">` or `<Badge variant="Warning">`; the 3px left-border card pattern has no folds equivalent |
| N63 | Report Modals — Box Instead of Dialog | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 97110 / 103116 | Both modals render as `<Box as="form" role="dialog">` with inline `background`/`borderRadius`/`boxShadow`; use `config.radii.R400` (rounder) vs native `Dialog` which uses `R300`**FIXED**: both shells are now `<Dialog as="form" variant="Surface">`; removed inline surface styles (Dialog provides background/radius/shadow) | Native `MessageReportItem` at `Message.tsx:634` and all other Cinny message-action modals use `<Dialog variant="Surface">` |
| N64 | EditHistoryModal — `<Modal>` vs `<Dialog>` | `EditHistoryModal.tsx` | 166 | Uses `<Modal variant="Surface" size="500">` while sibling message-action modals (`DeleteMessageItem:505`, `MessageReportItem:634`) all use `<Dialog variant="Surface">` — different widths and internal padding | `<Dialog variant="Surface">` is the established modal shell for all message-triggered dialogs |
| N65 | EditHistoryModal — No "Load More" | `EditHistoryModal.tsx` | 253259 | When `hasMore` is true the modal shows passive `<Text>"Showing the 50 most recent edits"</Text>` with no action; older edits are inaccessible — **FIXED**: implemented real pagination — edits accumulate across `next_batch` fetches (de-duped by event id, re-sorted by ts), with a folds `<Button>Load more</Button>` (spinner while loading) replacing the passive text | `RoomActivityLog.tsx:425` and `MessageSearch.tsx:129` both render a folds `<Button size="300" variant="Secondary">Load more</Button>` to fetch the next page |
| N66 | DateRangeButton — Native `<input type="date">` | `SearchFilters.tsx` | 558589 | "From" and "To" date fields are raw `<input type="date">` with inline style overrides including `fontSize: '0.82rem'`**FIXED**: replaced both with folds `<Input type="date" variant="SurfaceVariant" size="300" radii="300">`; removed now-unused `color` import | `SelectRoomButton` (same file, line 224) and `SelectSenderButton` (line 424) both use folds `<Input size="300" radii="300">`; the date inputs are the only native browser inputs in the search filter row |
| N67 | SeasonalEffect / NightLight Z-Index Order | `SeasonalEffect.tsx` / `App.tsx` | 759 / 6277 | `SeasonalEffect` mounts at `zIndex: 9999`; `NightLightOverlay` at `zIndex: 9998`. Seasonal particles render **above** Night Light so they are never tinted. `SeasonalEffect` also shares `z-index: 9999` with the skip-to-content link in `ClientLayout.tsx`**FIXED**: lowered `SeasonalEffect` overlay to `zIndex: 9997` (below Night Light at 9998 and modals at 9999), so Night Light now tints the particles and dialogs are never obscured | Expected UX: Night Light tints all visible content including effects; requires either a higher Night Light z-index or a lower SeasonalEffect z-index |
| N68 | Syntax Highlighting — `--lt-accent-*` Vars in Non-TDS Themes | `syntaxHighlight.ts` | 313323 | `tokenStyle()` returns `var(--lt-accent-cyan/green/orange/purple, hardcoded-fallback)``--lt-*` vars only exist in TDS mode; fallbacks are Monokai dark colors that have poor contrast on light themes and no relationship to the existing `--prism-*` variables in `ReactPrism.css`**FIXED**: `tokenStyle()` now maps to the `--prism-*` family (keyword/selector/boolean/atrule/comment) which has proper light/dark/TDS palettes; comment uses `--prism-comment` instead of an opacity hack | `ReactPrism.css` uses `--prism-keyword`, `--prism-selector` etc. which switch correctly between light and dark palettes; syntax highlighting should use the same variable family |
| N69 | Mention Highlight — `<input type="color">` Instead of `HexColorPickerPopOut` | `General.tsx` | 644675 | Raw `<input type="color">` with hardcoded pixel dimensions; OS-native color picker chrome renders completely differently from the rest of settings UI — **FIXED**: replaced with `<HexColorPickerPopOut>` + `<HexColorPicker>` (react-colorful) behind a folds `<Button>` trigger showing a color swatch; the picker's built-in `onRemove` replaces the separate Reset button | `PowersEditor.tsx:125143` establishes `<HexColorPickerPopOut picker={<HexColorPicker ...>}>` as the codebase's color-picking pattern; Reset button should be `<Button size="300" variant="Secondary" radii="300">` |
| N70 | ChatBgGrid / SeasonalBgGrid — Raw `<button>` Elements | `General.tsx` | 16481689 / 17111742 | Both pickers use raw HTML `<button>` elements with hardcoded `width: toRem(76)`, `height: toRem(50/56)`, `borderRadius: toRem(8)`, `border: 2px solid rgba(...)` — no focus ring via folds, no `variant` prop, no hover state from the design system — **FIXED**: chrome (radius, border, hover, **keyboard `:focus-visible` ring**, selected state via `data-selected`) moved to a shared `BgSwatch.css.ts` using `config`/`color` tokens; only the per-swatch size + live preview background remain inline (these are inherently custom preview tiles, not folds `MenuItem`/`Chip` candidates) | Native Cinny theme pickers use folds `<MenuItem>` or `<Chip>` which respond to theme and provide focus/hover states automatically |
---
#### 🟡 Additional Minor Findings
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :-------------------------------------------- | :-------------------------------------------- | :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N71 | Call Prescreen Text | `CallView.tsx` | 6385 | `ChannelFullMessage` and `AlreadyInCallMessage` use `<Text style={{ color: color.Critical/Warning.Main }}>` inline instead of folds `<Badge variant="Critical/Warning">`**WON'T FIX (deliberate)**: these are full, centered explanatory **sentences** ("Channel Full (N/M) — Wait for someone to leave…"), not short labels. A `Badge` is for compact chips like "N Live"; wrapping a sentence in one is visually wrong. They already use folds `color.*` tokens. The sibling `LivekitServerMissingMessage`/`NoPermissionMessage` use the same (un-flagged) pattern. | The "N Live" badge directly above (line 127) correctly uses `<Badge variant="Critical" fill="Solid" size="400">` |
| N72 | Mute MenuItem Icon | `RoomNavItem.tsx` | 454466 | "Mute" `<MenuItem>` places bell-mute icon as a raw child node instead of using the `before` prop — **FIXED**: moved `Icons.BellMute` to `before` prop | Every other `<MenuItem>` in both `RoomNavItemMenu` and `RoomMenu` places its leading icon in the `before` prop |
| N73 | Pending Requests Header | `MembersDrawer.tsx` | 415422 | "Pending Requests" section header is bare `<Text>` with inline padding instead of `className={css.MembersGroupLabel}`**FIXED**: now uses `className={css.MembersGroupLabel}` like every other section header | Power-level group labels at lines 506519 use `className={css.MembersGroupLabel}` for all other section headers in the same virtualizer list |
| N74 | Emoji Prefix Span | `RoomNavItem.tsx` | 730736 | Emoji prefix rendered as raw `<span style={{ fontSize: '1.15em', lineHeight: 1 }}>` inside a `<Text>` node — **FIXED**: removed the emoji-splitting span; the room name (including any leading emoji) now renders directly inside `<Text>` | All other nav item text uses folds `<Text size="Inherit">` or similar — no raw `<span>` with em-based font-size override exists elsewhere in the sidebar |
| N75 | Room Name Override / Star Indicators | `RoomNavItem.tsx` | 741757 | Pencil and star indicator icons are embedded inside the name `<Box as="span">`, giving them the same visual baseline as the room name text — **WON'T FIX (deliberate)**: an inline favorite-star / local-name marker adjacent to the name is a deliberate, common design (cf. Element/Slack pinned-name markers). Moving them to the far right would collide with the unread/notification indicators already there and risks layout regressions. Low value, real regression risk. | Native sidebar status indicators (unread count, notification mode icon) are placed to the far right of the item, never inside the name text span group |
| N76 | Report Modals — Extra Cancel Button | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 189191 / 195197 | Both custom report modals include a "Cancel" `<Button>` in the footer row — **FIXED**: removed the Cancel button; dismissal is via the header `×` / click-outside, matching `MessageReportItem` | Native `MessageReportItem` (`Message.tsx:675691`) has no Cancel button — dismissal is via `×` header button or click-outside only |
| N77 | Search Filter Inline Lambdas | `SearchFilters.tsx` | 480, 625 | `SelectSenderButton` and `DateRangeButton` trigger chips use inline `onClick` arrow functions — **WON'T FIX (deliberate)**: purely a code-style nit with zero user-facing or behavioural impact. Inline arrow handlers are idiomatic React and used throughout this very file; extracting them yields no functional benefit. | `OrderButton` (line 58) and `SelectRoomButton` (line 195) both extract a named `const handleOpenMenu: MouseEventHandler<HTMLButtonElement>` handler — bypassing the type annotation in the inline form |
| N78 | HasLink Chip Active Color | `SearchFilters.tsx` | 755 | `HasLink` active state uses `variant="Primary"` (blue); all boolean scope-toggle chips in the same bar use `variant="Success"` (green) with `outlined`**FIXED**: changed to `variant={containsUrl ? 'Success' : 'SurfaceVariant'} outlined={!!containsUrl}` | `variant="Success" outlined` is the established active-state pattern for boolean toggles in the filter bar |
| N79 | Server Notice Chip Radii | `RoomViewHeader.tsx` | 570 | `<Chip size="400" radii="Pill">``Pill` radii on a room-type label — **FIXED**: changed to `radii="300"` | Room/space type labels in lobby (`RoomItem.tsx:83`, `SpaceItem.tsx:63`) use `radii="300"`; `radii="Pill"` is for filter/tag chips only |
| N80 | Server Support Contact Layout | `About.tsx` | 172239 | Homeserver support contacts rendered as raw `<Box direction="Column">` with `<Text as="a">` pairs — custom label/link layout — **WON'T FIX (deliberate)**: a contact is `role → {matrix_id?, email?, …}` (one-to-many links per role), which doesn't map onto `SettingTile`'s single `title`/`description`/`after` slots without contortion. The current layout already uses folds `Box`/`Text`/`SequenceCard` + tokens and `Text as="a"` (a valid folds pattern); no undefined vars or raw HTML chrome. | All other `<SequenceCard>` content in `About.tsx` and `General.tsx` uses `<SettingTile title="..." description="..." after={...}>` as the content unit |
| N81 | Background Picker Grid — No Responsive Layout | `General.tsx` | 17071742 | Fixed `width: toRem(76)` flex-wrap cells with no `minWidth` floor or CSS grid `auto-fill` — SeasonalBgGrid's 13 items produce a visually lopsided orphan last row at any viewport width | Cinny's native grids use `grid-template-columns: repeat(auto-fill, minmax(N, 1fr))` or equivalent for responsive fill |
| N82 | Join/Leave Sounds Auto-Preview | `General.tsx` | 15921609 | Selecting a sound in the dropdown immediately plays a preview, but no UI affordance (button label, description text, or "▶ Preview" button) communicates this to the user | Settings tiles with side effects on selection (theme picker, chat background) show a live visual preview or a dedicated control explaining the side effect |
---
### Round 3 — Rich Topic Editor, RemindMe Dialog, Composer Toolbar, Voice Recorder, Uploads, Location, Mention Highlight
#### 🔴 Additional Major Findings
**N83 — Rich Topic Formatting Toolbar: Raw `<button>` Elements with Fully Inline Styles**
- **File:** `src/app/features/common-settings/general/RoomProfile.tsx`, lines 335358
- **Status:** **FIXED** — replaced raw `<button>` elements with `<Button size="300" radii="300" variant="Secondary" fill="Soft">` with styled `<Text>` children for B/I/S/code labels
- **Issue:** The four formatting buttons (B, I, S, `` ` ``) in the room topic editor are plain HTML `<button>` elements with entirely inline styles: manual `border`, `borderRadius`, `background`, `color`, `cursor`, `fontSize`, `fontWeight`, `fontStyle`, `fontFamily`, `lineHeight`. They bypass the folds design token system completely — no `variant`, `size`, or `radii` props, no theme-reactive hover/focus states.
- **Root Cause:** Custom addition without referencing folds primitives.
- **Fix:** Replace with `<IconButton type="button" size="300" radii="300" variant="Surface" fill="Soft">` matching the emoji-picker trigger immediately above them at line 285, which already uses the correct pattern.
**N84 — Topic Preview in Room Settings Renders Plain Text Instead of `formatted_body`**
- **File:** `src/app/features/common-settings/general/RoomProfile.tsx`, lines 457461
- **Status:** **FIXED** — read-mode topic now checks `topic.format === 'org.matrix.custom.html'` and renders `parse(sanitizeCustomHtml(topic.formatted_body))`, matching `RoomTopicViewer` and all other display sites
- **Issue:** The read-mode topic display wraps `topic.topic` (the plain-text field) in `<Linkify>` and never reads `formatted_body`. However `buildTopicContent()` (lines 8289) intentionally stores both `topic` and `formatted_body` under `org.matrix.custom.html`. After the user saves a formatted topic, the preview panel immediately shows the stripped plain-text version — the formatting appears to disappear within the same settings panel.
- **Root Cause:** The existing `RoomTopicViewer` component (`src/app/components/room-topic-viewer/RoomTopicViewer.tsx:2451`) already checks `topic.format === 'org.matrix.custom.html'` and pipes `formatted_body` through `sanitizeCustomHtml`. This component is used everywhere else (`RoomIntro`, `LobbyHero`, `RoomItem`, `Invites`, etc.) but not in Room Settings.
- **Fix:** Replace the inline plain-text render with `<RoomTopicViewer topic={roomTopic}>` to match all other display sites.
---
#### 🟠 Additional Moderate Findings
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :--------------------------------- | :------------------------- | :----------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N85 | RemindMe Dialog Shell | `RemindMeDialog.tsx` | 6981 | Dialog shell is `<Box role="dialog">` with `background`, `borderRadius`, `boxShadow`, `overflow` all set as inline styles using token lookups. Corner radius is `config.radii.R400` which differs from the `R300` embedded in `<Dialog variant="Surface">`**FIXED**: shell replaced with `<Dialog variant="Surface" style={modalStyle}>`; removed the inline `background`/`borderRadius`/`boxShadow`/`overflow` and the now-unused `color` import | All small message-action dialogs (`LeaveRoomPrompt`, `LogoutDialog`, `JoinAddressPrompt`, `PowerChip`, `DeleteMessageItem`) use `<Dialog variant="Surface" style={modalStyle}>` as the shell |
| N86 | RemindMe Preset Buttons | `RemindMeDialog.tsx` | 111117 | The four preset time choices (20 min, 1 hr, 3 hr, tomorrow) use `<MenuItem size="300" radii="300">``MenuItem` is a navigation primitive tied to `menu`/`menubar` ARIA roles; placing it inside `role="dialog"` is an invalid ARIA combination — **FIXED**: each preset is now a folds `<Button variant="Secondary" fill="Soft" radii="300">`, resolving the invalid `menuitem`-in-`dialog` ARIA | Dialog action choices use `<Button>` (delete/leave/logout dialogs) or `<Chip>` (selection choices). No other dialog in the codebase uses `MenuItem` for action items |
| N87 | Composer Toolbar Toggle Pattern | `General.tsx` | 11001114 | Per-button toolbar toggles (Format, Emoji, Sticker, GIF, Location, Poll, Voice, Schedule) use `<Chip variant="Primary"/"Secondary" radii="Pill">` in a wrap grid — a compact chip-toggle grid inside a `SettingTile`, different from every adjacent row | The three sibling tiles in the same `Editor()` function (ENTER for Newline, Markdown, Formatting Toolbar) all use `<SettingTile after={<Switch variant="Primary">}>`. 15+ other binary settings in the file use the Switch pattern |
| N88 | Voice Recorder Recording State | `VoiceMessageRecorder.tsx` | 195, 206, 240, 276 | Recording container background is `var(--bg-surface-variant)`, the live pulse dot is `var(--tc-danger-normal)`, waveform bars are `var(--tc-primary-normal)` — custom Lotus CSS vars that may not exist in folds themes, falling back to transparent/black — **FIXED**: replaced with `color.SurfaceVariant.Container`, `color.Critical.Main`, `color.Primary.Main` | Native message components use JS-accessible `color.*` tokens that are always populated regardless of theme class |
| N89 | Voice Recorder Preview Audio | `VoiceMessageRecorder.tsx` | 282283 | Preview state renders bare `<audio src={previewUrl} controls>` — native browser element with inconsistent cross-browser chrome — **FIXED**: replaced with `<audio ref>` + folds `<IconButton>` play/pause toggle; `onEnded` resets playing state | Native audio messages use folds `Attachment`/`AttachmentContent` layout wrappers; pre-send preview should use `<IconButton>` play/pause controls |
| N90 | Mention Highlight Contrast Formula | `App.tsx` | 3640 | Auto-computed text color (black/white) uses simplified luma `(0.299r + 0.587g + 0.114b)/255 > 0.5` — not WCAG 2.1 relative luminance (which requires gamma linearization) — **FIXED**: replaced with WCAG 2.1 relative luminance formula using `((c+0.055)/1.055)^2.4` gamma linearization; threshold moved from 0.5 to 0.179 | Folds `color.*.OnContainer` tokens are manually curated to pass WCAG AA 4.5:1 contrast ratios; custom computation must match this guarantee |
---
#### 🟡 Additional Minor Findings
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :--------------------------------- | :----------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N91 | Upload Card Caption Input | `UploadCardRenderer.tsx` | 356376 | Caption input is raw `<input type="text">` with hardcoded inline CSS using Lotus-specific vars not in folds — **FIXED**: replaced with folds `<Input variant="Secondary" size="300" radii="300">` | Other text inputs in the UI use folds `<Input size="300" radii="300">` with folds-token props for all sizing and color |
| N92 | Location "Open Location" Button | `MsgTypeRenderers.tsx` | 534547 | "Open Location" action link uses `<Chip as="a">` — compact badge-sized element — **FIXED**: replaced with `<Button as="a" variant="Secondary" fill="Solid" radii="300" size="400">` matching FileContent pattern | `FileContent.tsx` uses `<Button variant="Secondary" fill="Solid" radii="300" size="400">` for "Open File"/"Open PDF" |
| N93 | Location Coordinates Text | `MsgTypeRenderers.tsx` | 532 | `<Text size="T300" style={{ opacity: 0.65 }}>` — hardcoded non-standard opacity — **FIXED**: replaced with `<Text size="T300" priority="300">` | Secondary text uses folds `priority` prop; `0.65` is outside the token scale |
| N94 | Mention Highlight Border Invisible | `App.tsx` | 41 | `--mention-highlight-border` is set to the same value as `--mention-highlight-bg` — the border is invisible — **FIXED**: border is now `rgba(r,g,b,0.5)` — same hue as the background at 50% opacity, always visible | In folds, `color.*.ContainerLine` is always a lighter/muted sibling of `color.*.Container`, providing the 1px outline that gives mention chips visual definition |
-54
View File
@@ -1,54 +0,0 @@
# Engineering Review: Multi-Model ML Noise Suppression Upgrade (P5-30)
## Overview
This PR implements a robust, modular, and high-fidelity client-side audio processing pipeline for noise suppression (NS) within Lotus Chat. It addresses issues with static noise artifacts, suboptimal sample rate resampling, and the lack of transparency in the audio processing chain.
## 1. Architectural Changes
### 1.1 Audio Processing Pipeline (`lotus-denoise.js`)
- **Decoupled Initialization:** The shim now treats the audio chain as a configurable graph: `Source``Noise Gate` (optional) → `ML Model``LiveKit`.
- **Series Processing:** We enabled the browser-native suppressor (Google NSNet2) to run in series with the ML model. The native engine handles stationary noise (fan hum) efficiently, while the ML model focuses on transient "life" noise (keyboard clicks, mouse taps).
- **Hardware Fidelity:** Removed forced `48kHz` capture constraints in `getUserMedia`. This allows high-end audio interfaces (e.g., Rode/Scarlett at 48kHz) to pass raw audio without low-quality browser-level resampling, which was previously creating "static" artifacts.
- **SIMD Optimization:** Added runtime `WebAssembly.validate` checks to detect SIMD support. The pipeline dynamically selects `rnnoise_simd.wasm` over standard WASM if supported, reducing CPU utilization.
- **Failure Resilience:** Wrapped the entire graph initialization in `Promise.all` + `try/catch`. If any component (WASM loading, AudioWorklet initialization) fails, the shim sends a `postMessage` failure report and falls back to the raw microphone stream, ensuring calls never drop due to suppression errors.
### 1.2 Multi-Model Support
Added support for 4 distinct processing models:
1. **RNNoise (Mozilla):** Default lightweight hybrid model.
2. **Speex (Legacy):** DSP-based fallback for extremely low-CPU requirements.
3. **DTLN (Balanced):** Deep learning model (~15% CPU). Improved transient handling.
4. **DeepFilterNet 3 (Pro):** Studio-grade Deep Learning (~25-50%+ CPU). Designed for high-fidelity noise removal.
## 2. Infrastructure & Build Integration (`vite.config.js`)
- **Automated Asset Pipeline:** Added rules to copy model assets (TFLite models, WASM runtimes) from `node_modules` into the `denoise/` directory during build.
- **CI-Friendly:** The copy logic now includes `console.warn` fallbacks for missing assets to prevent build failures in environments where `npm install` hasn't yet finished, facilitating robust CI/CD integration.
- **Self-Hosting:** All assets are explicitly served from the `/denoise/` path, ensuring full privacy and avoiding external CDN dependencies at runtime.
## 3. UI & UX Improvements
### 3.1 Settings & Tuning (`General.tsx`)
- **Capability Detection:** Created `lotusDenoiseUtils.ts` to verify support for `AudioContext` and `AudioWorklet`. The ML option is programmatically disabled in unsupported browsers (e.g., Safari/Mobile) with a clear requirement list.
- **Comparison Chart:** Added a UI table listing `Model`, `CPU Usage`, `Quality`, and `Transient Handling` to allow users to make informed decisions based on their hardware.
- **Live Tuning:** Added a `MicMeter` component using an `AnalyserNode` to provide real-time visual feedback, enabling users to calibrate the **Noise Gate Threshold** (-100dB to 0dB) precisely to their microphone's noise floor.
### 3.2 Error Reporting
- **Inter-Iframe Comms:** The shim now reports status and failures to the parent `LotusChat` host via `window.parent.postMessage`.
- **System Toasts:** Added `LotusDenoiseFeature` in `ClientNonUIFeatures.tsx`. It listens for these events and triggers a non-intrusive system toast if the noise suppression falls back to raw mic, ensuring users know their microphone status.
## 4. Technical Debt & Safety
- **Settings Persistence:** Added strongly-typed settings fields for `callDenoiseModel`, `callDenoiseNativeNS`, `callDenoiseGate`, and `callDenoiseGateThreshold` to `settings.ts`.
- **Clean Teardown:** Improved `cleanup()` logic in `lotus-denoise.js` to ensure the `AudioContext` and `MediaStreamTracks` are properly released, preventing potential memory leaks or microphone "hanging" after calls.
## Testing Instructions for Senior Engineer
1. **Calibration:** Go to Settings, enable ML NS, toggle on Noise Gate, and click "Test Microphone". Confirm the meter reflects real-time audio.
2. **Validation:** Test "Series Suppression ON" vs "OFF" with a fan running in the background to confirm native NS is effectively handling the stationary noise.
3. **Fallback Test:** Introduce a malformed model request (via devtools console) to verify the System Toast notification functions.
+433 -58
View File
@@ -5,6 +5,28 @@
---
## 🏗️ Infrastructure & Maintenance
- [x] **Upgrade Synapse to v1.155.0** ✅ Done 2026-06-18
- **Context:** 1.155.0 is the last version supporting Debian 12 Bookworm. LXC 151 is already on Debian 13 Trixie — OS migration was completed prior to this upgrade.
- **What changed (1.154→1.155):** No breaking changes, no config changes, no DB migrations. Bugfixes: to-device EDU size limiting, restricted room joins, sliding sync subscription response timing. Rust port of more internal classes (perf only).
- **MSC4452** (Preview URL capabilities) shipped in 1.154 — opt-in via `msc4452_enabled`, not enabled.
---
## 📱 Quick Feature Additions
- [x] **Full-Screen Camera Broadcasts** ⚠️ UNTESTED — verify in a real call
- **Context:** Element Call currently supports full-screening screenshares. We need to parity this functionality for camera broadcasts.
- **Goal:** Users should be able to toggle any camera feed to full-screen mode, similar to the existing screenshare full-screen implementation.
- **Implemented 2026-06-18:**
1. **Fullscreen button always shows** — removed `screenshare &&` gate in `CallControls.tsx`. The fullscreen button is now available in camera-only calls, not just during screenshares.
2. **Per-participant camera focus**`CallControl.focusCameraParticipant(userId)` added. Finds the participant's video tile via `[data-testid="videoTile"]` / `[data-video-fit]` + `[aria-label="${userId}"]`, enables spotlight mode, then clicks the tile to focus them.
3. **MemberGlance "Focus camera" action** — clicking a participant avatar in the call status bar now opens a mini popup with "Focus camera" (triggers focusCameraParticipant) and "View profile" options, rather than immediately opening the profile.
4. **PiP fullscreen button** — a small fullscreen toggle button (⛶/⊡) is shown in the PiP overlay top-right, allowing users to go fullscreen directly from PiP mode without navigating back to the call room.
---
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
@@ -35,37 +57,37 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
## Server Capabilities (as of June 2026)
- **Homeserver:** `matrix.lotusguild.org`
- **Synapse version:** `1.153.0` (2026-05-19) — fully up to date
- **Synapse version:** `1.155.0` (2026-06-18) — fully up to date; last version for Debian 12 (LXC 151 already on Debian 13 Trixie)
- **Matrix spec:** up to `v1.12` formally; newer MSC features via `unstable_features`
### Confirmed facts
| Finding | Impact |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` | All safe to use now |
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
| **MSC3266** room summary: returns 404 | Room Preview feature BLOCKED |
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
| **MSC4260** report user: server at v1.12, endpoint may not exist | Report User feature BLOCKED |
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
| Cindy CANNOT inject audio into EC call stream | In-call soundboard must be redesigned as local-only |
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
| Finding | Impact |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** ✅ | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
| Cindy CANNOT inject audio into EC call stream | In-call soundboard must be redesigned as local-only |
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
---
@@ -114,17 +136,24 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
- Reading messages in the timeline (screen reader announces new messages)
- Composing and sending a reply
- Opening and closing modals (focus trap, return focus)
- ARIA labels on all icon-only buttons
**Scope:** Do NOT attempt to make every corner of the app AA-compliant in one pass — focus on the golden path (open app → find room → read → reply → send).
**[AUDIT REQUIRED]** — Run an automated audit first: `npx axe-core` or browser DevTools accessibility tree. Document every violation before writing a single line of code. Prioritize by severity (critical > serious > moderate).
**Complexity:** Medium-High (audit is the main work).
- ARIA labels on all icon-only buttons
**Scope:** Do NOT attempt to make every corner of the app AA-compliant in one pass — focus on the golden path (open app → find room → read → reply → send).
**[AUDIT REQUIRED]** — Run an automated audit first: `npx axe-core` or browser DevTools accessibility tree. Document every violation before writing a single line of code. Prioritize by severity (critical > serious > moderate).
**Investigation Findings:**
- **Root Cause:** Inconsistent focus management, missing `aria-live` regions for dynamic timeline updates, and sparse global keyboard shortcuts.
- **Approach:** Standardize `focus-trap-react` usage (reference `RoomNavItem.tsx`). Add `aria-live` regions to the timeline. Expand `useKeyDown.ts` for section navigation shortcuts.
- **Complexity:** Medium-High (audit is the main work).
---
### [ ] P3-8 · Thread Panel (full side drawer)
**⚠️ LARGEST FEATURE — requires its own planning session before implementation.**
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
Features:
- Click "Reply in Thread" → opens thread drawer on the right
@@ -132,20 +161,56 @@ Features:
- Full message rendering for all in-thread replies (reuse timeline components)
- Reply input at the bottom (full composer with formatting, emoji, etc.)
- Unread count badge on the thread button in the main timeline
- Keyboard shortcut to close thread panel
**Architecture:**
- Keyboard shortcut to close thread panel
**Architecture:**
- New Jotai atom: `activeThreadEventId: string | null`
- New component: `src/app/features/room/thread/ThreadPanel.tsx`
- Rendered alongside `RoomView` as a conditional right panel (mirror the members drawer pattern)
- Filter events in timeline to `m.thread` relation for the active root event ID
- Shares the same `mx` client and room reference as the main timeline
**[AUDIT REQUIRED]** — Deeply audit how `m.thread` relation events are currently stored and retrieved in the matrix-js-sdk. Understand the thread aggregation API: `GET /rooms/{roomId}/relations/{eventId}/m.thread`. Check if `RoomTimeline.tsx` currently filters out thread replies from the main timeline (it should — confirm).
**Complexity:** High.
- Shares the same `mx` client and room reference as the main timeline
**[AUDIT REQUIRED]** — Deeply audit how `m.thread` relation events are currently stored and retrieved in the matrix-js-sdk. Understand the thread aggregation API: `GET /rooms/{roomId}/relations/{eventId}/m.thread`. Check if `RoomTimeline.tsx` currently filters out thread replies from the main timeline (it should — confirm).
**Investigation Findings:**
- **Root Cause:** Current `m.thread` events are treated as standard `m.room.message` events and rendered in the main timeline.
- **Approach:** Introduce new Jotai atom `activeThreadEventId`. Create `ThreadPanel.tsx`. Update `RoomTimeline.tsx` to filter out thread relations (`m.relates_to`). Implement aggregation fetch using `GET /rooms/{roomId}/relations/{eventId}/m.thread`. Use `thread.timelineSet` directly for the most accurate thread view.
- **Complexity:** High.
---
## Priority 4 — Specialized, high complexity, or low priority
### [ ] P4-7 · Virtualized Infinite Scroll for Search Results
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
**Approach:** Utilize `@tanstack/react-virtual` in `MessageSearch.tsx` to handle the `nextToken` automatically as the user scrolls.
### [ ] P4-8 · Encrypted Message Search Indexing & Caching
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
### [x] P4-9 · Advanced Search Filter UI — PARTIALLY DONE (UNTESTED)
**What:** Improve search filter UX in `SearchFilters.tsx`.
**Completed 2026-06-18:**
- ✅ `SelectSenderButton` — picker UI for sender filter (previously required typing `from:@user` by hand)
- ✅ `DateRangeButton` — quick-pick presets: Today / Last week / Last month / Last year
- ✅ `Has link` chip — `contains_url: true` filter, wired to Matrix API and URL param
**UNTESTED** — needs verification at chat.lotusguild.org.
**Remaining for parity with Discord/Slack:**
- [ ] `has:image` / `has:file` / `has:video` — msgtype filters (require client-side post-filtering, no server API)
- [ ] Pinned messages filter
- [ ] Saved searches / search history
---
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
@@ -169,7 +234,7 @@ Features:
**Spec:** CS-API §11.5 (stable) — `formatted_body` can contain LaTeX.
**What:** Render `$...$` or `$$...$$` LaTeX expressions in message bodies. Use KaTeX (lightweight, ~100KB, renders server-side-compatible CSS). Must gracefully fall back to raw LaTeX text if KaTeX fails.
**Note:** This is LOW PRIORITY — only useful for academic/technical communities. Implement last.
**[AUDIT REQUIRED]** — Confirm KaTeX bundle size impact on the Vite bundle. Check if matrix-js-sdk's HTML sanitizer strips LaTeX before it reaches the renderer. The formatted_body sanitization pipeline is the main risk here.
**[AUDIT REQUIRED]** — Confirm KaTeX bundle size impact on the Vite bundle. Check if matrix-js-sdk's HTML sanitizer strips LaTeX before it reaches the renderer. The formatted_body sanitization pipeline is the main risk here. (Confirmed: sanitizer STRIPS `<math>` tags — must be patched alongside the renderer.)
**Complexity:** Low-Medium.
---
@@ -201,23 +266,25 @@ Features:
**What:** A hex/HSL color picker in Settings → Appearance. Chosen color replaces the primary accent throughout the UI: buttons, badges, active states, highlights, presence dot, links. Applied via a CSS custom property override injected into `<head>`.
**IMPORTANT:** This feature is completely inactive when TDS is enabled — TDS has its own fixed palette. Add this setting under a "Non-TDS Themes" section that is hidden when TDS is active.
**[AUDIT REQUIRED]** Identify all CSS custom properties that constitute the "accent color" in non-TDS mode. Map them to the folds/vanilla-extract token names.
**[AUDIT REQUIRED]** Identify all CSS custom properties that constitute the "accent color" in non-TDS mode. Map them to the folds/vanilla-extract token names. (Confirmed: folds uses vanilla-extract, NOT CSS custom properties — must create a new vanilla-extract theme variant dynamically.)
**Complexity:** Medium.
---
### [ ] P5-2 · Additional Color Theme Presets
**What:** 5 new one-click theme presets alongside TDS. Each must be a complete, polished system with proper contrast ratios (WCAG AA). All implemented as vanilla-extract themes matching the existing TDS pattern.
**What:** 5 new one-click theme presets alongside TDS. Each must be a complete, polished system with proper contrast ratios (WCAG AA). All implemented as vanilla-extract themes matching the existing TDS pattern.
Themes:
1. **Cyberpunk** — deep navy bg (`#0a0015`), electric purple (`#bf5fff`) + hot pink (`#ff2d9b`) accents, neon glow
2. **Ocean** — deep sea blue bg (`#020b18`), teal (`#00c9b1`) + aqua (`#0096d6`) accents, soft feel
3. **Blood Red** — near-black bg (`#0d0203`), deep crimson (`#7a0010`) + bright red (`#ff2233`) accents
4. **Classic Matrix** — pure black bg (`#000000`), phosphor green (`#00ff41`) text + accents
5. **Midnight** — dark charcoal (`#111827`), cool blue-grey (`#6b7ca8`) accents, clean minimal
**[AUDIT REQUIRED]** Study `src/lotus-terminal.css.ts` for the full token list before designing themes. All tokens must be covered.
**Complexity:** Medium (design effort is the main cost).
5. **Midnight** — dark charcoal (`#111827`), cool blue-grey (`#6b7ca8`) accents, clean minimal
**[AUDIT REQUIRED]** Study `src/lotus-terminal.css.ts` for the full token list before designing themes. All tokens must be covered (~50 CSS custom properties each).
**Complexity:** Medium (design effort is the main cost).
---
@@ -227,6 +294,19 @@ Themes:
---
### [x] P5-5 · Intersection-Based Lazy Loading ⚠️ UNTESTED — needs verification in timeline with many images
**What:** Use `IntersectionObserver` to trigger media decryption and loading only when components approach the viewport.
**Approach:** Reduce initial memory footprint and improve timeline load times by deferring decryption of images/videos until they are visible.
### [x] P5-6 · Context-Aware Thumbnail Previews ⚠️ UNTESTED
**What:** Enhance thumbnail rendering in the timeline for consistent, polished aesthetics.
**Approach:** Use CSS `object-fit: cover` with improved focal-point centering within `ThumbnailContent` to prevent media stretching or awkward aspect-ratio cropping.
**Fix Applied:** Added `objectPosition: 'center top'` to: (1) `media.css.ts``Image` component (timeline images), (2) video thumbnail inline style in `RenderMessageContent.tsx`, (3) `GalleryTile` `<img>` in `MediaGallery.tsx`. Full-size viewers retain `objectFit: 'contain'` — no change. `objectPosition: 'center top'` prevents face/subject cropping on tall portrait images capped at 600px by `AttachmentBox`.
---
### [ ] P5-15 · In-Call Soundboard
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
@@ -235,20 +315,28 @@ Themes:
---
### [ ] P5-20 · Quick Reply from Browser Notification
### [~] P5-20 · Quick Reply from Browser Notification
**What:** Inline reply field in browser notification toasts via Notification Actions API. Reply sends as threaded reply to the triggering message.
**[AUDIT REQUIRED]** (1) Verify browser Notification Actions API support in target browsers. (2) This requires a Service Worker to handle the reply event — confirm if Lotus Chat has one or needs one.
**Complexity:** Medium-High.
**[AUDIT REQUIRED]** (1) Verify browser Notification Actions API support in target browsers. (2) Confirmed: service worker EXISTS at `src/sw.ts` — add `notificationclick` handler there.
**Complexity:** Medium-High.
**Partial Fix Applied ⚠️ UNTESTED:** Notifications now (a) show the real message body (`username: message` instead of "New inbox notification from..."), (b) click navigates directly to the room at the specific event (not the inbox), (c) `window.focus()` called on click so the tab comes to front, (d) reminder toasts also link to the specific event. Full inline-reply via Notification Actions API still needs the SW `push`+`notificationclick` pipeline (requires switching from `new Notification()` to `showNotification()` through the SW).
---
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
**Shipped:** 3-tier setting (Off / Browser-native / ML beta) in Settings → General → Calls. ML tier injects a same-origin pre-init shim into the vendored Element Call `index.html` that monkeypatches `getUserMedia` and routes the captured mic through an RNNoise `AudioWorklet` (`@sapphi-red/web-noise-suppressor`) before LiveKit publishes — no EC fork required. See LOTUS_FEATURES.md → "Noise Suppression (3-Tier)".
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls. ML tier injects a same-origin pre-init shim into the vendored Element Call `index.html` that monkeypatches `getUserMedia` and routes the captured mic through an RNNoise `AudioWorklet` before LiveKit publishes — no EC fork required. See LOTUS_FEATURES.md → "Noise Suppression (Advanced Multi-Tier)".
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. The shim is the same post-capture pipeline #3892 uses, executed from the realm we control, so it survives EC version bumps.
**AEC note (resolved-as-accepted):** WebAudio capture routing can weaken browser AEC (AEC runs on the native track) — same tradeoff as EC's upstream feature; mitigated by keeping `echoCancellation`/`autoGainControl` on the raw capture and labeling the tier "beta". Validate echo quality on real multi-party calls after deploy.
**AEC note (resolved-as-accepted):** WebAudio capture routing can weaken browser AEC — same tradeoff as EC's upstream feature; mitigated by keeping `echoCancellation`/`autoGainControl` on the raw capture and labeling the tier "beta".
**Model Roadmap (priority order):**
- [ ] **Verify DTLN** (16 kHz narrowband fix) in a real call before investing further — wired but unverified.
- [ ] **DeepFilterNet 3** — best self-hostable upgrade: Rust→WASM, CPU real-time, 48 kHz fullband. Effort: self-host `df_bg.wasm` + DFN3 ONNX model, wire a 48 kHz worklet.
- [ ] **Desktop-only / HW-gated:** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in Tauri Rust backend + bridge a virtual mic into the webview. Must detect capability and only offer on supported hardware; web falls back to RNNoise.
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
---
@@ -279,15 +367,103 @@ Themes:
---
### [ ] P5-40 · Desktop — Proactive Update Notifications (Tauri)
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) ⚠️ UNTESTED (requires Tauri build)
**What:** Automatically check for app updates on launch and periodically during long sessions. If an update is available, show an in-app toast or badge (e.g. on the Settings icon) to alert the user without requiring them to manual check in settings.
**What:** Automatically check for app updates on launch and periodically during long sessions. If an update is available, show an in-app toast or badge (e.g., on the Settings icon) to alert the user without requiring a manual check in settings.
**Mechanism:** Use the `useTauriUpdater` hook in a global component like `ClientNonUIFeatures.tsx`.
**Note:** Ensure the check is throttled (e.g. once every 12 hours) to avoid redundant Tauri commands.
**Note:** Ensure the check is throttled (e.g., once every 12 hours) to avoid redundant Tauri commands.
**Complexity:** Low-Medium.
---
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications
**What:** Replace emulated notifications with native WinRT Toast notifications.
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
### [ ] P5-42 · Desktop — Persistent Background Sync
**What:** Maintain light connection to homeserver when WebView2 is suspended.
**Approach:** Implement a headless Rust sidecar to fetch unread counts/notifications while the webview is suspended to ensure instant notification delivery.
### [ ] P5-43 · Desktop — System Media Transport Controls (SMTC)
**What:** Integrate with Windows SMTC for volume flyout call/media control.
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
### [ ] P5-44 · Desktop — Taskbar Thumbnail Toolbar
**What:** Add persistent call controls to the taskbar preview.
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
### [ ] P5-46 · Desktop — System Power Management (Call Continuity)
**What:** Prevent system sleep/hibernate during active calls.
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
### [ ] P5-47 · Desktop — TDS-Styled Native Window Chrome
**What:** Replace system titlebar with custom Lotus TDS chrome.
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
### [ ] P5-48 · Desktop — Native File System Drag-and-Drop Improvements
**What:** Enhance drag-and-drop support for Windows.
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
### [ ] P5-49 · Desktop — Network Awareness (NCSI Integration)
**What:** Proactively detect Windows network connectivity changes.
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
### [ ] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
**What:** Replace standard browser decoding with native Windows Media Foundation.
**Approach:** Leverage DirectShow/Media Foundation to offload video/audio decoding from the CPU to the GPU, significantly reducing power consumption and latency during calls.
### [ ] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts."
**Approach:** Implement a zero-leak boundary for personas (e.g., Work vs. Personal) by isolating `IndexedDB`, filesystem caches, and session persistence per context.
**Priority:** Extreme Low (Multi-sprint/Architectural).
### [~] P5-52 · Desktop — Room-Level Sync Governor (Performance Control) [STILL_CONSIDERING]
**What:** Granular sync tuning for individual rooms.
**Approach:** Allow per-room overrides for sync frequency and event type filtering (e.g., disable read receipts/typing in heavy rooms) to optimize performance. Implementation requires careful UX to prevent complexity fatigue.
### [ ] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
**Approach:** Implement a WASM-based execution engine that allows users to write local-only, client-side scripts to interact with incoming Matrix events, trigger sounds/notifications, or inject custom UI elements based on event payload rules. Designed for privacy — all logic runs exclusively on the local machine.
### [ ] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering
**What:** Allow users to reorder toolbar icons via drag-and-drop.
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
### [ ] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync
**What:** Automatically toggle notification state based on Windows Focus Assist.
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
### [ ] P5-57 · Desktop — Visual Draft Persistence Indicator
---
## 🚀 Features to Add
- [ ] **Mobile Audit:** Comprehensive audit of all features in LOTUS_FEATURES.md for mobile PWA usability and layout responsiveness.
- [x] **Remind Me Later:** Slack-style reminders for messages — fully implemented ⚠️ UNTESTED end-to-end
- **Storage:** `useReminders.ts` — persists to `io.lotus.reminders` account data with `addReminder` / `removeReminder` / `getReminders`
- **UI:** `RemindMeDialog.tsx` — 4 presets (20 min, 1 hr, 3 hr, tomorrow 9am); wired into `Message.tsx` context menu via `remindOpen` state; `useModalStyle(320)` for mobile fullscreen
- **Monitor:** `ReminderMonitor` in `ClientNonUIFeatures.tsx` — polls every 30s + on tab visibility; fires Lotus toast when due and calls `removeReminder`
- [x] **Mobile Bookmarks:** Fixed ⚠️ UNTESTED — bookmarks now accessible from within any room on mobile
- **Root Cause:** `BookmarksPanel` renders correctly on mobile but `BookmarksTab` lives in `SidebarNav`, which is hidden when inside a room on mobile (`MobileFriendlyClientNav` returns `null`). No trigger existed.
- **Fix:** Added "Saved Messages" `MenuItem` to the `RoomMenu` (···More Options) in `RoomViewHeader.tsx`. Toggles `bookmarksPanelAtom` and closes the menu. Works on all screen sizes — desktop users see it as a duplicate of the sidebar star, mobile users now have their only in-room access point.
---
## Blocked Features
These features are confirmed desirable but cannot be built until the listed dependency is resolved.
@@ -308,9 +484,9 @@ Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `
### [BLOCKED] · Room Preview Before Joining (MSC3266)
**Blocked by:** `GET /v1/rooms/{id}/summary` returns 404 — endpoint not available on this server
**Blocked by:** `GET /_matrix/client/v1/rooms/{roomId}/summary` returns `M_UNRECOGNIZED` 404 — endpoint not implemented in Synapse 1.155. Config flag `msc3266_enabled: true` is set but has no effect; Synapse appears not to have shipped a stable implementation at the v1 path. Verified 2026-06-18.
**What it would do:** Show room name, topic, avatar, member count before joining.
**Action when unblocked:** Build pre-join preview card; trigger on unjoined room navigation.
**Action when unblocked:** Re-test after each future Synapse upgrade.
### [BLOCKED] · Thread Subscriptions (MSC4306)
@@ -318,12 +494,12 @@ Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `
**What it would do:** Follow a thread without posting; get notifications for replies.
**Action when unblocked:** Add "Follow thread" button in the thread panel header (depends on #P3-8 Thread Panel).
### [BLOCKED] · Report User (MSC4260)
### [DONE] · Report User (MSC4260)
**Blocked by:** Server declares only spec v1.12; MSC4260 merged in v1.14 — endpoint may not exist
**What it would do:** Report a specific user to homeserver admins (separate from reporting a message).
**Note:** Report Message already exists in upstream Cinny. This would add Report User to the profile panel.
**Action when unblocked:** Test `POST /_matrix/client/v3/users/{userId}/report`; if 200, add button to user profile.
**Previously blocked by:** Server spec v1.12, but `POST /_matrix/client/v3/users/{userId}/report` was confirmed **200** on 2026-06-18 (live since Synapse 1.133.0).
**What it does:** Reports a specific user to homeserver admins (separate from reporting a message).
**Note:** Report Message already exists in upstream Cinny. This adds Report User to the profile panel.
**Implemented 2026-06-18:** `ReportUserModal.tsx` added at `src/app/features/room/ReportUserModal.tsx`. Button wired into `UserRoomProfile.tsx` between UserModeration and UserDeviceSessions (hidden for own profile). Category dropdown + reason text, inline success/error feedback, auto-close 1500ms after success.
---
@@ -335,6 +511,205 @@ Research whether Matrix spec or MSC4133 (v1.16) defines a standard profile banne
---
## 📚 Implementation Reference
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
### P3-8 · Thread Panel (Full Side Drawer)
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
- **State (`src/app/state/room/thread.ts`):**
```typescript
export const activeThreadIdAtom = atom<string | null>(null);
```
- **Layout (`src/app/features/room/Room.tsx`):** Insert `ThreadPanel` conditionally alongside `RoomTimeline`:
```tsx
{
activeThreadId && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<ThreadPanel roomId={roomId} threadId={activeThreadId} />
</>
);
}
```
- **Component (`src/app/features/room/thread/ThreadPanel.tsx`):** Use `room.getThread(threadId)` from the SDK. Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`. Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`. Use `thread.timelineSet` directly for the most accurate thread view.
---
### P4-4 · Math / LaTeX Rendering
**Mechanism:** KaTeX injection into the HTML parser.
- **Sanitizer (`src/app/utils/sanitize.ts`):** Allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
- **Parser (`src/app/plugins/react-custom-html-parser.tsx`):** Detect `$ ... $` and `$$ ... $$` patterns in text nodes:
```tsx
if (node.type === 'text') {
const parts = node.data.split(/(\$\$.*?\$\$|\$.*?\$)/g);
return parts.map((p) => {
if (p.startsWith('$')) return <KaTeX math={p.replace(/\$/g, '')} />;
return p;
});
}
```
- **CSS (`src/app/styles/CustomHtml.css.ts`):** Import `katex/dist/katex.min.css` only when a math block is rendered to save initial bundle size.
---
### P4-6 · OIDC / SSO Next-Gen Auth (MSC3861)
**Mechanism:** Matrix Authentication Service (MAS) Integration.
- **Architecture:** Shift from password-based `/login` to OAuth2 `authorization_code` flow.
- **Key Files:** `src/app/pages/auth/Login.tsx` and `src/app/hooks/useAuth.ts`.
- **Implementation:** Use `oidc-client-ts` or a similar lightweight OIDC library. Check for `m.authentication` in `/.well-known/matrix/client`. Redirect to the MAS authorization endpoint. Handle the callback in a new `OidcCallback` route and store the OIDC `refresh_token`.
---
### P5-1 · Custom Accent Color Picker (Non-TDS only)
**Mechanism:** Dynamic CSS variable injection.
- **Setting (`src/app/state/settings.ts`):** Add `customAccentColor: string` (hex).
- **Manager (`src/app/pages/ThemeManager.tsx`):** Inside the `useEffect` that monitors theme changes:
```typescript
if (!lotusTerminal && customAccentColor) {
document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor);
document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`);
}
```
- **UI (`src/app/features/settings/general/General.tsx`):** Use `<Input type="color">`. Hide this section if `lotusTerminal` is `true`.
---
### P5-15 · In-Call Soundboard
**Mechanism:** Local-to-Global Audio Bridge via Web Audio API.
- Create an `AudioContext` and a `MediaStreamDestinationNode`.
- Create an `AudioBufferSourceNode` for each clip.
- Route the mic `MediaStream` and the clip source to the destination node.
- Pass the destination's `.stream` to the call bridge.
---
### P5-20 · Quick Reply from Browser Notification
**Mechanism:** Service Worker `notificationclick` Action.
```typescript
// src/sw.ts
self.addEventListener('notificationclick', (event) => {
if (event.action === 'reply' && event.reply) {
const { roomId, threadId } = event.notification.data;
const session = sessions.get(event.clientId);
fetch(`${session.baseUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({
msgtype: 'm.text',
body: event.reply,
'm.relates_to': threadId ? { rel_type: 'm.thread', event_id: threadId } : undefined,
}),
});
}
});
```
---
### P5-30 · Advanced ML Noise Suppression — Model Roadmap
See shipped implementation in LOTUS_FEATURES.md → "Noise Suppression (Advanced Multi-Tier)".
**Models status:**
- **RNNoise** (sapphi, 48 kHz) — ✅ working, default fallback. Keep — runs on any hardware.
- **Speex** (sapphi, 48 kHz) — ✅ working, low value; candidate to drop.
- **DTLN** (@workadventure, 16 kHz) — 🟡 wired; sample-rate fix applied (was robotic at 48 kHz). **TODO: verify in a real call.** Narrowband (16 kHz) = slightly telephone-y even when correct.
**Constraints:** client-side AudioWorklet, fully self-hosted, no GPU, self-hosted SFU (no LiveKit Cloud).
**Roadmap:**
- [ ] Verify DTLN 16 kHz fix in a real call.
- [ ] **DeepFilterNet 3** — best self-hostable upgrade: Rust→WASM, CPU real-time, 48 kHz fullband. Self-host `df_bg.wasm` + DFN3 ONNX model; wire a 48 kHz worklet. Audio quality unverifiable without a real-call test.
- [ ] **Desktop-only / HW-gated:** FRCRN (Alibaba) or NVIDIA Maxine (RTX/Tensor only). Runs in Tauri Rust backend + bridges a virtual mic into the webview. Must detect capability; web + weak HW falls back to RNNoise/DTLN.
---
### P5-31 · Granular Voice & Screenshare Quality Controls
**Mechanism:** WebRTC Encoding Parameters + Backend Quality Guard.
- **State Event:** `io.lotus.room_quality` (state key `""`) containing:
```json
{ "audio_bitrate": 128000, "screen_max_res": "1080p", "screen_max_fps": 60 }
```
- **Screenshare:** In `src/app/plugins/call/CallControl.ts`, map the "Quality" setting to `getDisplayMedia` constraints.
- **Audio Bitrate:** After the call joins, find the `RTCRtpSender` for the audio track:
```typescript
const sender = peerConnection.getSenders().find((s) => s.track?.kind === 'audio');
const params = sender.getParameters();
params.encodings[0].maxBitrate = roomBitrate || 128000;
await sender.setParameters(params);
```
- **Backend Sidecar:** Extend `voice-limit-guard.py` (LXC 151) to fetch `io.lotus.room_quality` and inject limits into the LiveKit JWT or return them as an authorized config packet.
---
### P5-40 · Desktop — Proactive Update Notifications (Tauri)
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
1. Create a `TauriUpdateFeature` component. Use `useTauriUpdater()` to get the `check` function and `status`.
2. In a `useEffect`, call `check()` on mount and then on a `setInterval` (every 12 hours).
3. When status transitions to `{ state: 'available', version: '...' }`, fire a Lotus Toast: "Lotus Chat v[version] is available!" with an "Update" button that calls `install()`.
4. Store `lastCheck` timestamp in `localStorage` to prevent redundant checks on refresh.
---
### Mobile Bookmarks Visibility Fix
**Issue:** `ClientLayout.tsx` explicitly restricts `BookmarksPanel` to `ScreenSize.Desktop` (lines 51-56).
```tsx
// ClientLayout.tsx
{
bookmarksOpen && (
<BookmarksPanel
onClose={() => setBookmarksOpen(false)}
isMobile={screenSize !== ScreenSize.Desktop}
/>
);
}
```
`BookmarksPanel.tsx` already supports the `isMobile` prop (line 127) to enable full-screen absolute positioning. No other changes required.
---
### Remind Me Later (Slack-style)
**Mechanism:** Account Data + Timer/Service Worker.
- **Storage (`src/app/hooks/useReminders.ts`):** Store in account data `io.lotus.reminders` as `Array<{ id: string, roomId: string, eventId: string, timestamp: number }>`.
- **Context Menu (`src/app/features/room/message/MessageContextMenu.tsx`):** Add "Remind me" option → opens date/time picker modal (reuse `JumpToTime.tsx` logic).
- **Trigger (foreground):** `setTimeout` in a hook inside `ReminderMonitor` in `ClientNonUIFeatures.tsx` → pushes to `toastQueueAtom` in `state/toast.ts` when due.
- **Trigger (background):** Use Service Worker — `setTimeout` in the main thread will not fire when the PWA is suspended.
---
### Mobile Usability Audit — Methodology
1. **Viewport & Touch:** All interactive elements must have at least `44px × 44px` touch targets. Audit for horizontal overflow (horizontal scrolling must be disabled).
2. **Modal Responsiveness:** All modals (Settings, Profile, etc.) MUST cover the full screen on mobile, not float as overlays.
3. **Sidebar / Panels:** On mobile, sidebar panels (Members, Bookmarks, Media) must become full-screen overlays (using a `Drawer` or `Modal` pattern) rather than side-by-side flexbox panels.
4. **Input & Composer:** Ensure the composer doesn't get obscured by the mobile keyboard. Test focus trap and blur behaviors.
---
## Implementation Notes
### ⚠️ TDS DESIGN LAW (repeated here for emphasis)
-214
View File
@@ -1,214 +0,0 @@
# Lotus Chat — Technical Implementation Field Guide
**Date:** June 2026
This document provides exhaustive, low-level implementation details for the remaining items in `LOTUS_TODO.md`. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
---
## 🧵 Priority 3 — Higher Complexity
### P3-8 · Thread Panel (Full Side Drawer)
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
- **1. State (src/app/state/room/thread.ts):**
```typescript
export const activeThreadIdAtom = atom<string | null>(null);
```
- **2. Layout (src/app/features/room/Room.tsx):**
Insert the `ThreadPanel` conditionally alongside the `RoomTimeline`.
```tsx
{
activeThreadId && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<ThreadPanel roomId={roomId} threadId={activeThreadId} />
</>
);
}
```
- **3. Component (src/app/features/room/thread/ThreadPanel.tsx):**
- Use `room.getThread(threadId)` from the SDK.
- Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`.
- Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`.
- **Pro Tip:** Use `thread.timelineSet` directly for the most accurate thread view.
---
## 🛠️ Priority 4 — Specialized Features
### P4-4 · Math / LaTeX Rendering
**Mechanism:** KaTeX injection into the HTML parser.
- **1. Sanitizer (src/app/utils/sanitize.ts):**
You must allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
- **2. Parser (src/app/plugins/react-custom-html-parser.tsx):**
Detect `$ ... $` and `$$ ... $$` patterns in text nodes.
```tsx
if (node.type === 'text') {
const parts = node.data.split(/(\$\$.*?\$\$|\$.*?\$)/g);
return parts.map((p) => {
if (p.startsWith('$')) return <KaTeX math={p.replace(/\$/g, '')} />;
return p;
});
}
```
- **3. CSS (src/app/styles/CustomHtml.css.ts):**
Import `katex/dist/katex.min.css` only when a math block is rendered to save initial bundle size.
### P4-6 · OIDC / SSO Next-Gen Auth (MSC3861)
**Mechanism:** Matrix Authentication Service (MAS) Integration.
- **Architecture:** Shift from password-based `/login` to OAuth2 `authorization_code` flow.
- **Key Files:** `src/app/pages/auth/Login.tsx` and `src/app/hooks/useAuth.ts`.
- **Implementation:**
1. Use `oidc-client-ts` or a similar lightweight OIDC library.
2. Check for `m.authentication` in `/.well-known/matrix/client`.
3. Redirect to the MAS authorization endpoint.
4. Handle the callback in a new `OidcCallback` route and store the OIDC `refresh_token`.
---
## 🎨 Priority 5 — Gamer / Aesthetic / Customization
### P5-1 · Custom Accent Color Picker (Non-TDS only)
**Mechanism:** Dynamic CSS variable injection.
- **1. Setting (src/app/state/settings.ts):**
Add `customAccentColor: string` (hex).
- **2. Manager (src/app/pages/ThemeManager.tsx):**
Inside the `useEffect` that monitors theme changes:
```typescript
if (!lotusTerminal && customAccentColor) {
document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor);
// Also derive a 'glow' version (e.g. 50% opacity)
document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`);
}
```
- **3. UI (src/app/features/settings/general/General.tsx):**
Use a `<Input type="color">` component. Hide this section if `lotusTerminal` is `true`.
### P5-40 · Desktop — Proactive Update Notifications (Tauri)
**Mechanism:** Global Background Check via `useTauriUpdater`.
- **Objective:** Alert users to app updates without requiring a manual check in settings.
- **Key Files:**
- `src/app/hooks/useTauriUpdater.ts`: Logic source.
- `src/app/pages/client/ClientNonUIFeatures.tsx`: Background mounting point.
- `src/app/features/toast/LotusToastContainer.tsx`: UI for notification.
- **Implementation:**
1. Create a `TauriUpdateFeature` component.
2. Use `useTauriUpdater()` to get the `check` function and `status`.
3. In a `useEffect`, call `check()` on mount and then on a `setInterval` (e.g., every 12 hours).
4. Watch the `status`. When it transitions to `{ state: 'available', version: '...' }`, trigger an in-app **Lotus Toast**.
5. The toast should say "Lotus Chat v[version] is available!" with an "Update" button that calls the `install()` function from the hook.
6. **Persistence:** Store the `lastCheck` timestamp in `localStorage` to ensure the background check doesn't fire redundant commands every time the user refreshes or re-opens the app.
---
## 🔊 Audio & Communications
### P5-15 · In-Call Soundboard
**Mechanism:** Local-to-Global Audio Bridge.
- **Architecture:** Use the `Web Audio API` to mix sounds into the `MediaStream` before it enters the Element Call widget.
- **Implementation:**
1. Create an `AudioContext`.
2. Create a `MediaStreamDestinationNode`.
3. Create an `AudioBufferSourceNode` for the clip.
4. Route the mic `MediaStream` and the clip source to the destination.
5. Pass the destination's `.stream` to the call bridge.
### P5-20 · Quick Reply from Browser Notification
**Mechanism:** Service Worker `notificationclick` Action.
- **1. Registration (src/sw.ts):**
```typescript
self.addEventListener('notificationclick', (event) => {
if (event.action === 'reply' && event.reply) {
const { roomId, threadId } = event.notification.data;
const session = sessions.get(event.clientId); // Uses existing session mapping
// Send via direct fetch to bypass SDK loading
fetch(`${session.baseUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({
msgtype: 'm.text',
body: event.reply,
'm.relates_to': threadId ? { rel_type: 'm.thread', event_id: threadId } : undefined,
}),
});
}
});
```
---
## 🔬 Extreme Complexity Projects
### P5-30 · Advanced ML Noise Suppression (Krisp-style)
**Mechanism:** RNNoise WASM + Web Audio Worklet Pipeline.
- **Objective:** Filter non-vocal noise from the microphone stream in real-time.
- **Architecture:**
1. **Engine:** Use `RNNoise` (Recurrent Neural Network for noise suppression). It is lightweight and highly effective for speech.
2. **Pipeline:** `Mic Stream` -> `AudioWorkletNode` (Processing) -> `MediaStreamDestination` -> `Element Call`.
- **Implementation Steps:**
1. **WASM Wrapper:** Compile the `RNNoise` C library to WebAssembly. Use a library like `rnnoise-wasm` or `noise-suppression-js`.
2. **Audio Worklet:** Create `src/app/utils/audio/RnnoiseWorklet.ts`. This must handle 480-sample chunks (10ms of audio at 48kHz), which is the standard frame size for RNNoise.
3. **Client Integration:**
- In `CallControl.ts`, intercept the `localStream`.
- Pass the stream through the Worklet.
- Crucially, you must ensure that the processed stream is used by the `RTCPeerConnection` within the Element Call iframe.
### P5-31 · Granular Voice & Screenshare Quality Controls
**Mechanism:** WebRTC Encoding Parameters + Backend Quality Guard.
- **Objective:** Per-room and per-user control over audio fidelity and screenshare smoothness.
- **Architecture:**
1. **State Event:** `io.lotus.room_quality` (state key `""`) containing:
```json
{
"audio_bitrate": 128000,
"screen_max_res": "1080p",
"screen_max_fps": 60
}
```
2. **Client-Side (RoomInput / CallControl):**
- **Screenshare:** In `src/app/plugins/call/CallControl.ts`, when initiating screenshare, map the "Quality" setting to `getDisplayMedia` constraints:
```typescript
const constraints = {
video: {
width: { ideal: 1920 }, // 1080p
frameRate: { ideal: 60 },
},
};
```
- **Audio Bitrate:** After the call joins, find the `RTCRtpSender` for the audio track and update parameters:
```typescript
const sender = peerConnection.getSenders().find((s) => s.track?.kind === 'audio');
const params = sender.getParameters();
params.encodings[0].maxBitrate = roomBitrate || 128000;
await sender.setParameters(params);
```
3. **Backend Sidecar (The "Quality Guard"):**
- **Pattern:** Extend the `voice-limit-guard.py` (on LXC 151) to handle quality metadata.
- **Mechanism:** When a user requests a LiveKit JWT to join a room, the Guard fetches the `io.lotus.room_quality` event for that room via the Synapse Admin API.
- **Enforcement:** The Guard injects these limits into the LiveKit token claims (if supported) or simply returns them to the client as an authorized "config" packet that the client must respect.
- **Challenges:**
- **LiveKit Compatibility:** Ensuring the SFU doesn't over-compress a high-bitrate stream from a "Pro" user.
- **Network Stability:** High bitrates (512kbps audio + 60fps 1080p video) require significant upstream bandwidth. Implement a "Network Warning" UI if packets are dropped.
Binary file not shown.
+42 -1
View File
@@ -35,9 +35,13 @@
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
var ASSET_BASE = './denoise/';
var SAMPLE_RATE = 48000;
var MODEL = params.get('lotusModel') || 'rnnoise';
// DTLN (@workadventure) targets 16 kHz and does not resample internally, so
// its whole graph runs in a 16 kHz context; RNNoise/Speex (sapphi) and
// DeepFilterNet 3 are 48 kHz fullband. The processed MediaStreamTrack is
// published to LiveKit either way (WebRTC/Opus resamples as needed).
var SAMPLE_RATE = MODEL === 'dtln' ? 16000 : 48000;
var USE_NATIVE_NS = params.get('lotusNativeNS') === 'true';
var USE_GATE = params.get('lotusGate') === 'true';
var GATE_THRESHOLD = parseFloat(params.get('lotusGateThreshold') || '-45');
@@ -61,6 +65,15 @@
// node, rather than addModule-ing a flat worklet ourselves.
helper: 'workadventure/audio-worklet.js',
},
deepfilternet: {
// deepfilternet3-noise-filter ships an ESM whose AudioWorklet processor +
// wasm-bindgen glue are INLINED as a string (loaded via a Blob URL — no
// CDN for the worklet). The only assets it fetches are its single-threaded
// df_bg.wasm + ONNX model, which we vendor + self-host under
// deepfilternet/v2/... We dynamic-import the ESM, build a DeepFilterNet3Core
// pointed at the self-hosted base, and let it create the worklet node.
esm: 'deepfilternet/index.esm.js',
},
gate: {
name: '@sapphi-red/web-noise-suppressor/noise-gate',
script: 'noiseGateWorklet.js',
@@ -160,6 +173,34 @@
return mod.createNoiseSuppressionAudioWorklet(ctx, { bypassUntilReady: true });
});
}
if (MODEL === 'deepfilternet') {
// Resolve an absolute self-hosted base so the package's cdnUrl override
// fetches our vendored df_bg.wasm + ONNX model (never the upstream CDN).
var dfnBase = new URL(ASSET_BASE + 'deepfilternet', window.location.href).href;
return import(ASSET_BASE + PROCESSORS.deepfilternet.esm).then(function (mod) {
var core = new mod.DeepFilterNet3Core({
sampleRate: SAMPLE_RATE,
noiseReductionLevel: 80,
assetConfig: { cdnUrl: dfnBase },
});
// initialize() fetches + compiles the wasm and loads the model on the
// main thread; the worklet node only exists once that resolves, so the
// graph is connected with a ready model (no half-initialised passthrough).
return core.initialize().then(function () {
return core.createAudioWorkletNode(ctx).then(function (node) {
return {
node: node,
ready: Promise.resolve(),
dispose: function () {
try {
core.destroy();
} catch (e) {}
},
};
});
});
});
}
var node = new AudioWorkletNode(ctx, PROCESSORS[MODEL].name, {
channelCount: 1,
numberOfInputs: 1,
+13
View File
@@ -35,6 +35,7 @@
"classnames": "2.5.1",
"dateformat": "5.0.3",
"dayjs": "1.11.20",
"deepfilternet3-noise-filter": "1.2.1",
"domhandler": "6.0.1",
"dompurify": "3.4.5",
"emojibase": "17.0.0",
@@ -6399,6 +6400,18 @@
"integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==",
"dev": true
},
"node_modules/deepfilternet3-noise-filter": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/deepfilternet3-noise-filter/-/deepfilternet3-noise-filter-1.2.1.tgz",
"integrity": "sha512-OAyrHTDlUHH+AhfpVNKYEOhVqb9cZpu0fdNThplA/tB/Ts4PF/UsI+abl2n1IbSxUkhiF0OqDejEhk1n42Oqpw==",
"license": "(Apache-2.0 OR MIT)",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"livekit-client": "^2.0.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+1
View File
@@ -59,6 +59,7 @@
"classnames": "2.5.1",
"dateformat": "5.0.3",
"dayjs": "1.11.20",
"deepfilternet3-noise-filter": "1.2.1",
"domhandler": "6.0.1",
"dompurify": "3.4.5",
"emojibase": "17.0.0",
+120 -41
View File
@@ -104,6 +104,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
const { room } = info;
const audioRef = useRef<HTMLAudioElement>(null);
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room, dm);
@@ -126,8 +127,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play().catch(() => undefined);
}, []);
if (!audioElement) return;
audioElement.volume = Math.max(0, Math.min(1, ringtoneVolume / 100));
audioElement.play().catch(() => undefined);
}, [ringtoneVolume]);
useEffect(() => {
const audioEl = audioRef.current;
@@ -416,34 +419,66 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
return null;
}
/** Shown inside the PiP window when the local microphone is muted. */
/**
* PiP status indicators:
* - Bottom-left badge: local mic muted (matches Discord/Slack convention bottom-left = "your" mic)
* - Top-right badge: all remote participants are muted (quiet room warning)
*
* Deliberately separated so users never mistake remote-mute state for their own.
*/
function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
const allMuted = useRemoteAllMuted(callEmbed);
if (!allMuted) return null;
const mx = useMatrixClient();
const controlState = useCallControlState(callEmbed.control);
const allRemoteMuted = useRemoteAllMuted(callEmbed);
const localMicMuted = !controlState.microphone;
const localUserId = mx.getSafeUserId();
const localDisplayName = getMxIdLocalPart(localUserId) ?? localUserId;
// Dark translucent scrim is intentional: these badges overlay arbitrary
// video, so a theme surface token would not guarantee legibility.
const badgeStyle: React.CSSProperties = {
position: 'absolute',
zIndex: 3,
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
borderRadius: config.radii.R300,
padding: `${config.space.S100} ${config.space.S200}`,
display: 'flex',
alignItems: 'center',
gap: config.space.S100,
pointerEvents: 'none',
lineHeight: 1,
userSelect: 'none',
};
return (
<div
aria-label="Microphone muted"
style={{
position: 'absolute',
bottom: '8px',
left: '8px',
zIndex: 3,
background: 'rgba(0,0,0,0.60)',
backdropFilter: 'blur(4px)',
borderRadius: '6px',
padding: '3px 7px',
display: 'flex',
alignItems: 'center',
gap: '4px',
pointerEvents: 'none',
color: color.Critical.Main,
fontSize: '13px',
lineHeight: 1,
userSelect: 'none',
}}
>
<Icon size="100" src={Icons.MicMute} filled />
</div>
<>
{localMicMuted && (
<div
aria-label={`Your microphone is muted (${localDisplayName})`}
title="Your microphone is muted"
style={{ ...badgeStyle, bottom: config.space.S200, left: config.space.S200 }}
>
<Icon size="100" src={Icons.MicMute} filled style={{ color: color.Critical.Main }} />
<Text as="span" size="T200" style={{ color: color.Critical.Main }}>
You
</Text>
</div>
)}
{allRemoteMuted && (
<div
aria-label="All other participants are muted"
title="All other participants are muted"
style={{ ...badgeStyle, top: config.space.S200, right: config.space.S200 }}
>
<Icon size="50" src={Icons.MicMute} style={{ color: color.Warning.Main }} />
<Text as="span" size="T200" style={{ color: color.Warning.Main }}>
All muted
</Text>
</div>
)}
</>
);
}
@@ -499,6 +534,21 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
[chatBackground, isDark],
);
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
useEffect(() => {
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
const handlePipFullscreen = useCallback(() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
callEmbedRef.current?.requestFullscreen();
}
}, [callEmbedRef]);
const pipDragRef = React.useRef<{
startX: number;
startY: number;
@@ -880,19 +930,48 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
padding: '6px',
}}
>
<div
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
borderRadius: '6px',
padding: '3px 8px',
color: '#fff',
fontSize: '11px',
fontWeight: 600,
pointerEvents: 'none',
}}
>
Return to call
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
{document.fullscreenEnabled && (
<button
type="button"
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
title={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
onClick={(e) => {
e.stopPropagation();
handlePipFullscreen();
}}
style={{
// Dark scrim is intentional for legibility over arbitrary video.
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
border: 'none',
borderRadius: config.radii.R300,
padding: `${config.space.S100} ${config.space.S200}`,
color: '#fff',
fontSize: '13px',
cursor: 'pointer',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
}}
>
{pipIsFullscreen ? '⊡' : '⛶'}
</button>
)}
<div
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
borderRadius: config.radii.R300,
padding: `${config.space.S100} ${config.space.S200}`,
color: '#fff',
fontSize: '11px',
fontWeight: 600,
pointerEvents: 'none',
}}
>
Return to call
</div>
</div>
</div>
<PipMuteOverlay callEmbed={callEmbed} />
+3 -1
View File
@@ -30,6 +30,7 @@ import {
} from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css';
import { useModalStyle } from '../hooks/useModalStyle';
const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -232,6 +233,7 @@ type DeviceVerificationProps = {
};
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
const phase = useVerificationRequestPhase(request);
const modalStyle = useModalStyle(480);
const handleCancel = useCallback(() => {
if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) {
@@ -255,7 +257,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
escapeDeactivates: false,
}}
>
<Dialog variant="Surface">
<Dialog variant="Surface" style={modalStyle}>
<Header style={DialogHeaderStyles} variant="Surface" size="500">
<Box grow="Yes">
<Text as="h2" size="H4">
@@ -16,6 +16,7 @@ import {
import FileSaver from 'file-saver';
import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
import { useModalStyle } from '../hooks/useModalStyle';
import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css';
import { copyToClipboard } from '../utils/dom';
@@ -287,9 +288,10 @@ type DeviceVerificationSetupProps = {
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
({ onCancel }, ref) => {
const [recoveryKey, setRecoveryKey] = useState<string>();
const modalStyle = useModalStyle(480);
return (
<Dialog ref={ref}>
<Dialog ref={ref} style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -324,9 +326,10 @@ type DeviceVerificationResetProps = {
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
({ onCancel }, ref) => {
const [reset, setReset] = useState(false);
const modalStyle = useModalStyle(480);
return (
<Dialog ref={ref}>
<Dialog ref={ref} style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+6 -5
View File
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import FocusTrap from 'focus-trap-react';
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
import { IGif } from '@giphy/js-types';
import { Box } from 'folds';
import { Box, color, config } from 'folds';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
@@ -91,11 +91,11 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
width: `${PICKER_WIDTH}px`,
}
: {
background: 'var(--bg-surface)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '12px',
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R400,
overflow: 'hidden',
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
boxShadow: color.Other.Shadow,
width: `${PICKER_WIDTH}px`,
};
@@ -103,6 +103,7 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
allowOutsideClick: true,
+3 -1
View File
@@ -3,6 +3,7 @@ import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { logoutClient } from '../../client/initMatrix';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useModalStyle } from '../hooks/useModalStyle';
import { useCrossSigningActive } from '../hooks/useCrossSigning';
import { InfoCard } from './info-card';
import {
@@ -16,6 +17,7 @@ type LogoutDialogProps = {
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
({ handleClose }, ref) => {
const mx = useMatrixClient();
const modalStyle = useModalStyle(480);
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
const crossSigningActive = useCrossSigningActive();
const verificationStatus = useDeviceVerificationStatus(
@@ -33,7 +35,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
return (
<Dialog variant="Surface" ref={ref}>
<Dialog variant="Surface" ref={ref} style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+21 -1
View File
@@ -2,12 +2,14 @@ import React, { ReactNode } from 'react';
import FocusTrap from 'focus-trap-react';
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import { stopPropagation } from '../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
type Modal500Props = {
requestClose: () => void;
children: ReactNode;
};
export function Modal500({ requestClose, children }: Modal500Props) {
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -19,7 +21,25 @@ export function Modal500({ requestClose, children }: Modal500Props) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" variant="Background">
<Modal
size="500"
variant="Background"
// On mobile expand to fill the viewport. On desktop fall back to the
// folds `size="500"` width (~50rem) — overriding maxWidth here would
// squish the two-pane settings layout.
style={
isMobile
? {
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: 0,
overflow: 'hidden auto',
}
: undefined
}
>
{children}
</Modal>
</FocusTrap>
+12 -1
View File
@@ -232,7 +232,18 @@ export function RenderMessageContent({
<ThumbnailContent
info={info}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
<Image
alt={body}
title={body}
src={src}
loading="lazy"
style={{
objectFit: 'cover',
objectPosition: 'center top',
width: '100%',
height: '100%',
}}
/>
)}
/>
)
+31 -6
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Text, config, toRem } from 'folds';
import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
@@ -51,6 +51,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
const previewMimeRef = useRef('audio/ogg;codecs=opus');
const previewDurationRef = useRef(0);
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
const [previewPlaying, setPreviewPlaying] = useState(false);
const stopAll = useCallback(() => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
@@ -192,7 +194,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
alignItems="Center"
gap="200"
style={{
background: 'var(--bg-surface-variant)',
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
padding: `${toRem(4)} ${toRem(8)}`,
}}
@@ -203,7 +205,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
width: toRem(8),
height: toRem(8),
borderRadius: '50%',
background: lotusTerminal ? 'var(--lt-accent-orange)' : 'var(--tc-danger-normal)',
background: lotusTerminal ? 'var(--lt-accent-orange)' : color.Critical.Main,
flexShrink: 0,
animation: 'pttLivePulse 900ms ease-in-out infinite',
}}
@@ -237,7 +239,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
width: toRem(2),
height: toRem(2 + (h / barMax) * 16),
borderRadius: toRem(1),
background: lotusTerminal ? 'var(--lt-accent-green)' : 'var(--tc-primary-normal)',
background: lotusTerminal ? 'var(--lt-accent-green)' : color.Primary.Main,
flexShrink: 0,
}}
/>
@@ -273,13 +275,36 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
alignItems="Center"
gap="200"
style={{
background: 'var(--bg-surface-variant)',
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
padding: `${toRem(4)} ${toRem(8)}`,
}}
>
{previewUrl && (
<audio src={previewUrl} controls style={{ height: toRem(28), maxWidth: toRem(180) }} />
<>
<audio ref={previewAudioRef} src={previewUrl} onEnded={() => setPreviewPlaying(false)} />
<IconButton
onClick={() => {
const audio = previewAudioRef.current;
if (!audio) return;
if (previewPlaying) {
audio.pause();
setPreviewPlaying(false);
} else {
audio.play();
setPreviewPlaying(true);
}
}}
aria-label={previewPlaying ? 'Pause preview' : 'Play preview'}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
title={previewPlaying ? 'Pause' : 'Play'}
>
<Icon src={previewPlaying ? Icons.Pause : Icons.Play} size="100" />
</IconButton>
</>
)}
<Text size="T200" style={{ fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>
{formatDuration(previewDurationRef.current)}
+1
View File
@@ -252,6 +252,7 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
onClick={handleClick}
size="400"
radii="300"
aria-label="Exit formatting"
>
<Text size="B400">{`Exit ${KeySymbol.Hyper}`}</Text>
</IconButton>
@@ -67,12 +67,12 @@ export const EventReaders = as<'div', EventReadersProps>(
<Header
className={css.Header}
variant="Surface"
size="600"
size="500"
style={
lotusTerminal
? {
borderBottom: '1px solid rgba(0,212,255,0.30)',
boxShadow: '0 2px 12px rgba(0,212,255,0.08)',
borderBottomWidth: config.borderWidth.B300,
boxShadow: 'var(--lt-box-glow-cyan)',
}
: undefined
}
@@ -83,8 +83,8 @@ export const EventReaders = as<'div', EventReadersProps>(
style={
lotusTerminal
? {
color: '#00D4FF',
textShadow: '0 0 6px rgba(0,212,255,0.45)',
color: 'var(--lt-accent-cyan)',
textShadow: 'var(--lt-glow-cyan)',
letterSpacing: '0.05em',
}
: undefined
@@ -93,7 +93,7 @@ export const EventReaders = as<'div', EventReadersProps>(
Seen by
</Text>
</Box>
<IconButton size="300" onClick={requestClose} aria-label="Close">
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -141,14 +141,14 @@ export const EventReaders = as<'div', EventReadersProps>(
{receiptTs !== undefined && (
<Text
size="T200"
priority="300"
style={
lotusTerminal
? {
color: '#FFB300',
textShadow: '0 0 5px rgba(255,179,0,0.45)',
fontSize: '0.72rem',
color: 'var(--lt-accent-amber)',
textShadow: 'var(--lt-glow-amber)',
}
: { opacity: 0.6 }
: undefined
}
>
{formatReadTs(receiptTs, hour24Clock)}
@@ -51,6 +51,7 @@ import { useAlive } from '../../hooks/useAlive';
import { copyToClipboard } from '../../utils/dom';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers';
import { useModalStyle } from '../../hooks/useModalStyle';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@@ -66,6 +67,7 @@ type InviteUserProps = {
};
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(560);
const alive = useAlive();
const [linkCopied, setLinkCopied] = useState(false);
const [showQr, setShowQr] = useState(false);
@@ -184,7 +186,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
escapeDeactivates: stopPropagation,
}}
>
<Dialog>
<Dialog style={modalStyle}>
<Box grow="Yes" direction="Column">
<Header
size="500"
@@ -18,6 +18,7 @@ import {
} from 'folds';
import { stopPropagation } from '../../utils/keyboard';
import { isRoomAlias, isRoomId } from '../../utils/matrix';
import { useModalStyle } from '../../hooks/useModalStyle';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom';
@@ -26,6 +27,7 @@ type JoinAddressProps = {
onCancel: () => void;
};
export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
const modalStyle = useModalStyle(480);
const [invalid, setInvalid] = useState(false);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@@ -71,7 +73,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog variant="Surface" style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
type LeaveRoomPromptProps = {
roomId: string;
@@ -28,6 +29,7 @@ type LeaveRoomPromptProps = {
};
export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(480);
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
useCallback(async () => {
@@ -56,7 +58,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" aria-labelledby="leave-room-dialog-title">
<Dialog variant="Surface" aria-labelledby="leave-room-dialog-title" style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
type LeaveSpacePromptProps = {
roomId: string;
@@ -28,6 +29,7 @@ type LeaveSpacePromptProps = {
};
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(480);
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
useCallback(async () => {
@@ -56,7 +58,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog variant="Surface" style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+1
View File
@@ -5,6 +5,7 @@ export const Image = style([
DefaultReset,
{
objectFit: 'cover',
objectPosition: 'center top',
width: '100%',
height: '100%',
},
+20 -13
View File
@@ -1,5 +1,5 @@
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
import { Box, Button, Chip, Icon, Icons, Text, color, toRem } from 'folds';
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
import { IContent } from 'matrix-js-sdk';
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
import { trimReplyFromBody } from '../../utils/room';
@@ -94,15 +94,21 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
)}
</div>
{needsCollapse && (
<Button
size="300"
variant="Secondary"
fill="None"
style={{ marginTop: '4px' }}
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
style={{
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
marginTop: config.space.S100,
}}
>
<Text size="B300">{collapsed ? 'Read more ↓' : 'Show less ↑'}</Text>
</Button>
<Text as="span" size="T200" style={{ color: color.Primary.Main }}>
{collapsed ? 'Read more ↓' : 'Show less ↑'}
</Text>
</button>
)}
</div>
);
@@ -529,21 +535,22 @@ export function MLocation({ content }: MLocationProps) {
loading="lazy"
sandbox="allow-scripts"
/>
<Text size="T300" style={{ opacity: 0.65 }}>
<Text size="T300" priority="300">
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
</Text>
<Chip
<Button
as="a"
size="400"
href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
target="_blank"
rel="noreferrer noopener"
variant="Primary"
radii="Pill"
variant="Secondary"
fill="Solid"
radii="300"
before={<Icon src={Icons.External} size="50" />}
>
<Text size="B300">Open Location</Text>
</Chip>
</Button>
</Box>
);
}
+36 -28
View File
@@ -15,34 +15,42 @@ export const Reaction = as<
reaction: string;
useAuthentication?: boolean;
}
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
{...props}
ref={ref}
>
<Text className={css.ReactionText} as="span" size="T400">
{reaction.startsWith('mxc://') ? (
<img
className={css.ReactionImg}
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction}
alt={reaction}
/>
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
</Text>
)}
</Text>
<Text as="span" size="T300">
{count}
</Text>
</Box>
));
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
const shortcode = reaction.startsWith('mxc://')
? 'custom emoji'
: (getShortcodeFor(getHexcodeForEmoji(reaction)) ?? reaction);
const label = `${shortcode} reaction, ${count} ${count === 1 ? 'person' : 'people'}`;
return (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
aria-label={label}
{...props}
ref={ref}
>
<Text className={css.ReactionText} as="span" size="T400">
{reaction.startsWith('mxc://') ? (
<img
className={css.ReactionImg}
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction}
alt={reaction}
/>
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
</Text>
)}
</Text>
<Text as="span" size="T300">
{count}
</Text>
</Box>
);
});
type ReactionTooltipMsgProps = {
room: Room;
+7 -1
View File
@@ -103,10 +103,16 @@ export const Reply = as<'div', ReplyProps>(
return (
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
<ThreadIndicator
as="button"
data-event-id={threadRootId}
onClick={onClick}
aria-label="View thread"
/>
)}
<ReplyLayout
as="button"
aria-label="Jump to original message"
userColor={usernameColor}
username={
sender && (
@@ -182,8 +182,8 @@ export function AudioContent({
<Chip
onClick={handleSpeedClick}
variant="SurfaceVariant"
radii="Pill"
variant="Secondary"
radii="300"
aria-label={`Playback speed: ${playbackSpeed}×`}
>
<Text size="B300">{`${playbackSpeed}×`}</Text>
@@ -75,6 +75,7 @@ export const MessageEditedContent = as<
<button
type="button"
onClick={onEditHistoryClick}
aria-label="View edit history"
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
>
<Text as="span" size="T200" priority="300">
@@ -1,4 +1,4 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import {
Badge,
Box,
@@ -31,6 +31,7 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
import { useNearViewport } from '../../../hooks/useNearViewport';
type RenderViewerProps = {
src: string;
@@ -85,6 +86,9 @@ export const ImageContent = as<'div', ImageContentProps>(
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const sentinelRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(sentinelRef);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
@@ -113,11 +117,12 @@ export const ImageContent = as<'div', ImageContentProps>(
};
useEffect(() => {
if (autoPlay) loadSrc().catch(() => undefined);
}, [autoPlay, loadSrc]);
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
}, [autoPlay, nearViewport, loadSrc]);
return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
{srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -1,4 +1,4 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import {
Badge,
Box,
@@ -32,6 +32,7 @@ import {
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
import { useNearViewport } from '../../../hooks/useNearViewport';
type RenderVideoProps = {
title: string;
@@ -79,6 +80,9 @@ export const VideoContent = as<'div', VideoContentProps>(
const [error, setError] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const sentinelRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(sentinelRef);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
@@ -106,11 +110,12 @@ export const VideoContent = as<'div', VideoContentProps>(
};
useEffect(() => {
if (autoPlay) loadSrc().catch(() => undefined);
}, [autoPlay, loadSrc]);
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
}, [autoPlay, nearViewport, loadSrc]);
return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
{typeof blurHash === 'string' && !load && (
<BlurhashCanvas
style={{ width: '100%', height: '100%' }}
@@ -110,10 +110,15 @@ export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
// ── Mention pulse animation ───────────────────────────────────────────────────
// Animates only `box-shadow` — NOT `transform`. A self-sent @mention message
// carries both this class and `MsgAppearClass` (which animates a scale), and two
// animations on the same element cannot share the `transform` property: the
// later one wins and the other is silently dropped. Pulsing the glow alone keeps
// both effects working. (The previous scale(1.003) was imperceptible anyway.)
const mentionPulseKeyframes = keyframes({
'0%': { transform: 'scale(1)', boxShadow: 'none' },
'30%': { transform: 'scale(1.003)', boxShadow: `0 0 8px ${color.Warning.Main}` },
'100%': { transform: 'scale(1)', boxShadow: 'none' },
'0%': { boxShadow: 'none' },
'30%': { boxShadow: `0 0 8px ${color.Warning.Main}` },
'100%': { boxShadow: 'none' },
});
/**
+6
View File
@@ -7,9 +7,15 @@ export const PageNav = recipe({
size: {
'400': {
width: toRem(256),
'@media': {
'(max-width: 750px)': { width: '100%' },
},
},
'300': {
width: toRem(222),
'@media': {
'(max-width: 750px)': { width: '100%' },
},
},
},
},
@@ -0,0 +1,24 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
// Hover/focus emphasis driven by CSS rather than JS style mutation, matching
// how every other interactive element in the app handles hover state.
export const ReceiptTrigger = style({
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
marginLeft: 'auto',
marginTop: config.space.S100,
display: 'flex',
alignItems: 'center',
gap: config.space.S100,
opacity: config.opacity.P500,
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
selectors: {
'&:hover, &:focus-visible': {
opacity: 1,
transform: 'scale(1.04)',
},
},
});
@@ -1,6 +1,16 @@
import React, { useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { Icon, Icons, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, color } from 'folds';
import {
Icon,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
color,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/settings';
@@ -12,6 +22,8 @@ import { UserAvatar } from '../user-avatar';
import { StackedAvatar } from '../stacked-avatar';
import { EventReaders } from '../event-readers';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
import * as css from './ReadReceiptAvatars.css';
const MAX_DISPLAY = 5;
@@ -28,6 +40,7 @@ export function ReadReceiptAvatars({
const useAuthentication = useMediaAuthentication();
const [open, setOpen] = useState(false);
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const modalStyle = useModalStyle(360);
if (userIds.length === 0) return null;
@@ -51,7 +64,7 @@ export function ReadReceiptAvatars({
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<Modal variant="Surface" size="300" style={modalStyle}>
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
</Modal>
</FocusTrap>
@@ -62,28 +75,7 @@ export function ReadReceiptAvatars({
onClick={() => setOpen(true)}
title={tooltipNames}
aria-label={tooltipNames}
className="receipt-pill-btn"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
marginLeft: 'auto',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
opacity: 0.85,
transition: 'opacity 0.15s, transform 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1';
e.currentTarget.style.transform = 'scale(1.04)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.85';
e.currentTarget.style.transform = 'scale(1)';
}}
className={css.ReceiptTrigger}
>
{/* Pill wrapper ensures visibility on any wallpaper/background */}
<span
@@ -93,10 +85,12 @@ export function ReadReceiptAvatars({
backgroundColor: lotusTerminal
? 'rgba(0,212,255,0.07)'
: color.SurfaceVariant.Container,
border: lotusTerminal ? '1px solid rgba(0,212,255,0.30)' : '1px solid transparent',
border: lotusTerminal
? `${config.borderWidth.B300} solid rgba(0,212,255,0.30)`
: `${config.borderWidth.B300} solid transparent`,
boxShadow: lotusTerminal ? '0 0 10px rgba(0,212,255,0.12)' : 'none',
borderRadius: '999px',
padding: '2px 6px',
borderRadius: config.radii.Pill,
padding: `${config.space.S100} ${config.space.S200}`,
gap: '0px',
}}
>
@@ -3,6 +3,7 @@ import parse from 'html-react-parser';
import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
import classNames from 'classnames';
import Linkify from 'linkify-react';
import { useModalStyle } from '../../hooks/useModalStyle';
import * as css from './style.css';
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { sanitizeCustomHtml } from '../../utils/sanitize';
@@ -17,6 +18,7 @@ export const RoomTopicViewer = as<
}
>(({ name, topic, requestClose, className, ...props }, ref) => {
const topicStr = typeof topic === 'string' ? topic : topic.topic;
const modalStyle = useModalStyle(480);
const isFormatted =
typeof topic !== 'string' &&
topic.format === 'org.matrix.custom.html' &&
@@ -28,6 +30,7 @@ export const RoomTopicViewer = as<
flexHeight
className={classNames(css.ModalFlex, className)}
aria-labelledby="room-topic-title"
style={modalStyle}
{...props}
ref={ref}
>
@@ -756,7 +756,9 @@ function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: bool
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 9999,
// Below the Night Light overlay (9998) so seasonal particles are tinted
// by it, and below modals (9999) so dialogs are never obscured.
zIndex: 9997,
overflow: 'hidden',
}}
>
@@ -797,5 +799,9 @@ export function SeasonalEffect() {
}, [settings.seasonalThemeOverride]);
if (!theme) return null;
// Suppress seasonal overlay when a chat background is active — both running simultaneously
// wastes GPU and looks cluttered. The settings UI enforces mutual exclusion on write;
// this guard covers any legacy state already persisted.
if (settings.chatBackground !== 'none') return null;
return <SeasonalOverlay theme={theme} reduced={reduced} />;
}
@@ -1,5 +1,17 @@
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { Box, Chip, Icon, IconButton, Icons, Switch, Text, color, config, toRem } from 'folds';
import {
Box,
Chip,
Icon,
IconButton,
Icons,
Input,
Switch,
Text,
color,
config,
toRem,
} from 'folds';
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -353,25 +365,18 @@ export function UploadCardRenderer({
)}
{(fileItem.originalFile.type.startsWith('image') ||
fileItem.originalFile.type.startsWith('video')) && (
<input
<Input
type="text"
placeholder="Add a caption… (optional)"
value={metadata.caption ?? ''}
onChange={(e) => setMetadata(fileItem, { ...metadata, caption: e.target.value })}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMetadata(fileItem, { ...metadata, caption: e.target.value })
}
data-caption-input
style={{
marginTop: '6px',
width: '100%',
background: 'var(--bg-surface-low)',
border: '1px solid var(--bg-surface-border)',
borderRadius: '6px',
padding: '5px 8px',
fontSize: '0.85rem',
color: 'var(--text-primary)',
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
variant="Secondary"
size="300"
radii="300"
style={{ marginTop: config.space.S200, width: '100%' }}
/>
)}
<CompressionCheckbox fileItem={fileItem} metadata={metadata} setMetadata={setMetadata} />
@@ -1,4 +1,4 @@
import { Box, Button, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
import { Box, Button, color, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
@@ -28,6 +28,7 @@ import { Membership } from '../../../types/matrix/room';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { ReportUserModal } from '../../features/room/ReportUserModal';
import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
@@ -88,7 +89,7 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
return (
<DeviceVerificationStatus crypto={crypto} userId={userId} deviceId={device.deviceId}>
{(status) => {
const color =
const deviceColor =
status === VerificationStatus.Verified
? 'var(--tc-positive-normal, #5effc4)'
: status === VerificationStatus.Unverified
@@ -96,7 +97,7 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
: 'var(--tc-surface-low-contrast)';
return (
<Box alignItems="Center" gap="200">
<Icon size="100" src={Icons.ShieldUser} style={{ color, flexShrink: 0 }} />
<Icon size="100" src={Icons.ShieldUser} style={{ color: deviceColor, flexShrink: 0 }} />
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{device.displayName ?? device.deviceId}
@@ -196,7 +197,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
style={{
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
borderTop: `${toRem(1)} solid var(--border-surface-variant)`,
borderTop: `${toRem(1)} solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
<UserDeviceRow userId={userId} device={device} />
@@ -236,11 +237,20 @@ function UserPrivateNotes({ userId }: { userId: string }) {
return (
<Box direction="Column" gap="200">
<Box justifyContent="SpaceBetween" alignItems="Center">
<Box justifyContent="SpaceBetween" alignItems="Center" gap="200">
<Text size="L400">Private Note</Text>
<Text size="T200" style={{ opacity: 0.5 }}>
{saving ? 'Saving…' : charsLeft < 100 ? `${charsLeft} left` : ''}
</Text>
{saving ? (
<Box alignItems="Center" gap="100" shrink="No">
<Spinner variant="Success" fill="Solid" size="100" />
<Text size="T200" priority="400">
Saving
</Text>
</Box>
) : (
<Text size="T200" priority="400">
{charsLeft < 100 ? `${charsLeft} left` : ''}
</Text>
)}
</Box>
<textarea
value={draft}
@@ -251,12 +261,11 @@ function UserPrivateNotes({ userId }: { userId: string }) {
style={{
width: '100%',
resize: 'vertical',
background: 'var(--bg-surface-variant)',
background: color.SurfaceVariant.Container,
color: 'inherit',
border: '1px solid var(--border-interactive)',
borderRadius: '6px',
padding: '8px 10px',
fontSize: '14px',
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `${config.space.S200} ${config.space.S300}`,
fontFamily: 'inherit',
lineHeight: 1.5,
boxSizing: 'border-box',
@@ -272,6 +281,7 @@ type UserRoomProfileProps = {
};
export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const mx = useMatrixClient();
const [reportUserOpen, setReportUserOpen] = useState(false);
const crossSigningActive = useCrossSigningActive();
const useAuthentication = useMediaAuthentication();
const navigate = useNavigate();
@@ -390,8 +400,25 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
canKick={canKickUser && membership === Membership.Join}
canBan={canBanUser && membership !== Membership.Ban}
/>
{userId !== myUserId && (
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S200}` }}>
<Button
variant="Critical"
fill="None"
size="300"
radii="300"
before={<Icon size="50" src={Icons.Warning} />}
onClick={() => setReportUserOpen(true)}
>
<Text size="B300">Report User</Text>
</Button>
</Box>
)}
{showEncryption && userId !== myUserId && <UserDeviceSessions userId={userId} />}
{userId !== myUserId && <UserPrivateNotes userId={userId} />}
{reportUserOpen && (
<ReportUserModal userId={userId} onClose={() => setReportUserOpen(false)} />
)}
</Box>
</Box>
);
@@ -54,6 +54,7 @@ import { StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { rateLimitedActions } from '../../utils/matrix';
import { useAlive } from '../../hooks/useAlive';
import { useModalStyle } from '../../hooks/useModalStyle';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 500,
@@ -72,6 +73,7 @@ type AddExistingModalProps = {
};
export function AddExistingModal({ parentId, space, requestClose }: AddExistingModalProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(480);
const useAuthentication = useMediaAuthentication();
const alive = useAlive();
@@ -188,7 +190,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300">
<Modal size="300" style={modalStyle}>
<Box grow="Yes" direction="Column">
<Header
size="500"
@@ -0,0 +1,42 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
// Mirrors MembersDrawer: a 266px side panel on desktop that becomes a
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
export const BookmarksPanel = style({
width: toRem(266),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 500,
},
},
});
export const BookmarksHeader = style({
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
});
export const BookmarksToolbar = style({
flexShrink: 0,
padding: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
});
export const BookmarksContent = style({
padding: config.space.S200,
});
export const BookmarkPreview = style({
width: '100%',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
textAlign: 'left',
});
+131 -163
View File
@@ -1,5 +1,6 @@
import React, { ChangeEvent, useState } from 'react';
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
import {
Avatar,
Box,
Button,
Header,
@@ -12,9 +13,17 @@ import {
color,
config,
} from 'folds';
import classNames from 'classnames';
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { RoomAvatar } from '../../components/room-avatar';
import { getRoomAvatarUrl } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './BookmarksPanel.css';
function formatTimeAgo(ts: number): string {
const diff = Date.now() - ts;
@@ -37,89 +46,67 @@ type BookmarkItemProps = {
function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
const mx = useMatrixClient();
const room = mx.getRoom(bookmark.roomId);
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined;
const displayRoomName = room?.name ?? bookmark.roomName;
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
return (
<Box
direction="Column"
gap="200"
style={{
padding: `${config.space.S300} ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
transition: 'background 0.1s',
padding: config.space.S300,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
{/* Room name row */}
<Box alignItems="Center" gap="100">
<Icon src={Icons.Hash} size="50" style={{ opacity: 0.5, flexShrink: 0 }} />
<Text
size="T200"
style={{
color: color.Primary.Main,
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
{/* Room identity + remove */}
<Box alignItems="Center" gap="200">
<Avatar size="200" radii="300">
<RoomAvatar
roomId={bookmark.roomId}
src={avatarUrl}
alt={displayRoomName}
renderFallback={() => <Text size="H6">{nameInitials(displayRoomName)}</Text>}
/>
</Avatar>
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
{displayRoomName}
</Text>
<Text size="T200" priority="300">
{formatTimeAgo(bookmark.savedAt)}
</Text>
</Box>
<IconButton
size="300"
variant="SurfaceVariant"
fill="None"
radii="300"
onClick={() => onRemove(bookmark.eventId)}
aria-label="Remove saved message"
>
{displayRoomName}
</Text>
<Icon size="100" src={Icons.Delete} />
</IconButton>
</Box>
{/* Message preview */}
<Box
style={{
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
borderLeft: `3px solid ${color.Primary.Main}`,
padding: `${config.space.S100} ${config.space.S200}`,
}}
{/* Message preview — clicking jumps to the message */}
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
onClick={() => onJump(bookmark.roomId, bookmark.eventId)}
aria-label="Jump to saved message"
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
>
<Text
size="T300"
style={{
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
opacity: 0.9,
}}
>
<Text className={css.BookmarkPreview} size="T200" priority="400">
{bookmark.previewText || '(no preview)'}
</Text>
</Box>
{/* Footer row */}
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="T200" style={{ opacity: 0.5 }}>
{formatTimeAgo(bookmark.savedAt)}
</Text>
<Box gap="100" shrink="No">
<Button
size="300"
variant="Primary"
fill="Soft"
radii="300"
onClick={() => onJump(bookmark.roomId, bookmark.eventId)}
before={<Icon size="100" src={Icons.ArrowRight} />}
>
<Text size="T300">Jump</Text>
</Button>
<IconButton
size="300"
variant="Surface"
fill="None"
radii="300"
onClick={() => onRemove(bookmark.eventId)}
aria-label="Remove bookmark"
>
<Icon size="100" src={Icons.Delete} />
</IconButton>
</Box>
</Box>
</Button>
</Box>
);
}
@@ -133,86 +120,76 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
const { navigateRoom } = useRoomNavigate();
const [filter, setFilter] = useState('');
const handleJump = (roomId: string, eventId: string) => {
navigateRoom(roomId, eventId);
onClose();
};
// Escape closes the panel (parity with the app's other overlays/drawers).
useEffect(() => {
const handleKeyDown = (evt: KeyboardEvent) => {
if (evt.key === 'Escape') {
stopPropagation(evt);
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleJump = useCallback(
(roomId: string, eventId: string) => {
navigateRoom(roomId, eventId);
onClose();
},
[navigateRoom, onClose],
);
const handleFilterChange = (e: ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
};
const query = filter.trim().toLowerCase();
const filtered: Bookmark[] =
filter.trim().length === 0
query.length === 0
? bookmarks
: bookmarks.filter((bk) => {
const q = filter.toLowerCase();
return bk.previewText.toLowerCase().includes(q) || bk.roomName.toLowerCase().includes(q);
});
: bookmarks.filter(
(bk) =>
bk.previewText.toLowerCase().includes(query) ||
bk.roomName.toLowerCase().includes(query),
);
return (
<Box
className={classNames(css.BookmarksPanel, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
style={{
width: '266px',
height: '100%',
flexShrink: 0,
borderLeft: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<Header
style={{
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
variant="Surface"
size="600"
>
<Header className={css.BookmarksHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Star} size="200" />
<Box grow="Yes">
<Text size="H5">Saved Messages</Text>
<Text size="H4" truncate>
Saved Messages
</Text>
</Box>
<IconButton
size="300"
variant="Surface"
radii="300"
aria-label="Close saved messages"
onClick={onClose}
>
<Icon src={Icons.Cross} size="100" />
<IconButton size="300" radii="300" aria-label="Close saved messages" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
{/* Search */}
<Box
style={{
flexShrink: 0,
padding: config.space.S200,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
<Box className={css.BookmarksToolbar} direction="Column" gap="100">
<Input
variant="Surface"
variant="SurfaceVariant"
size="400"
radii="400"
radii="300"
placeholder="Search saved messages…"
value={filter}
onChange={handleFilterChange}
before={<Icon size="200" src={Icons.Search} />}
before={<Icon size="100" src={Icons.Search} />}
after={
filter.length > 0 ? (
<IconButton
size="300"
variant="Surface"
variant="SurfaceVariant"
fill="None"
radii="300"
aria-label="Clear search"
onClick={() => setFilter('')}
@@ -222,56 +199,47 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
) : undefined
}
/>
</Box>
{/* Count badge */}
{bookmarks.length > 0 && (
<Box
style={{
flexShrink: 0,
padding: `${config.space.S100} ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
<Text size="T200" style={{ opacity: 0.6 }}>
{bookmarks.length > 0 && (
<Text size="T200" priority="300">
{filtered.length === bookmarks.length
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
: `${filtered.length} of ${bookmarks.length} messages`}
</Text>
</Box>
)}
)}
</Box>
{/* List */}
<Scroll variant="Background" size="300" style={{ flexGrow: 1, minHeight: 0 }}>
{filtered.length === 0 ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
style={{ padding: config.space.S600, textAlign: 'center' }}
>
<Icon size="600" src={Icons.Star} style={{ opacity: 0.3 }} />
<Text size="T300" priority="300" align="Center">
{bookmarks.length === 0
? 'No saved messages yet.\nRight-click any message to bookmark it.'
: 'No bookmarks match your search.'}
</Text>
</Box>
) : (
<Box direction="Column">
{filtered.map((bk) => (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
))}
</Box>
)}
</Scroll>
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
{filtered.length === 0 ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
style={{ padding: config.space.S700, textAlign: 'center' }}
>
<Icon size="600" src={Icons.Star} style={{ opacity: config.opacity.Disabled }} />
<Text size="T300" priority="300" align="Center">
{bookmarks.length === 0
? 'No saved messages yet. Right-click any message to save it.'
: 'No saved messages match your search.'}
</Text>
</Box>
) : (
<Box className={css.BookmarksContent} direction="Column" gap="200">
{filtered.map((bk) => (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
))}
</Box>
)}
</Scroll>
</Box>
</Box>
);
}
+6 -1
View File
@@ -63,7 +63,12 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
</Box>
{memberVisible && (
<Box shrink="No">
<MemberGlance room={room} members={callMembers} speakers={speakers} />
<MemberGlance
room={room}
members={callMembers}
speakers={speakers}
callEmbed={callEmbed}
/>
</Box>
)}
</Box>
+157 -47
View File
@@ -1,6 +1,7 @@
import { Box, config, Icon, Icons, Text } from 'folds';
import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React from 'react';
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import { UserAvatar } from '../../components/user-avatar';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
@@ -9,67 +10,176 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { StackedAvatar } from '../../components/stacked-avatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { getMouseEventCords } from '../../utils/dom';
import { stopPropagation } from '../../utils/keyboard';
import { CallEmbed } from '../../plugins/call/CallEmbed';
import * as css from './styles.css';
type ParticipantMenuProps = {
anchor: RectCords;
name: string;
userId: string;
room: Room;
callEmbed?: CallEmbed;
onClose: () => void;
profileCords: DOMRect;
};
function ParticipantMenu({
anchor,
name,
userId,
room,
callEmbed,
onClose,
profileCords,
}: ParticipantMenuProps) {
const openUserProfile = useOpenUserRoomProfile();
const handleViewProfile = () => {
onClose();
openUserProfile(room.roomId, undefined, userId, profileCords, 'Top');
};
const handleFocusCamera = () => {
onClose();
callEmbed?.control.focusCameraParticipant(userId);
};
return (
<PopOut
anchor={anchor}
align="Start"
position="Top"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu variant="Surface" style={{ minWidth: 160, padding: config.space.S100 }}>
<Box direction="Column">
<Text
size="L400"
style={{
padding: `${config.space.S100} ${config.space.S200}`,
opacity: 0.6,
}}
truncate
>
{name}
</Text>
{callEmbed && (
<MenuItem
size="300"
variant="Surface"
radii="300"
before={<Icon size="100" src={Icons.VideoCamera} />}
onClick={handleFocusCamera}
>
<Text size="B300">Focus camera</Text>
</MenuItem>
)}
<MenuItem
size="300"
variant="Surface"
radii="300"
before={<Icon size="100" src={Icons.User} />}
onClick={handleViewProfile}
>
<Text size="B300">View profile</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
>
{/* PopOut requires a JSX child even if we anchor externally */}
<span />
</PopOut>
);
}
type MemberGlanceProps = {
room: Room;
members: CallMembership[];
speakers: Set<string>;
callEmbed?: CallEmbed;
max?: number;
};
export function MemberGlance({ room, members, speakers, max = 6 }: MemberGlanceProps) {
export function MemberGlance({ room, members, speakers, callEmbed, max = 6 }: MemberGlanceProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openUserProfile = useOpenUserRoomProfile();
const [menuState, setMenuState] = useState<{
anchor: RectCords;
profileCords: DOMRect;
userId: string;
name: string;
} | null>(null);
const visibleMembers = members.slice(0, max);
const remainingCount = max && members.length > max ? members.length - max : 0;
return (
<Box alignItems="Center">
{visibleMembers.map((callMember) => {
const { userId } = callMember;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
: undefined;
<>
<Box alignItems="Center">
{visibleMembers.map((callMember) => {
const { userId } = callMember;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
: undefined;
return (
<StackedAvatar
key={callMember.memberId}
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
title={name}
as="button"
variant="Background"
size="200"
radii="Pill"
onClick={(evt) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Top',
)
}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
})}
{remainingCount > 0 && (
<Text size="L400" style={{ paddingLeft: config.space.S100 }}>
+{remainingCount}
</Text>
return (
<StackedAvatar
key={callMember.memberId}
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
title={name}
as="button"
variant="Background"
size="200"
radii="Pill"
onClick={(evt) => {
const rect = evt.currentTarget.getBoundingClientRect();
setMenuState({
anchor: rect,
profileCords: rect,
userId,
name,
});
}}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
})}
{remainingCount > 0 && (
<Text size="L400" style={{ paddingLeft: config.space.S100 }}>
+{remainingCount}
</Text>
)}
</Box>
{menuState && (
<ParticipantMenu
anchor={menuState.anchor}
profileCords={menuState.profileCords}
name={menuState.name}
userId={menuState.userId}
room={room}
callEmbed={callEmbed}
onClose={() => setMenuState(null)}
/>
)}
</Box>
</>
);
}
+16 -3
View File
@@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css';
import { keyframes, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const LiveChipText = style({
@@ -16,6 +16,19 @@ export const ControlDivider = style({
height: toRem(16),
});
export const SpeakerAvatarOutline = style({
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`,
const speakerPulse = keyframes({
'0%': { boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}` },
'50%': { boxShadow: `0 0 0 ${toRem(4)} ${color.Success.ContainerActive}` },
'100%': { boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}` },
});
export const SpeakerAvatarOutline = style({
'@media': {
'(prefers-reduced-motion: no-preference)': {
animation: `${speakerPulse} 1200ms ease-in-out infinite`,
},
'(prefers-reduced-motion: reduce)': {
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`,
},
},
});
+4 -4
View File
@@ -246,8 +246,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
top: '-2.5rem',
left: '50%',
transform: 'translateX(-50%)',
background: pttActive ? 'rgba(0,255,136,0.18)' : 'rgba(255,107,0,0.12)',
border: `1px solid ${pttActive ? 'rgba(0,255,136,0.55)' : 'rgba(255,107,0,0.35)'}`,
background: pttActive ? 'var(--lt-accent-green-dim)' : 'var(--lt-accent-orange-dim)',
border: `1px solid ${pttActive ? 'var(--lt-accent-green-border)' : 'var(--lt-accent-orange-border)'}`,
borderRadius: '99px',
padding: '0.2rem 0.9rem',
pointerEvents: 'none',
@@ -257,7 +257,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
<Text
size="T200"
style={{
color: pttActive ? '#00FF88' : '#FF6B00',
color: pttActive ? 'var(--lt-accent-green)' : 'var(--lt-accent-orange)',
fontWeight: 700,
letterSpacing: '0.08em',
fontFamily: 'JetBrains Mono, monospace',
@@ -387,7 +387,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
/>
{screenshare && !!document.fullscreenEnabled && (
{!!document.fullscreenEnabled && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
)}
</Box>
@@ -28,6 +28,7 @@ import { useRoom } from '../../../hooks/useRoom';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { stopPropagation } from '../../../utils/keyboard';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { useModalStyle } from '../../../hooks/useModalStyle';
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
@@ -37,6 +38,7 @@ type RoomEncryptionProps = {
export function RoomEncryption({ permissions }: RoomEncryptionProps) {
const mx = useMatrixClient();
const room = useRoom();
const modalStyle = useModalStyle(480);
const canEnable = permissions.stateEvent(StateEvent.RoomEncryption, mx.getSafeUserId());
const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{
@@ -111,7 +113,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog variant="Surface" style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -4,7 +4,6 @@ import {
Button,
Chip,
color,
config,
Icon,
IconButton,
Icons,
@@ -18,6 +17,7 @@ import {
import React, { FormEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import Linkify from 'linkify-react';
import parse from 'html-react-parser';
import classNames from 'classnames';
import { JoinRule, MatrixError } from 'matrix-js-sdk';
import { EmojiBoard } from '../../../components/emoji-board';
@@ -33,6 +33,7 @@ import {
import { mDirectAtom } from '../../../state/mDirectList';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
import { sanitizeCustomHtml } from '../../../utils/sanitize';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -84,7 +85,7 @@ function buildTopicContent(topic: string): Record<string, string> {
const formattedBody = topicMarkdownToHtml(topic);
// Use HTML-stripped text as the plain topic so the header shows clean text, not raw markdown syntax
const plainTopic = formattedBody.replace(/<br>/g, '\n').replace(/<[^>]+>/g, '');
// eslint-disable-next-line @typescript-eslint/naming-convention
return { topic: plainTopic, format: 'org.matrix.custom.html', formatted_body: formattedBody };
}
@@ -332,30 +333,30 @@ export function RoomProfileEdit({
{ label: '`', syntax: '`', placeholder: 'code', title: 'Inline Code' },
] as const
).map(({ label, syntax, placeholder, title }) => (
<button
<Button
key={label}
type="button"
title={title}
size="300"
radii="300"
variant="Secondary"
fill="Soft"
aria-label={title}
title={title}
onClick={() =>
topicRef.current && wrapSelection(topicRef.current, syntax, placeholder)
}
style={{
padding: `${config.space.S100} ${config.space.S200}`,
border: `1px solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R300,
background: color.Surface.Container,
color: color.Surface.OnContainer,
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: label === 'B' ? 700 : label === 'I' ? undefined : undefined,
fontStyle: label === 'I' ? 'italic' : undefined,
fontFamily: label === '`' ? 'monospace' : 'inherit',
lineHeight: 1,
}}
>
{label}
</button>
<Text
size="B300"
style={{
fontWeight: label === 'B' ? 700 : undefined,
fontStyle: label === 'I' ? 'italic' : undefined,
fontFamily: label === '`' ? 'monospace' : undefined,
}}
>
{label}
</Text>
</Button>
))}
</Box>
)}
@@ -456,7 +457,12 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
</Text>
{topic && (
<Text className={classNames(BreakWord, LineClamp3)} size="T200">
<Linkify options={LINKIFY_OPTS}>{topic.topic}</Linkify>
{topic.format === 'org.matrix.custom.html' &&
typeof topic.formatted_body === 'string' ? (
parse(sanitizeCustomHtml(topic.formatted_body))
) : (
<Linkify options={LINKIFY_OPTS}>{topic.topic}</Linkify>
)}
</Text>
)}
</Box>
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { Box, Button, config, Icon, Icons, Text } from 'folds';
import { Box, Button, color, config, Icon, Icons, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
@@ -12,6 +12,7 @@ export function RoomShareInvite() {
const mx = useMatrixClient();
const room = useRoom();
const [copied, setCopied] = useState(false);
const [qrError, setQrError] = useState(false);
const domain = mx.getDomain() ?? undefined;
const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined);
@@ -63,13 +64,35 @@ export function RoomShareInvite() {
</Box>
</Box>
<Box justifyContent="Center">
<img
src={qrSrc}
alt="QR code for room invite link"
width={160}
height={160}
style={{ display: 'block', borderRadius: config.radii.R300 }}
/>
{qrError ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100"
style={{
width: 160,
height: 160,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
<Icon size="400" src={Icons.Warning} />
<Text size="T200" priority="300" align="Center">
QR code unavailable
</Text>
</Box>
) : (
<img
src={qrSrc}
alt="QR code for room invite link"
width={160}
height={160}
loading="lazy"
onError={() => setQrError(true)}
style={{ display: 'block', borderRadius: config.radii.R300 }}
/>
)}
</Box>
</Box>
</CutoutCard>
@@ -39,12 +39,14 @@ import { useAlive } from '../../../hooks/useAlive';
import { creatorsSupported } from '../../../utils/matrix';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { BreakWord } from '../../../styles/Text.css';
import { useModalStyle } from '../../../hooks/useModalStyle';
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
const mx = useMatrixClient();
const room = useRoom();
const alive = useAlive();
const creators = useRoomCreators(room);
const modalStyle = useModalStyle(480);
const capabilities = useCapabilities();
const roomVersions = capabilities['m.room_versions'];
@@ -93,7 +95,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog variant="Surface" style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -24,6 +24,7 @@ import {
import { CreateRoomModalState } from '../../state/createRoomModal';
import { stopPropagation } from '../../utils/keyboard';
import { CreateRoomType } from '../../components/create-room/types';
import { useModalStyle } from '../../hooks/useModalStyle';
type CreateRoomModalProps = {
state: CreateRoomModalState;
@@ -31,6 +32,7 @@ type CreateRoomModalProps = {
function CreateRoomModal({ state }: CreateRoomModalProps) {
const { spaceId, type } = state;
const closeDialog = useCloseCreateRoomModal();
const modalStyle = useModalStyle(480);
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
@@ -48,7 +50,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" flexHeight>
<Modal size="300" flexHeight style={modalStyle}>
<Box direction="Column">
<Header
size="500"
@@ -23,6 +23,7 @@ import {
} from '../../state/hooks/createSpaceModal';
import { CreateSpaceModalState } from '../../state/createSpaceModal';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
type CreateSpaceModalProps = {
state: CreateSpaceModalState;
@@ -30,6 +31,7 @@ type CreateSpaceModalProps = {
function CreateSpaceModal({ state }: CreateSpaceModalProps) {
const { spaceId } = state;
const closeDialog = useCloseCreateSpaceModal();
const modalStyle = useModalStyle(480);
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
@@ -47,7 +49,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" flexHeight>
<Modal size="300" flexHeight style={modalStyle}>
<Box direction="Column">
<Header
size="500"
+5
View File
@@ -493,5 +493,10 @@ export const getChatBg = (
const { animation: _anim, ...rest } = style;
return rest;
}
// For animated backgrounds, promote the element to its own compositor layer so
// background-position keyframes don't trigger repaints on descendant elements.
if (style.animation) {
return { ...style, willChange: 'background-position', contain: 'paint' };
}
return style;
};
@@ -35,6 +35,7 @@ const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSe
senders: searchParams.get('senders') ?? undefined,
fromTs: searchParams.get('fromTs') ?? undefined,
toTs: searchParams.get('toTs') ?? undefined,
containsUrl: searchParams.get('containsUrl') ?? undefined,
}),
[searchParams],
);
@@ -197,6 +198,7 @@ export function MessageSearch({
senders: searchParamsSenders ?? senders,
fromTs: searchPathSearchParams.fromTs ? Number(searchPathSearchParams.fromTs) : undefined,
toTs: searchPathSearchParams.toTs ? Number(searchPathSearchParams.toTs) : undefined,
containsUrl: searchPathSearchParams.containsUrl === 'true' ? true : undefined,
};
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
@@ -357,6 +359,18 @@ export function MessageSearch({
[setSearchParams],
);
const handleContainsUrlChange = useCallback(
(value?: boolean) => {
setSearchParams((prevParams) => {
const p = new URLSearchParams(prevParams);
p.delete('containsUrl');
if (value) p.append('containsUrl', 'true');
return p;
});
},
[setSearchParams],
);
const lastVItem = vItems[vItems.length - 1];
const lastVItemIndex: number | undefined = lastVItem?.index;
const lastGroupIndex = groups.length - 1;
@@ -409,6 +423,8 @@ export function MessageSearch({
fromTs={msgSearchParams.fromTs}
toTs={msgSearchParams.toTs}
onDateRangeChange={handleDateRangeChange}
containsUrl={msgSearchParams.containsUrl}
onContainsUrlChange={handleContainsUrlChange}
/>
</Box>
+265 -26
View File
@@ -3,6 +3,7 @@ import React, {
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -326,6 +327,196 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
);
}
type SelectSenderButtonProps = {
selectedSenders?: string[];
onChange: (senders?: string[]) => void;
};
function SelectSenderButton({ selectedSenders, onChange }: SelectSenderButtonProps) {
const mx = useMatrixClient();
const scrollRef = useRef<HTMLDivElement>(null);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [localSelected, setLocalSelected] = useState(selectedSenders);
const knownUsers = useMemo(() => {
const ids = new Set<string>();
mx.getRooms().forEach((room) => {
room.getJoinedMembers().forEach((m) => {
if (m.userId !== mx.getSafeUserId()) ids.add(m.userId);
});
});
return Array.from(ids).sort();
}, [mx]);
const getUserDisplayStr: SearchItemStrGetter<string> = useCallback(
(userId) => {
const user = mx.getUser(userId);
return user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
},
[mx],
);
const [searchResult, _searchUser, resetSearch] = useAsyncSearch(
knownUsers,
getUserDisplayStr,
SEARCH_OPTS,
);
const users = searchResult?.items ?? knownUsers;
const virtualizer = useVirtualizer({
count: users.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 32,
overscan: 5,
});
const vItems = virtualizer.getVirtualItems();
const searchUser = useDebounce(_searchUser, SEARCH_DEBOUNCE_OPTS);
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.currentTarget.value.trim();
if (!value) {
resetSearch();
return;
}
searchUser(value);
};
const handleUserClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
if (localSelected?.includes(userId)) {
setLocalSelected(localSelected.filter((id) => id !== userId));
return;
}
setLocalSelected([...(localSelected ?? []), userId]);
};
const handleSave = () => {
setMenuAnchor(undefined);
onChange(localSelected && localSelected.length > 0 ? localSelected : undefined);
};
const handleDeselectAll = () => {
setMenuAnchor(undefined);
onChange(undefined);
};
useEffect(() => {
setLocalSelected(selectedSenders);
resetSearch();
}, [menuAnchor, selectedSenders, resetSearch]);
return (
<PopOut
anchor={menuAnchor}
align="Center"
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu variant="Surface" style={{ width: toRem(250) }}>
<Box direction="Column" style={{ maxHeight: toRem(400), maxWidth: toRem(300) }}>
<Box
shrink="No"
direction="Column"
gap="100"
style={{ padding: config.space.S200, paddingBottom: 0 }}
>
<Text size="L400">From</Text>
<Input
onChange={handleSearchChange}
size="300"
radii="300"
placeholder="Search people..."
/>
</Box>
<Scroll ref={scrollRef} size="300" hideTrack>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S200, paddingRight: 0 }}
>
{users.length === 0 && (
<Text style={{ padding: config.space.S400 }} size="T300" align="Center">
No match found!
</Text>
)}
<div style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{vItems.map((vItem) => {
const userId = users[vItem.index];
const user = mx.getUser(userId);
const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
const selected = localSelected?.includes(userId);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<MenuItem
data-user-id={userId}
onClick={handleUserClick}
variant={selected ? 'Success' : 'Surface'}
size="300"
radii="300"
aria-pressed={selected}
before={<Icon size="50" src={Icons.User} />}
>
<Text truncate size="T300">
{name}
</Text>
</MenuItem>
</VirtualTile>
);
})}
</div>
</Box>
</Scroll>
<Line variant="Surface" size="300" />
<Box shrink="No" direction="Column" gap="100" style={{ padding: config.space.S200 }}>
<Button size="300" variant="Secondary" radii="300" onClick={handleSave}>
{localSelected && localSelected.length > 0 ? (
<Text size="B300">Save ({localSelected.length})</Text>
) : (
<Text size="B300">Save</Text>
)}
</Button>
<Button
size="300"
radii="300"
variant="Secondary"
fill="Soft"
onClick={handleDeselectAll}
disabled={!localSelected || localSelected.length === 0}
>
<Text size="B300">Deselect All</Text>
</Button>
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
onClick={(e: React.MouseEvent<HTMLButtonElement>) =>
setMenuAnchor(e.currentTarget.getBoundingClientRect())
}
variant="SurfaceVariant"
radii="Pill"
before={<Icon size="100" src={Icons.User} />}
>
<Text size="T200">From</Text>
</Chip>
</PopOut>
);
}
type DateRangeButtonProps = {
fromTs?: number;
toTs?: number;
@@ -364,38 +555,61 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
>
<Menu variant="Surface" style={{ padding: config.space.S300, minWidth: toRem(220) }}>
<Box direction="Column" gap="200">
<Box direction="Column" gap="100">
<Text size="L400">Quick pick</Text>
<Box gap="100" wrap="Wrap">
{(
[
{ label: 'Today', days: 0 },
{ label: 'Last week', days: 7 },
{ label: 'Last month', days: 30 },
{ label: 'Last year', days: 365 },
] as const
).map(({ label: l, days }) => {
const now = Date.now();
const from =
days === 0
? new Date().setHours(0, 0, 0, 0)
: now - days * 24 * 60 * 60 * 1000;
return (
<Chip
key={l}
radii="Pill"
variant="SurfaceVariant"
onClick={() => {
onChange(from, now);
setMenuAnchor(undefined);
}}
>
<Text size="T200">{l}</Text>
</Chip>
);
})}
</Box>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100">
<Text size="L400">From</Text>
<input
<Input
type="date"
variant="SurfaceVariant"
size="300"
radii="300"
value={fromDate}
max={toDate || undefined}
onChange={(e) => handleFrom(e.target.value)}
style={{
background: 'var(--bg-surface-variant)',
border: '1px solid var(--border-surface-variant)',
borderRadius: config.radii.R300,
color: 'inherit',
fontSize: '0.82rem',
padding: `${config.space.S100} ${config.space.S200}`,
}}
/>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">To</Text>
<input
<Input
type="date"
variant="SurfaceVariant"
size="300"
radii="300"
value={toDate}
min={fromDate || undefined}
onChange={(e) => handleTo(e.target.value)}
style={{
background: 'var(--bg-surface-variant)',
border: '1px solid var(--border-surface-variant)',
borderRadius: config.radii.R300,
color: 'inherit',
fontSize: '0.82rem',
padding: `${config.space.S100} ${config.space.S200}`,
}}
/>
</Box>
{hasRange && (
@@ -458,6 +672,8 @@ type SearchFiltersProps = {
fromTs?: number;
toTs?: number;
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
containsUrl?: boolean;
onContainsUrlChange: (value?: boolean) => void;
};
export function SearchFilters({
defaultRoomsFilterName,
@@ -474,6 +690,8 @@ export function SearchFilters({
fromTs,
toTs,
onDateRangeChange,
containsUrl,
onContainsUrlChange,
}: SearchFiltersProps) {
const mx = useMatrixClient();
@@ -531,14 +749,12 @@ export function SearchFilters({
selectedRooms={selectedRooms}
onChange={onSelectedRoomsChange}
/>
{selectedSenders && selectedSenders.length > 0 && (
<Line
style={{ margin: `${config.space.S100} 0` }}
direction="Vertical"
variant="Surface"
size="300"
/>
)}
<Line
style={{ margin: `${config.space.S100} 0` }}
direction="Vertical"
variant="Surface"
size="300"
/>
{selectedSenders?.map((userId) => {
const user = mx.getUser(userId);
const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
@@ -555,7 +771,30 @@ export function SearchFilters({
</Chip>
);
})}
<SelectSenderButton selectedSenders={selectedSenders} onChange={onSelectedSendersChange} />
<Box grow="Yes" data-spacing-node />
<Chip
variant={containsUrl ? 'Success' : 'SurfaceVariant'}
outlined={!!containsUrl}
radii="Pill"
aria-pressed={!!containsUrl}
before={<Icon size="100" src={Icons.Link} />}
after={
containsUrl ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onContainsUrlChange(undefined);
}}
/>
) : undefined
}
onClick={() => onContainsUrlChange(containsUrl ? undefined : true)}
>
<Text size="T200">Has link</Text>
</Chip>
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
<OrderButton order={order} onChange={onOrderChange} />
</Box>
@@ -70,10 +70,11 @@ export type MessageSearchParams = {
senders?: string[];
fromTs?: number;
toTs?: number;
containsUrl?: boolean;
};
export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient();
const { term, order, rooms, senders, fromTs, toTs } = params;
const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
const searchMessages = useCallback(
async (nextBatch?: string) => {
@@ -96,9 +97,10 @@ export const useMessageSearch = (params: MessageSearchParams) => {
limit,
rooms,
senders,
// from_ts / to_ts are valid Matrix spec fields not yet in SDK types
// from_ts / to_ts and contains_url are valid Matrix spec fields not yet in SDK types
...(fromTs !== undefined && { from_ts: fromTs }),
...(toTs !== undefined && { to_ts: toTs }),
...(containsUrl !== undefined && { contains_url: containsUrl }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
include_state: false,
@@ -114,7 +116,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
});
return parseSearchResult(r);
},
[mx, term, order, rooms, senders, fromTs, toTs],
[mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
);
return searchMessages;
+2 -18
View File
@@ -453,12 +453,12 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
>
<MenuItem
size="300"
before={<Icon size="100" src={Icons.BellMute} />}
after={<Icon size="100" src={Icons.ChevronRight} />}
radii="300"
aria-pressed={!!muteMenuAnchor}
onClick={(e) => setMuteMenuAnchor(e.currentTarget.getBoundingClientRect())}
>
<Icon size="100" src={Icons.BellMute} />
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mute
</Text>
@@ -720,23 +720,7 @@ function RoomNavItem_({
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Box as="span" grow="Yes" alignItems="Center" gap="100">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{(() => {
const emojiMatch = roomName.match(
/^(\p{Emoji_Presentation}|\p{Extended_Pictographic})\s*/u,
);
const emojiPrefix = emojiMatch?.[0] ?? '';
const nameRest = emojiPrefix ? roomName.slice(emojiPrefix.length) : roomName;
return (
<>
{emojiPrefix && (
<span style={{ fontSize: '1.15em', lineHeight: 1 }}>
{emojiPrefix.trim()}
</span>
)}
{emojiPrefix ? ` ${nameRest}` : roomName}
</>
);
})()}
{roomName}
</Text>
{hasLocalName && (
<Icon
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, config, color } from 'folds';
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -255,40 +255,24 @@ ${msgRows}
<Box gap="400" wrap="Wrap">
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text size="T300">From</Text>
<input
<Input
type="date"
variant="Secondary"
size="400"
radii="300"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
style={{
background: color.Surface.Container,
color: color.Surface.OnContainer,
border: `1px solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `${config.space.S200} ${config.space.S300}`,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
boxSizing: 'border-box',
}}
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text size="T300">To</Text>
<input
<Input
type="date"
variant="Secondary"
size="400"
radii="300"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
style={{
background: color.Surface.Container,
color: color.Surface.OnContainer,
border: `1px solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `${config.space.S200} ${config.space.S300}`,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
boxSizing: 'border-box',
}}
/>
</Box>
</Box>
@@ -1,5 +1,17 @@
import React, { useCallback, useRef, useState } from 'react';
import { Badge, Box, Button, Icon, IconButton, Icons, Scroll, Text, color, config } from 'folds';
import {
Badge,
Box,
Button,
Icon,
IconButton,
Icons,
Input,
Scroll,
Text,
color,
config,
} from 'folds';
import { EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -242,7 +254,7 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
gap="300"
>
<Box gap="200" alignItems="Center">
<input
<Input
ref={inputRef}
value={roomIdInput}
onChange={(e) => setRoomIdInput(e.target.value)}
@@ -250,17 +262,10 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
if (e.key === 'Enter') handleLoad();
}}
placeholder="!roomId:server or #alias:server"
style={{
flexGrow: 1,
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${error ? color.Critical.Main : color.Surface.ContainerLine}`,
background: color.Surface.Container,
color: color.Surface.OnContainer,
fontSize: 'inherit',
fontFamily: 'inherit',
outline: 'none',
}}
variant={error ? 'Critical' : 'Secondary'}
size="400"
radii="300"
style={{ flexGrow: 1 }}
/>
<Button
onClick={handleLoad}
+21 -32
View File
@@ -1,7 +1,8 @@
import React, { useMemo } from 'react';
import { Avatar, Box, Icon, IconButton, Icons, Scroll, Text, color, config } from 'folds';
import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Scroll, Text, color, config } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { SequenceCard } from '../../components/sequence-card';
import { useRoom } from '../../hooks/useRoom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -23,14 +24,7 @@ function formatDate(ts: number): string {
function SectionHeader({ label }: { label: string }) {
return (
<Text
size="L400"
style={{
textTransform: 'uppercase',
letterSpacing: '0.06em',
opacity: 0.6,
}}
>
<Text size="L400" priority="300">
{label}
</Text>
);
@@ -38,7 +32,7 @@ function SectionHeader({ label }: { label: string }) {
// ── Stat tile ─────────────────────────────────────────────────────────────────
function StatTile({ emoji, count, label }: { emoji: string; count: number; label: string }) {
function StatTile({ icon, count, label }: { icon: IconSrc; count: number; label: string }) {
return (
<Box
direction="Column"
@@ -53,7 +47,7 @@ function StatTile({ emoji, count, label }: { emoji: string; count: number; label
background: color.Surface.Container,
}}
>
<Text size="H4">{emoji}</Text>
<Icon src={icon} size="300" />
<Text size="H4" style={{ fontWeight: 700 }}>
{count}
</Text>
@@ -165,31 +159,22 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
<PageContent>
<Box direction="Column" gap="500">
{/* ── Disclaimer banner ── */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Warning.Main}`,
background: color.Warning.Container,
}}
>
<Icon src={Icons.Warning} size="200" />
<SequenceCard variant="SurfaceVariant" gap="200" alignItems="Center">
<Icon src={Icons.Warning} size="200" style={{ color: color.Warning.Main }} />
<Box direction="Column" gap="100">
<Text size="T300" style={{ color: color.Warning.OnContainer }}>
<Text size="T300">
<strong>
Based on {stats.totalMessages} locally cached message
{stats.totalMessages !== 1 ? 's' : ''}
</strong>
</Text>
{stats.oldestTs !== null && stats.newestTs !== null && (
<Text size="T200" style={{ color: color.Warning.OnContainer, opacity: 0.8 }}>
<Text size="T200" priority="300">
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
</Text>
)}
</Box>
</Box>
</SequenceCard>
{/* ── Summary row ── */}
<Box direction="Column" gap="200">
@@ -289,10 +274,14 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
<Box direction="Column" gap="200">
<SectionHeader label="Media Shared" />
<Box gap="200" wrap="Wrap">
<StatTile emoji="🖼️" count={stats.mediaCounts.image} label="Images" />
<StatTile emoji="🎬" count={stats.mediaCounts.video} label="Videos" />
<StatTile emoji="🎵" count={stats.mediaCounts.audio} label="Audio" />
<StatTile emoji="📎" count={stats.mediaCounts.file} label="Files" />
<StatTile icon={Icons.Photo} count={stats.mediaCounts.image} label="Images" />
<StatTile
icon={Icons.VideoCamera}
count={stats.mediaCounts.video}
label="Videos"
/>
<StatTile icon={Icons.Headphone} count={stats.mediaCounts.audio} label="Audio" />
<StatTile icon={Icons.File} count={stats.mediaCounts.file} label="Files" />
</Box>
</Box>
@@ -350,7 +339,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
height: 6,
width: barWidth,
background: color.Primary.Main,
borderRadius: 3,
borderRadius: config.radii.R300,
transition: 'width 0.3s ease',
flexShrink: 0,
}}
@@ -432,7 +421,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
count > 0 && count === maxHour
? color.Primary.Main
: color.SurfaceVariant.Container,
borderRadius: '2px 2px 0 0',
borderRadius: `${config.radii.R300} ${config.radii.R300} 0 0`,
transition: 'height 0.2s ease',
}}
/>
@@ -445,7 +434,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
{stats.hourBuckets.map((_, h) => (
<Box key={h} justifyContent="Center" style={{ flex: 1 }}>
{h % 6 === 0 ? (
<Text size="T200" priority="300" align="Center" style={{ fontSize: 9 }}>
<Text size="T200" priority="300" align="Center">
{h}
</Text>
) : null}
@@ -1,5 +1,18 @@
import React, { useCallback, useRef, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color, config } from 'folds';
import {
Box,
Button,
Checkbox,
Icon,
IconButton,
Icons,
Input,
Scroll,
Spinner,
Text,
color,
config,
} from 'folds';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
@@ -97,22 +110,14 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
{canEdit && (
<Box direction="Column" gap="100">
<input
<Input
ref={inputRef}
type="text"
placeholder="e.g. *.example.com or badserver.org"
onKeyDown={handleKeyDown}
style={{
background: color.Surface.Container,
color: color.Surface.OnContainer,
border: `1px solid ${error ? color.Critical.Main : color.Surface.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `${config.space.S200} ${config.space.S300}`,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
boxSizing: 'border-box',
}}
variant={error ? 'Critical' : 'Secondary'}
size="400"
radii="300"
/>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
@@ -295,18 +300,13 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
gap="200"
>
<Box alignItems="Center" gap="300">
<input
type="checkbox"
<Checkbox
id="allow-ip-literals"
checked={allowIpLiterals}
disabled={!canEdit}
onChange={(e) => setAllowIpLiterals(e.target.checked)}
style={{
width: 16,
height: 16,
flexShrink: 0,
cursor: canEdit ? 'pointer' : 'default',
}}
onClick={() => setAllowIpLiterals(!allowIpLiterals)}
size="300"
variant="Primary"
/>
<Box direction="Column" gap="0">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+122
View File
@@ -0,0 +1,122 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Right-side drawer DOCKED into the room layout row (a flex sibling of the
// timeline), exactly like MembersDrawer — not a floating overlay. 320px is a
// little wider than the 266px member/bookmark drawers because it hosts a media
// grid. On narrow viewports it becomes a full-screen fixed panel, matching the
// app's other drawers.
export const MediaGalleryDrawer = style({
width: toRem(320),
overflow: 'hidden',
'@media': {
'(max-width: 750px)': {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 500,
},
},
});
export const MediaGalleryHeader = style({
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
});
export const MediaGalleryTabs = style({
flexShrink: 0,
padding: config.space.S200,
gap: config.space.S100,
borderBottomWidth: config.borderWidth.B300,
borderBottomStyle: 'solid',
borderBottomColor: color.Background.ContainerLine,
});
export const MediaGalleryContent = style({
padding: config.space.S200,
});
export const MediaGalleryGroup = style({
marginBottom: config.space.S300,
});
export const MediaGalleryGroupLabel = style({
padding: `${config.space.S200} ${config.space.S100}`,
});
export const MediaGalleryGrid = style({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: config.space.S100,
});
export const GalleryTile = style({
position: 'relative',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
borderWidth: config.borderWidth.B300,
borderStyle: 'solid',
borderColor: color.SurfaceVariant.ContainerLine,
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'border-color 100ms ease-in-out',
selectors: {
'&:hover, &:focus-visible': {
borderColor: color.Primary.Main,
},
},
});
export const GalleryTileImg = style({
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center top',
display: 'block',
});
// Dark scrim is intentional: it overlays arbitrary media, so a theme surface
// token would not guarantee legibility of the sender/date caption.
export const GalleryTileOverlay = style({
position: 'absolute',
inset: 0,
background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, transparent 55%)',
opacity: 0,
transition: 'opacity 100ms ease-in-out',
pointerEvents: 'none',
selectors: {
[`${GalleryTile}:hover &, ${GalleryTile}:focus-visible &`]: {
opacity: 1,
},
},
});
export const GalleryTileCaption = style({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: `${config.space.S100} ${config.space.S200}`,
});
export const GalleryVideoBadge = style({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: toRem(28),
height: toRem(28),
borderRadius: '50%',
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
});
+89 -148
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
@@ -15,11 +15,15 @@ import {
config,
} from 'folds';
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './MediaGallery.css';
type GalleryTab = 'image' | 'video' | 'file';
@@ -45,11 +49,13 @@ function useDecryptedMediaUrl(
encInfo: IEncryptedFile | undefined,
useAuthentication: boolean,
mimeType?: string,
enabled = true,
): DecryptState {
const [state, setState] = useState<DecryptState>({ status: 'loading' });
const prevBlobUrl = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return undefined;
if (!mxcUrl) {
setState({ status: 'error' });
return;
@@ -84,7 +90,7 @@ function useDecryptedMediaUrl(
return () => {
cancelled = true;
};
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType]);
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType, enabled]);
useEffect(
() => () => {
@@ -367,31 +373,25 @@ function GalleryTile({
onClick: () => void;
}) {
const mx = useMatrixClient();
const media = useDecryptedMediaUrl(mx, mxcUrl, encInfo, useAuthentication, mimeType);
const [hovered, setHovered] = useState(false);
const tileRef = useRef<HTMLButtonElement>(null);
const nearViewport = useNearViewport(tileRef, 300);
const media = useDecryptedMediaUrl(
mx,
mxcUrl,
encInfo,
useAuthentication,
mimeType,
nearViewport,
);
const relDate = formatRelativeDate(ts);
return (
<button
ref={tileRef}
type="button"
aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
position: 'relative',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
border: `1px solid ${hovered ? color.Primary.Main : color.SurfaceVariant.ContainerLine}`,
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'border-color 0.15s',
}}
className={css.GalleryTile}
>
{media.status === 'loading' && <Spinner size="200" />}
{media.status === 'error' && (
@@ -405,65 +405,30 @@ function GalleryTile({
<Text
size="T200"
truncate
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.5 }}
priority="300"
style={{ maxWidth: '100%', textAlign: 'center' }}
>
{body}
</Text>
</Box>
)}
{media.status === 'ok' && (
<img
src={media.url}
alt={body}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
)}
{media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />}
{/* Video play badge */}
{isVideo && media.status === 'ok' && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 28,
height: 28,
borderRadius: '50%',
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<div className={css.GalleryVideoBadge}>
<Icon src={Icons.Play} size="200" />
</div>
)}
{/* Hover overlay */}
{hovered && media.status === 'ok' && (
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, transparent 55%)',
pointerEvents: 'none',
}}
>
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: `${config.space.S100} ${config.space.S200}`,
}}
>
{/* Hover/focus caption overlay (CSS-driven) */}
{media.status === 'ok' && (
<div className={css.GalleryTileOverlay}>
<div className={css.GalleryTileCaption}>
<Text size="T200" truncate style={{ color: '#fff', display: 'block', lineHeight: 1.3 }}>
{sender}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)', opacity: 0.8 }}>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
{relDate}
</Text>
</div>
@@ -475,30 +440,16 @@ function GalleryTile({
// ── Month separator ───────────────────────────────────────────────────────────
function MonthSeparator({ label }: { label: string }) {
return (
<Box
alignItems="Center"
gap="200"
style={{ padding: `${config.space.S100} 0`, gridColumn: '1 / -1' }}
>
<div style={{ flex: 1, height: 1, background: color.Surface.ContainerLine }} />
<Text size="T200" priority="300" style={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
{label}
</Text>
<div style={{ flex: 1, height: 1, background: color.Surface.ContainerLine }} />
</Box>
);
}
// ── Tab button ────────────────────────────────────────────────────────────────
function TabButton({
label,
count,
active,
onClick,
}: {
label: string;
count: number;
active: boolean;
onClick: () => void;
}) {
@@ -506,11 +457,14 @@ function TabButton({
<Button
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Soft' : 'None'}
fill={active ? 'Solid' : 'Soft'}
radii="300"
onClick={onClick}
>
<Text size="B300">{label}</Text>
<Text size="B300">
{label}
{count > 0 ? ` (${count})` : ''}
</Text>
</Button>
);
}
@@ -539,6 +493,20 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
setLightboxIndex(null); // stale index would open wrong item in new tab's lightboxItems
}, []);
// Escape closes the drawer — but only when the lightbox isn't open, since the
// lightbox has its own Escape handler that should take precedence.
useEffect(() => {
if (lightboxIndex !== null) return undefined;
const handleKeyDown = (evt: KeyboardEvent) => {
if (evt.key === 'Escape') {
stopPropagation(evt);
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [lightboxIndex, onClose]);
const msgtype = TAB_MSGTYPES[tab];
const getFilteredEvents = useCallback(
@@ -621,6 +589,25 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
};
});
// Per-tab counts for the tab labels (single pass over loaded timeline)
const tabCounts = useMemo(() => {
const counts: Record<GalleryTab, number> = { image: 0, video: 0, file: 0 };
room
.getLiveTimeline()
.getEvents()
.forEach((ev) => {
if (ev.isRedacted() || ev.getType() !== EventType.RoomMessage) return;
const mt = ev.getContent().msgtype;
if (mt === MsgType.Image) counts.image += 1;
else if (mt === MsgType.Video) counts.video += 1;
else if (mt === MsgType.File) counts.file += 1;
});
return counts;
// `events` is intentional: it changes when more history is paginated in, so
// the counts stay in sync with the loaded window (it isn't read directly).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, events]);
// Group image/video events by month for the grid
type MonthGroup = { label: string; events: MatrixEvent[] };
const monthGroups: MonthGroup[] = [];
@@ -637,71 +624,32 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
return (
<>
<Box
className={ContainerColor({ variant: 'Surface' })}
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: '320px',
zIndex: 500,
borderLeft: `1px solid ${color.Surface.ContainerLine}`,
overflow: 'hidden',
}}
>
{/* Header */}
<Header
variant="Surface"
size="600"
style={{
flexShrink: 0,
paddingRight: config.space.S200,
paddingLeft: config.space.S300,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
>
<Header variant="Background" size="600" className={css.MediaGalleryHeader}>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} />
<Box grow="Yes">
<Text size="H5" truncate>
<Text size="H4" truncate>
Media Gallery
</Text>
</Box>
{events.length > 0 && (
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{events.length}
</Text>
)}
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton ref={ref} variant="Background" aria-label="Close" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
<IconButton size="300" radii="300" aria-label="Close media gallery" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
{/* Tabs */}
<Box
shrink="No"
gap="100"
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
>
<Box className={css.MediaGalleryTabs} shrink="No">
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
<TabButton
key={t}
label={TAB_LABELS[t]}
count={tabCounts[t]}
active={tab === t}
onClick={() => handleTabChange(t)}
/>
@@ -711,7 +659,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
{/* Content */}
<Box grow="Yes" style={{ position: 'relative', overflow: 'hidden' }}>
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
<Box direction="Column" style={{ padding: config.space.S200, gap: 0 }}>
<Box className={css.MediaGalleryContent} direction="Column">
{/* ── Image / video grid ── */}
{(tab === 'image' || tab === 'video') && (
<>
@@ -735,20 +683,14 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
{(() => {
let flatIdx = 0;
return monthGroups.map((group) => (
<Box
key={group.label}
direction="Column"
style={{ marginBottom: config.space.S200 }}
>
{/* Month header — only shown when there are multiple groups */}
{monthGroups.length > 1 && <MonthSeparator label={group.label} />}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: config.space.S100,
}}
>
<Box key={group.label} direction="Column" className={css.MediaGalleryGroup}>
{/* Month label — only shown when there are multiple groups */}
{monthGroups.length > 1 && (
<Text className={css.MediaGalleryGroupLabel} size="L400" priority="300">
{group.label}
</Text>
)}
<div className={css.MediaGalleryGrid}>
{group.events.map((mEvent) => {
const c = mEvent.getContent();
const isEnc = !!c.file;
@@ -826,8 +768,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
background: color.SurfaceVariant.Container,
}}
>
<Icon size="300" src={Icons.File} />
@@ -845,7 +786,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
</Text>
</Box>
<IconButton
variant="Background"
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={`Download ${body}`}
@@ -3,6 +3,14 @@ import { config, toRem } from 'folds';
export const MembersDrawer = style({
width: toRem(266),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 500,
},
},
});
export const MembersDrawerHeader = style({
+1 -5
View File
@@ -414,11 +414,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
{knockMembers.length > 0 && (
<Box direction="Column" gap="100">
<Text
style={{ padding: `${config.space.S100} ${config.space.S200}` }}
size="L400"
priority="300"
>
<Text className={css.MembersGroupLabel} size="L400">
Pending Requests
</Text>
{knockMembers.map((knockMember) => {
+7 -11
View File
@@ -3,6 +3,7 @@ import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
@@ -19,6 +20,7 @@ import {
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
interface PollCreatorProps {
roomId: string;
@@ -28,6 +30,7 @@ interface PollCreatorProps {
export function PollCreator({ roomId, onClose }: PollCreatorProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(440);
const [question, setQuestion] = useState('');
const [options, setOptions] = useState<string[]>(['', '']);
const [isMultiple, setIsMultiple] = useState(false);
@@ -98,21 +101,14 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
escapeDeactivates: stopPropagation,
}}
>
<Box
<Dialog
as="form"
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="poll-creator-title"
onSubmit={handleSubmit}
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: color.Other.Shadow,
width: '100vw',
maxWidth: 440,
overflow: 'hidden',
}}
style={modalStyle}
>
{/* Header */}
<Header
@@ -256,7 +252,7 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
</Button>
</Box>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
@@ -0,0 +1,85 @@
import React, { MouseEventHandler, useState } from 'react';
import { Box, Button, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config } from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../../utils/keyboard';
type ReportCategorySelectProps = {
id?: string;
value: string;
labels: Record<string, string>;
onChange: (value: string) => void;
};
/**
* Category dropdown for the report modals folds `Button` + `PopOut` + `Menu`
* pattern (matching `OrderButton` in SearchFilters), replacing the OS-styled
* native `<select>` that looked foreign inside the modal.
*/
export function ReportCategorySelect({ id, value, labels, onChange }: ReportCategorySelectProps) {
const [anchor, setAnchor] = useState<RectCords>();
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
setAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (key: string) => {
onChange(key);
setAnchor(undefined);
};
return (
<PopOut
anchor={anchor}
position="Bottom"
align="Start"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
}}
>
<Menu style={{ padding: config.space.S100 }}>
<Box direction="Column" gap="100">
{Object.keys(labels).map((key) => (
<MenuItem
key={key}
size="300"
variant={key === value ? 'Primary' : 'Surface'}
radii="300"
aria-pressed={key === value}
onClick={() => handleSelect(key)}
>
<Text size="T300">{labels[key]}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
>
<Button
id={id}
type="button"
variant="Secondary"
fill="Soft"
size="400"
radii="300"
outlined
onClick={handleOpen}
aria-haspopup="listbox"
aria-expanded={!!anchor}
after={<Icon size="100" src={Icons.ChevronBottom} />}
style={{ width: '100%', justifyContent: 'space-between' }}
>
<Text size="T300">{labels[value] ?? value}</Text>
</Button>
</PopOut>
);
}
+12 -37
View File
@@ -5,6 +5,7 @@ import {
Text,
Input,
Button,
Dialog,
IconButton,
Icon,
Icons,
@@ -17,8 +18,10 @@ import {
Spinner,
} from 'folds';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ReportCategorySelect } from './ReportCategorySelect';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
type ReportCategory = 'spam' | 'harassment' | 'inappropriate' | 'other';
@@ -36,6 +39,7 @@ type ReportRoomModalProps = {
export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(420);
const [category, setCategory] = useState<ReportCategory>('spam');
const [reportState, submitReport] = useAsyncCallback(
@@ -92,21 +96,14 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
escapeDeactivates: stopPropagation,
}}
>
<Box
<Dialog
as="form"
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="report-room-dialog-title"
onSubmit={handleSubmit}
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: color.Other.Shadow,
width: '100%',
maxWidth: 420,
overflow: 'hidden',
}}
style={modalStyle}
>
<Header
style={{
@@ -135,31 +132,12 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
<Text as="label" htmlFor="report-category" size="L400">
Category
</Text>
<Box
as="select"
<ReportCategorySelect
id="report-category"
aria-label="Report category"
value={category}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setCategory(e.target.value as ReportCategory)
}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
color: color.Surface.OnContainer,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
}}
>
{(Object.keys(CATEGORY_LABELS) as ReportCategory[]).map((key) => (
<option key={key} value={key}>
{CATEGORY_LABELS[key]}
</option>
))}
</Box>
labels={CATEGORY_LABELS}
onChange={(v) => setCategory(v as ReportCategory)}
/>
</Box>
<Box direction="Column" gap="100">
@@ -186,9 +164,6 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
</Box>
<Box gap="200" justifyContent="End">
<Button type="button" variant="Secondary" fill="None" radii="300" onClick={onClose}>
<Text size="B400">Cancel</Text>
</Button>
<Button
type="submit"
variant="Critical"
@@ -209,7 +184,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
</Button>
</Box>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
+198
View File
@@ -0,0 +1,198 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Text,
Input,
Button,
Dialog,
IconButton,
Icon,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Header,
config,
color,
Spinner,
} from 'folds';
import { Method } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ReportCategorySelect } from './ReportCategorySelect';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
type ReportCategory = 'spam' | 'harassment' | 'inappropriate' | 'other';
const CATEGORY_LABELS: Record<ReportCategory, string> = {
spam: 'Spam',
harassment: 'Harassment',
inappropriate: 'Inappropriate Content',
other: 'Other',
};
type ReportUserModalProps = {
userId: string;
onClose: () => void;
};
export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(420);
const [category, setCategory] = useState<ReportCategory>('spam');
const [reportState, submitReport] = useAsyncCallback(
useCallback(
async (reason: string) => {
await mx.http.authedRequest(
Method.Post,
`/users/${encodeURIComponent(userId)}/report`,
undefined,
{ reason },
);
},
[mx, userId],
),
);
useEffect(() => {
if (reportState.status === AsyncStatus.Success) {
const timer = setTimeout(onClose, 1500);
return () => clearTimeout(timer);
}
return undefined;
}, [reportState.status, onClose]);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (reportState.status === AsyncStatus.Loading || reportState.status === AsyncStatus.Success) {
return;
}
const target = evt.target as HTMLFormElement;
const reasonInput = target.elements.namedItem('reasonInput') as HTMLInputElement | null;
const reasonText = reasonInput?.value.trim() ?? '';
const fullReason = `[${CATEGORY_LABELS[category]}] ${reasonText}`;
submitReport(fullReason);
};
const reportError =
reportState.status === AsyncStatus.Error
? (reportState.error as { errcode?: string; httpStatus?: number })
: undefined;
const errcode = reportError?.errcode;
const errorMsg =
errcode === 'M_LIMIT_EXCEEDED'
? 'You are being rate limited. Please wait before reporting again.'
: errcode === 'M_FORBIDDEN'
? 'You cannot report this user.'
: errcode === 'M_UNRECOGNIZED' || reportError?.httpStatus === 404
? 'User reporting is not supported by your homeserver.'
: 'Failed to submit report. Please try again.';
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
as="form"
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="report-user-dialog-title"
onSubmit={handleSubmit}
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text id="report-user-dialog-title" size="H4">
Report User
</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Text priority="400">
Report this user to your homeserver admins. Please describe the issue below.
</Text>
<Box direction="Column" gap="100">
<Text as="label" htmlFor="report-user-category" size="L400">
Category
</Text>
<ReportCategorySelect
id="report-user-category"
value={category}
labels={CATEGORY_LABELS}
onChange={(v) => setCategory(v as ReportCategory)}
/>
</Box>
<Box direction="Column" gap="100">
<Text as="label" htmlFor="report-user-reason-input" size="L400">
Reason
</Text>
<Input
id="report-user-reason-input"
name="reasonInput"
aria-label="Reason for report"
variant="Background"
required
/>
{reportState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{errorMsg}
</Text>
)}
{reportState.status === AsyncStatus.Success && (
<Text style={{ color: color.Success.Main }} size="T300">
User has been reported to the server.
</Text>
)}
</Box>
<Box gap="200" justifyContent="End">
<Button
type="submit"
variant="Critical"
radii="300"
before={
reportState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Critical" size="200" />
) : undefined
}
aria-disabled={
reportState.status === AsyncStatus.Loading ||
reportState.status === AsyncStatus.Success
}
>
<Text size="B400">
{reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report User'}
</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
+17 -3
View File
@@ -2,9 +2,11 @@ import React, { useCallback } from 'react';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai';
import { useAtomValue, useSetAtom } from 'jotai';
import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer';
import { MediaGallery } from './MediaGallery';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -31,6 +33,8 @@ export function Room() {
const callEmbed = useCallEmbed();
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const galleryOpen = useAtomValue(mediaGalleryAtom);
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room);
@@ -78,9 +82,19 @@ export function Room() {
<CallChatView />
</>
)}
{!callView && screenSize === ScreenSize.Desktop && isDrawer && (
{!callView && galleryOpen && (
<>
<Line variant="Background" direction="Vertical" size="300" />
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</>
)}
{!callView && isDrawer && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<MembersDrawer key={room.roomId} room={room} members={members} />
</>
)}
+16 -7
View File
@@ -210,6 +210,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
const touchTarget = mobileOrTablet() ? { minWidth: '44px', minHeight: '44px' } : undefined;
const showFormat = composerToolbarButtons?.showFormat ?? true;
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
const showSticker = composerToolbarButtons?.showSticker ?? true;
@@ -724,7 +725,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
info: { mimetype: 'image/gif', w, h, size: blob.size },
});
} catch (e) {
console.error('GIF send failed', e);
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
if (!alive()) return;
setGifError('Failed to send GIF. Please try again.');
setTimeout(() => setGifError(null), 4000);
@@ -865,15 +866,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
padding: `${config.space.S100} ${config.space.S200}`,
borderRadius: config.radii.R300,
background: color.Warning.Container,
borderLeft: `3px solid ${color.Warning.Main}`,
border: `${config.borderWidth.B300} solid ${color.Warning.ContainerLine}`,
}}
>
<Icon
size="100"
src={Icons.Shield}
style={{ color: color.Warning.OnContainer, opacity: 0.8, flexShrink: 0 }}
style={{ color: color.Warning.OnContainer, flexShrink: 0 }}
/>
<Text size="T200" style={{ color: color.Warning.OnContainer, opacity: 0.9 }}>
<Text size="T200" style={{ color: color.Warning.OnContainer }}>
{roomUnverifiedDeviceCount}{' '}
{roomUnverifiedDeviceCount === 1 ? 'unverified device' : 'unverified devices'} in this
room
@@ -936,6 +937,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon src={Icons.PlusCircle} />
</IconButton>
@@ -947,6 +949,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
aria-pressed={toolbar}
onClick={() => setToolbar(!toolbar)}
@@ -998,6 +1001,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon
src={Icons.Sticker}
@@ -1016,6 +1020,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon
src={Icons.Smile}
@@ -1063,6 +1068,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
size="300"
radii="300"
disabled={gifUploading}
style={touchTarget}
>
{gifUploading ? (
<Spinner variant="Secondary" size="100" />
@@ -1119,6 +1125,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
size="300"
radii="300"
title="Share location"
style={touchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="100" />
@@ -1135,6 +1142,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
size="300"
radii="300"
title="Create poll"
style={touchTarget}
>
<Icon src={Icons.OrderList} size="100" />
</IconButton>
@@ -1151,14 +1159,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{charCount > 0 && (
<Text
size="T200"
priority="300"
style={{
color: 'var(--tc-surface-low)',
padding: '0 4px',
padding: `0 ${config.space.S100}`,
alignSelf: 'center',
userSelect: 'none',
minWidth: '2rem',
textAlign: 'right',
opacity: 0.7,
}}
>
{charCount}
@@ -1170,6 +1177,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Schedule message"
title="Schedule message"
>
@@ -1181,6 +1189,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Send message"
>
<Icon src={Icons.Send} />
+3
View File
@@ -2102,6 +2102,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Box
direction="Column"
justifyContent="End"
role="log"
aria-label="Message timeline"
aria-live="polite"
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
>
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
+47 -14
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Box, Text, config } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
@@ -109,13 +110,31 @@ export function RoomView({ eventId }: { eventId?: string }) {
return (
<Page ref={roomViewRef} style={chatBgStyle}>
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
<ErrorBoundary
fallback={
<Box
grow="Yes"
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="400"
style={{ padding: config.space.S400, opacity: 0.7 }}
>
<Text size="H4">Timeline unavailable</Text>
<Text size="T300" align="Center">
An error occurred while rendering messages. Try refreshing the page.
</Text>
</Box>
}
>
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
</ErrorBoundary>
<RoomViewTyping room={room} />
</Box>
<Box shrink="No" direction="Column">
@@ -129,13 +148,27 @@ export function RoomView({ eventId }: { eventId?: string }) {
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
<ErrorBoundary
fallback={
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">
Message composer encountered an error. Try refreshing.
</Text>
</RoomInputPlaceholder>
}
>
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
</ErrorBoundary>
)}
{!canMessage && (
<RoomInputPlaceholder
+262 -208
View File
@@ -24,6 +24,7 @@ import {
Spinner,
Button,
} from 'folds';
import { useAtom } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { Room } from 'matrix-js-sdk';
import { useStateEvent } from '../../hooks/useStateEvent';
@@ -72,206 +73,260 @@ import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { MediaGallery } from './MediaGallery';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
type RoomMenuProps = {
room: Room;
requestClose: () => void;
galleryOpen?: boolean;
onToggleGallery?: () => void;
};
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
({ room, requestClose, galleryOpen, onToggleGallery }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const isServerNotice = room.getType() === 'm.server_notice';
const isCreator = creators.has(mx.getSafeUserId());
const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const { navigateRoom } = useRoomNavigate();
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const isServerNotice = room.getType() === 'm.server_notice';
const isCreator = creators.has(mx.getSafeUserId());
const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const { navigateRoom } = useRoomNavigate();
const [invitePrompt, setInvitePrompt] = useState(false);
const [reportRoomOpen, setReportRoomOpen] = useState(false);
const [invitePrompt, setInvitePrompt] = useState(false);
const [reportRoomOpen, setReportRoomOpen] = useState(false);
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
const handleInvite = () => {
setInvitePrompt(true);
};
const openSettings = useOpenRoomSettings();
const parentSpace = useSpaceOptionally();
const handleOpenSettings = () => {
openSettings(room.roomId, parentSpace?.roomId);
requestClose();
};
const openSettings = useOpenRoomSettings();
const parentSpace = useSpaceOptionally();
const handleOpenSettings = () => {
openSettings(room.roomId, parentSpace?.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
{reportRoomOpen && (
<ReportRoomModal
roomId={room.roomId}
onClose={() => {
setReportRoomOpen(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
{reportRoomOpen && (
<ReportRoomModal
roomId={room.roomId}
onClose={() => {
setReportRoomOpen(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Notifications
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={() => {
setBookmarksOpen((v) => !v);
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.Star} filled={bookmarksOpen} />}
radii="300"
aria-pressed={bookmarksOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Saved Messages
</Text>
</MenuItem>
{screenSize === ScreenSize.Mobile && (
<MenuItem
onClick={() => {
setPeopleDrawer(!peopleDrawer);
requestClose();
}}
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
after={<Icon size="100" src={Icons.User} filled={peopleDrawer} />}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
aria-pressed={peopleDrawer}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Notifications
Members
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{!isServerNotice && (
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
)}
{!isServerNotice && (
<MenuItem
onClick={handleOpenSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Room Settings
</Text>
</MenuItem>
)}
<UseStateProvider initial={false}>
{(promptJump, setPromptJump) => (
<>
<MenuItem
onClick={() => setPromptJump(true)}
size="300"
after={<Icon size="100" src={Icons.RecentClock} />}
radii="300"
aria-pressed={promptJump}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Jump to Time
</Text>
</MenuItem>
{promptJump && (
<JumpToTime
onSubmit={(eventId) => {
setPromptJump(false);
navigateRoom(room.roomId, eventId);
requestClose();
}}
onCancel={() => setPromptJump(false)}
/>
)}
</>
{screenSize === ScreenSize.Mobile && onToggleGallery && (
<MenuItem
onClick={() => {
onToggleGallery();
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.Photo} filled={galleryOpen} />}
radii="300"
aria-pressed={galleryOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Media Gallery
</Text>
</MenuItem>
)}
</UseStateProvider>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{!isServerNotice && !isCreator && (
<MenuItem
onClick={() => setReportRoomOpen(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Warning} />}
radii="300"
aria-pressed={reportRoomOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Report Room
</Text>
</MenuItem>
)}
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Room
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
{!isServerNotice && (
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
)}
</UseStateProvider>
</Box>
</Menu>
);
});
{!isServerNotice && (
<MenuItem
onClick={handleOpenSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Room Settings
</Text>
</MenuItem>
)}
<UseStateProvider initial={false}>
{(promptJump, setPromptJump) => (
<>
<MenuItem
onClick={() => setPromptJump(true)}
size="300"
after={<Icon size="100" src={Icons.RecentClock} />}
radii="300"
aria-pressed={promptJump}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Jump to Time
</Text>
</MenuItem>
{promptJump && (
<JumpToTime
onSubmit={(eventId) => {
setPromptJump(false);
navigateRoom(room.roomId, eventId);
requestClose();
}}
onCancel={() => setPromptJump(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{!isServerNotice && !isCreator && (
<MenuItem
onClick={() => setReportRoomOpen(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Warning} />}
radii="300"
aria-pressed={reportRoomOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Report Room
</Text>
</MenuItem>
)}
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Room
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
},
);
type CallMenuProps = {
onVoiceCall: () => void;
@@ -433,7 +488,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
: undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useState(false);
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
const pendingKnocks = usePendingKnocks(room);
const handleSearchClick = () => {
@@ -469,6 +524,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<PageHeader
className={ContainerColor({ variant: 'Surface' })}
balance={screenSize === ScreenSize.Mobile}
aria-label={`${name} room header`}
>
<Box grow="Yes" gap="300">
{screenSize === ScreenSize.Mobile && (
@@ -511,7 +567,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<Chip ref={triggerRef} size="400" variant="Warning" radii="Pill" outlined>
<Chip ref={triggerRef} size="400" variant="Warning" radii="300" outlined>
<Text size="T200">Server Notice</Text>
</Chip>
)}
@@ -597,6 +653,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
style={{ position: 'relative' }}
onClick={handleOpenPinMenu}
ref={triggerRef}
aria-label="Pinned messages"
aria-pressed={!!pinMenuAnchor}
>
{pinnedEvents.length > 0 && (
@@ -684,45 +741,38 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<div style={{ position: 'relative', display: 'inline-flex' }}>
<IconButton
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
aria-label={
pendingKnocks.length > 0
? `Toggle member list, ${pendingKnocks.length} pending join request${pendingKnocks.length > 1 ? 's' : ''}`
: 'Toggle member list'
}
>
<Icon size="400" src={Icons.User} />
</IconButton>
<IconButton
fill="None"
ref={triggerRef}
style={{ position: 'relative' }}
onClick={handleMemberToggle}
aria-label={
pendingKnocks.length > 0
? `Toggle member list, ${pendingKnocks.length} pending join request${pendingKnocks.length > 1 ? 's' : ''}`
: 'Toggle member list'
}
>
{pendingKnocks.length > 0 && (
<Badge
aria-hidden
variant="Warning"
fill="Solid"
radii="Pill"
size="200"
size="400"
style={{
position: 'absolute',
top: '-2px',
right: '-2px',
right: toRem(3),
top: toRem(3),
pointerEvents: 'none',
fontSize: '9px',
minWidth: '14px',
height: '14px',
padding: '0 3px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 1,
}}
>
{pendingKnocks.length > 9 ? '9+' : pendingKnocks.length}
<Text as="span" size="L400">
{pendingKnocks.length > 9 ? '9+' : pendingKnocks.length}
</Text>
</Badge>
)}
</div>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
</TooltipProvider>
)}
@@ -766,14 +816,18 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
escapeDeactivates: stopPropagation,
}}
>
<RoomMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
<RoomMenu
room={room}
requestClose={() => setMenuAnchor(undefined)}
galleryOpen={galleryOpen}
onToggleGallery={() => setGalleryOpen((v: boolean) => !v)}
/>
</FocusTrap>
}
/>
</Box>
</Box>
</PageHeader>
{galleryOpen && <MediaGallery room={room} onClose={() => setGalleryOpen(false)} />}
</>
);
}
+7 -11
View File
@@ -3,6 +3,7 @@ import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
@@ -19,6 +20,7 @@ import { IContent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { scheduleMessage } from '../../utils/scheduledMessages';
import { useModalStyle } from '../../hooks/useModalStyle';
interface ScheduleMessageModalProps {
roomId: string;
@@ -89,6 +91,7 @@ export function ScheduleMessageModal({
onScheduled,
onClose,
}: ScheduleMessageModalProps) {
const modalStyle = useModalStyle(400);
const mx = useMatrixClient();
const [messageText, setMessageText] = useState(initialBody ?? '');
const [submitting, setSubmitting] = useState(false);
@@ -175,21 +178,14 @@ export function ScheduleMessageModal({
escapeDeactivates: stopPropagation,
}}
>
<Box
<Dialog
as="form"
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="schedule-message-title"
onSubmit={handleSubmit}
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: color.Other.Shadow,
width: '100vw',
maxWidth: 400,
overflow: 'hidden',
}}
style={modalStyle}
>
{/* Header */}
<Header
@@ -341,7 +337,7 @@ export function ScheduleMessageModal({
<Text size="B400">Schedule</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
+13 -13
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAtom } from 'jotai';
import { Box, Icon, IconButton, Icons, Text, color, config } from 'folds';
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
import { cancelScheduledMessage } from '../../utils/scheduledMessages';
@@ -106,24 +106,24 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
}}
>
{/* Tray header */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S100} ${config.space.S300}`,
cursor: 'pointer',
}}
<Button
variant="Secondary"
fill="None"
radii="0"
onClick={() => setExpanded((v) => !v)}
as="button"
aria-expanded={expanded}
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
before={<Icon src={Icons.Clock} size="50" />}
after={<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
style={{
padding: `${config.space.S100} ${config.space.S300}`,
justifyContent: 'flex-start',
}}
>
<Icon src={Icons.Clock} size="50" />
<Text size="T200" style={{ flex: 1, fontWeight: 600 }}>
<Text size="T200" style={{ flex: 1, fontWeight: 600, textAlign: 'left' }}>
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
</Text>
<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />
</Box>
</Button>
{/* Tray items */}
{expanded && (
@@ -31,6 +31,7 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../
import { DatePicker, TimePicker } from '../../../components/time-date';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { useModalStyle } from '../../../hooks/useModalStyle';
type JumpToTimeProps = {
onCancel: () => void;
@@ -38,6 +39,7 @@ type JumpToTimeProps = {
};
export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(480);
const room = useRoom();
const alive = useAlive();
const createStateEvent = useStateEvent(room, StateEvent.RoomCreate);
@@ -96,7 +98,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog variant="Surface" style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -1,9 +1,10 @@
import React, { ReactNode, useCallback, useEffect } from 'react';
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import parse from 'html-react-parser';
import Linkify from 'linkify-react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Header,
Icon,
IconButton,
@@ -20,6 +21,7 @@ import {
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { stopPropagation } from '../../../utils/keyboard';
import { useModalStyle } from '../../../hooks/useModalStyle';
import { sanitizeCustomHtml } from '../../../utils/sanitize';
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
@@ -39,11 +41,6 @@ type EditHistoryResponse = {
next_batch?: string;
};
type EditHistoryData = {
events: MatrixEvent[];
hasMore: boolean;
};
type EditHistoryModalProps = {
room: Room;
mEvent: MatrixEvent;
@@ -92,54 +89,78 @@ function getVersionContent(evt: MatrixEvent): ReactNode {
export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(560);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const eventId = mEvent.getId();
const roomId = room.roomId;
const [historyState, fetchHistory] = useAsyncCallback<EditHistoryData, unknown, []>(
useCallback(async () => {
if (!eventId) return { events: [], hasMore: false };
// Accumulated, de-duplicated edits across paginated fetches.
const [edits, setEdits] = useState<MatrixEvent[]>([]);
const [nextBatch, setNextBatch] = useState<string | undefined>(undefined);
// Relations API lives at /_matrix/client/v1/ (not v3); use raw fetch to avoid SDK prefix
const token = mx.getAccessToken();
const baseUrl = mx.getHomeserverUrl();
const url = `${baseUrl}/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}/m.replace?limit=50`;
const fetchRes = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
const res = (await fetchRes.json()) as EditHistoryResponse;
const rawEvents = res.chunk ?? [];
const events = await Promise.all(
rawEvents
.filter(isRawEditEvent)
.sort((a, b) => a.origin_server_ts - b.origin_server_ts)
.map(async (raw) => {
const existing = room.findEventById(raw.event_id);
if (existing) return existing;
const evt = new MatrixEvent({
type: raw.type,
content: raw.content,
origin_server_ts: raw.origin_server_ts,
event_id: raw.event_id,
room_id: roomId,
sender: mEvent.getSender() ?? '',
});
if (evt.isEncrypted()) {
await mx.decryptEventIfNeeded(evt);
}
return evt;
}),
);
const parseRawEvents = useCallback(
(rawEvents: Array<Record<string, unknown>>): Promise<MatrixEvent[]> =>
Promise.all(
rawEvents.filter(isRawEditEvent).map(async (raw) => {
const existing = room.findEventById(raw.event_id);
if (existing) return existing;
const evt = new MatrixEvent({
type: raw.type,
content: raw.content,
origin_server_ts: raw.origin_server_ts,
event_id: raw.event_id,
room_id: roomId,
sender: mEvent.getSender() ?? '',
});
if (evt.isEncrypted()) {
await mx.decryptEventIfNeeded(evt);
}
return evt;
}),
),
[room, roomId, mEvent, mx],
);
return { events, hasMore: !!res.next_batch };
}, [mx, roomId, eventId, room, mEvent]),
const [historyState, fetchHistory] = useAsyncCallback<void, unknown, [string | undefined]>(
useCallback(
async (from?: string) => {
if (!eventId) return;
// Relations API lives at /_matrix/client/v1/ (not v3); use raw fetch to avoid SDK prefix
const token = mx.getAccessToken();
const baseUrl = mx.getHomeserverUrl();
const fromParam = from ? `&from=${encodeURIComponent(from)}` : '';
const url = `${baseUrl}/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}/m.replace?limit=50${fromParam}`;
const fetchRes = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
const res = (await fetchRes.json()) as EditHistoryResponse;
const newEvents = await parseRawEvents(res.chunk ?? []);
// Merge with prior pages, de-dupe by event id, sort chronologically so
// page ordering across batches is always correct.
setEdits((prev) => {
const byId = new Map<string, MatrixEvent>();
[...prev, ...newEvents].forEach((evt) => {
const id = evt.getId();
if (id) byId.set(id, evt);
});
return Array.from(byId.values()).sort((a, b) => a.getTs() - b.getTs());
});
setNextBatch(res.next_batch);
},
[mx, roomId, eventId, parseRawEvents],
),
);
useEffect(() => {
fetchHistory().catch(() => undefined);
fetchHistory(undefined).catch(() => undefined);
}, [fetchHistory]);
const initialLoading = historyState.status === AsyncStatus.Loading && edits.length === 0;
const loadingMore = historyState.status === AsyncStatus.Loading && edits.length > 0;
const formatTs = (ts: number): string => {
const time = timeHourMinute(ts, hour24Clock);
const date = timeDayMonYear(ts, dateFormatString);
@@ -167,6 +188,7 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
role="dialog"
aria-modal="true"
aria-labelledby="edit-history-title"
style={modalStyle}
>
<Header
variant="Surface"
@@ -192,7 +214,7 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
paddingBottom: config.space.S700,
}}
>
{historyState.status === AsyncStatus.Loading && (
{initialLoading && (
<Box
justifyContent="Center"
alignItems="Center"
@@ -201,12 +223,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
<Spinner size="200" />
</Box>
)}
{historyState.status === AsyncStatus.Error && (
{historyState.status === AsyncStatus.Error && edits.length === 0 && (
<Text size="T300" priority="300">
Failed to load edit history.
</Text>
)}
{historyState.status === AsyncStatus.Success && (
{!initialLoading && historyState.status !== AsyncStatus.Error && (
<Box direction="Column" gap="300">
<Box direction="Column" gap="100">
<Box gap="200" alignItems="Center">
@@ -220,11 +242,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
</Text>
</Box>
{historyState.data.events.map((editEvt, index) => (
{edits.map((editEvt, index) => (
<Box key={editEvt.getId() ?? index} direction="Column" gap="100">
<Box gap="200" alignItems="Center">
<Text size="L400">
{index === historyState.data.events.length - 1
{index === edits.length - 1
? `Edit ${index + 1} (current)`
: `Edit ${index + 1}`}
</Text>
@@ -241,17 +263,27 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
</Box>
))}
{historyState.data.events.length === 0 && (
{edits.length === 0 && (
<Text size="T300" priority="300">
No edit history found.
</Text>
)}
{historyState.data.hasMore && (
{nextBatch && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
<Text size="T200" priority="300">
Showing the 50 most recent edits
</Text>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
disabled={loadingMore}
before={
loadingMore ? <Spinner size="100" variant="Secondary" /> : undefined
}
onClick={() => fetchHistory(nextBatch).catch(() => undefined)}
>
<Text size="B300">Load more</Text>
</Button>
</Box>
)}
</Box>
@@ -4,6 +4,10 @@ import {
Avatar,
Box,
config,
Header,
Icon,
IconButton,
Icons,
Input,
Line,
MenuItem,
@@ -19,6 +23,7 @@ import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { stopPropagation } from '../../../utils/keyboard';
import { useModalStyle } from '../../../hooks/useModalStyle';
import { mDirectAtom } from '../../../state/mDirectList';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../../utils/matrix';
@@ -78,6 +83,7 @@ type Props = {
export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(400);
const directs = useAtomValue(mDirectAtom);
const useAuthentication = useMediaAuthentication();
const [query, setQuery] = useState('');
@@ -129,16 +135,25 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
borderRadius: config.radii.R500,
display: 'flex',
flexDirection: 'column',
...modalStyle,
}}
>
<Box
direction="Column"
gap="200"
shrink="No"
style={{ padding: config.space.S400, paddingBottom: config.space.S200 }}
<Header
variant="Surface"
size="500"
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
>
<Text size="H5">Forward message</Text>
{!sentTo && (
<Box grow="Yes">
<Text as="h2" size="H4" truncate>
Forward message
</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
{!sentTo && (
<Box shrink="No" style={{ padding: `${config.space.S200} ${config.space.S400}` }}>
<Input
variant="Background"
size="400"
@@ -148,8 +163,8 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
/>
)}
</Box>
</Box>
)}
<Line size="300" />
{sentTo ? (
<Box
+42 -21
View File
@@ -7,6 +7,7 @@ import {
Header,
Icon,
IconButton,
IconSrc,
Icons,
Input,
Line,
@@ -81,6 +82,7 @@ import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { ForwardMessageDialog } from './ForwardMessageDialog';
import { RemindMeDialog } from './RemindMeDialog';
import { useBookmarks } from '../../../hooks/useBookmarks';
import { PresenceRingAvatar } from '../../../components/presence';
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
@@ -94,23 +96,20 @@ function DeliveryStatus({
lotusTerminal: boolean;
}) {
if (status === null) return null; // confirmed by server — read receipts take over
let icon: string;
let iconSrc: IconSrc;
let label: string;
let colorStyle: string;
const isSending = status === EventStatus.SENDING || status === EventStatus.ENCRYPTING;
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
icon = '✕';
iconSrc = Icons.Cross;
label = 'Failed to send';
colorStyle = lotusTerminal ? '#FF3B3B' : color.Critical.Main;
} else if (status === EventStatus.QUEUED) {
icon = '⟳';
label = 'Queued';
colorStyle = lotusTerminal ? 'rgba(0,212,255,0.45)' : color.Secondary.Main;
} else if (status === EventStatus.SENDING || status === EventStatus.ENCRYPTING) {
icon = '⟳';
label = 'Sending...';
} else if (status === EventStatus.QUEUED || isSending) {
iconSrc = Icons.Send;
label = isSending ? 'Sending...' : 'Queued';
colorStyle = lotusTerminal ? 'rgba(0,212,255,0.60)' : color.Secondary.Main;
} else {
icon = '✓';
iconSrc = Icons.Check;
label = 'Sent';
colorStyle = lotusTerminal ? 'rgba(0,212,255,0.70)' : color.Secondary.Main;
}
@@ -123,7 +122,6 @@ function DeliveryStatus({
display: 'inline-flex',
alignItems: 'center',
marginTop: '2px',
fontSize: '10px',
lineHeight: 1,
color: colorStyle,
opacity: 0.85,
@@ -133,14 +131,8 @@ function DeliveryStatus({
: {}),
}}
>
<span
className={
status === EventStatus.SENDING || status === EventStatus.ENCRYPTING
? SendingSpinClass
: undefined
}
>
{icon}
<span className={isSending ? SendingSpinClass : undefined}>
<Icon size="100" src={iconSrc} />
</span>
</Box>
);
@@ -156,7 +148,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
const mx = useMatrixClient();
const recentEmojis = useRecentEmoji(mx, 3);
if (recentEmojis.length === 0) return <span />;
if (recentEmojis.length === 0) return null;
return (
<>
<Box
@@ -809,6 +801,7 @@ export const Message = React.memo(
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
const [forwardOpen, setForwardOpen] = useState(false);
const [remindOpen, setRemindOpen] = useState(false);
const { addBookmark, removeBookmark, isBookmarked } = useBookmarks();
const senderDisplayName =
@@ -1145,7 +1138,7 @@ export const Message = React.memo(
{!mEvent.isRedacted() && (
<MenuItem
size="300"
after={<Icon src={Icons.ArrowRight} />}
after={<Icon size="100" src={Icons.ArrowRight} />}
radii="300"
onClick={() => {
setForwardOpen(true);
@@ -1204,6 +1197,26 @@ export const Message = React.memo(
</Text>
</MenuItem>
)}
{!mEvent.isRedacted() && mEvent.getId() && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Clock} />}
radii="300"
onClick={() => {
setRemindOpen(true);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Remind Me
</Text>
</MenuItem>
)}
{!isThreadedMessage && (
<MenuItem
size="300"
@@ -1372,6 +1385,14 @@ export const Message = React.memo(
{forwardOpen && (
<ForwardMessageDialog mEvent={mEvent} onClose={() => setForwardOpen(false)} />
)}
{remindOpen && mEvent.getId() && (
<RemindMeDialog
roomId={room.roomId}
eventId={mEvent.getId()!}
previewText={(mEvent.getContent()?.body as string | undefined)?.slice(0, 120) ?? ''}
onClose={() => setRemindOpen(false)}
/>
)}
</MessageBase>
);
},
@@ -0,0 +1,124 @@
import React, { useMemo } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Line,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import { stopPropagation } from '../../../utils/keyboard';
import { useReminders } from '../../../hooks/useReminders';
import { useModalStyle } from '../../../hooks/useModalStyle';
type RemindMeDialogProps = {
roomId: string;
eventId: string;
previewText: string;
onClose: () => void;
};
function getPresets(): Array<{ label: string; ms: number }> {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
const timeLabel = tomorrow.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return [
{ label: 'In 20 minutes', ms: 20 * 60_000 },
{ label: 'In 1 hour', ms: 60 * 60_000 },
{ label: 'In 3 hours', ms: 3 * 60 * 60_000 },
{ label: `Tomorrow at ${timeLabel}`, ms: tomorrow.getTime() - Date.now() },
];
}
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
const modalStyle = useModalStyle(320);
const { addReminder } = useReminders();
const presets = useMemo(() => getPresets(), []);
const handlePick = async (ms: number) => {
await addReminder({
roomId,
eventId,
timestamp: Date.now() + ms,
message: previewText || 'Reminder',
});
onClose();
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="remind-me-title"
style={modalStyle}
>
<Header
variant="Surface"
size="500"
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Clock} size="100" />
<Text id="remind-me-title" size="H4">
Remind Me
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
{previewText && (
<>
<Box style={{ padding: `${config.space.S200} ${config.space.S400}` }}>
<Text size="T200" priority="300" truncate>
{previewText}
</Text>
</Box>
<Line size="300" />
</>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
{presets.map((p) => (
<Button
key={p.label}
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => handlePick(p.ms)}
>
<Text size="B300" truncate>
{p.label}
</Text>
</Button>
))}
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
+3 -3
View File
@@ -50,7 +50,7 @@ export const getImageMsgContent = async (
): Promise<IContent> => {
const { file, originalFile, encInfo, metadata } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
if (imgError) console.warn(imgError);
if (imgError) console.warn('Failed to load image element:', imgError.message);
const content: IContent = {
msgtype: MsgType.Image,
@@ -85,7 +85,7 @@ export const getVideoMsgContent = async (
const { file, originalFile, encInfo, metadata } = item;
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
if (videoError) console.warn(videoError);
if (videoError) console.warn('Failed to load video element:', videoError.message);
const content: IContent = {
msgtype: MsgType.Video,
@@ -109,7 +109,7 @@ export const getVideoMsgContent = async (
scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight),
);
}
if (thumbError) console.warn(thumbError);
if (thumbError) console.warn('Failed to generate video thumbnail:', thumbError.message);
content.info = {
...getVideoInfo(videoEl, file),
...thumbContent,
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Spinner } from 'folds';
import { Box, Button, Text, Spinner, color } from 'folds';
import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
@@ -170,51 +170,36 @@ export function ProfileDecoration() {
: 'None'}
</Text>
{selected && (
<button
<Button
type="button"
size="300"
radii="300"
variant="Critical"
fill="None"
onClick={handleClear}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--tc-surface-low-contrast)',
fontSize: '0.8rem',
textAlign: 'left',
}}
>
Remove
</button>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
{hasChanges && (
<button
<Button
type="button"
size="400"
radii="300"
variant="Success"
fill="Solid"
onClick={handleSave}
disabled={saving}
style={{
padding: '6px 14px',
borderRadius: 6,
border: '1px solid var(--accent-cyan)',
background: 'transparent',
color: 'var(--accent-cyan)',
cursor: saving ? 'not-allowed' : 'pointer',
fontSize: '0.85rem',
display: 'flex',
alignItems: 'center',
gap: 6,
opacity: saving ? 0.6 : 1,
flexShrink: 0,
}}
before={saving ? <Spinner size="100" variant="Success" /> : undefined}
>
{saving && <Spinner size="100" variant="Secondary" />}
{saving ? 'Saving…' : 'Save'}
</button>
<Text size="B300">{saving ? 'Saving…' : 'Save'}</Text>
</Button>
)}
</Box>
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save. Try again.
</Text>
)}
@@ -0,0 +1,31 @@
import { style } from '@vanilla-extract/css';
import { color, config } from 'folds';
// Shared base for the chat-background / seasonal-theme preview swatches. These
// are inherently custom tiles (they render a live background preview), so they
// can't be a folds MenuItem/Chip — but the chrome (radius, border, hover, and a
// proper keyboard focus ring) now comes from design tokens instead of raw
// rgba/px inline styles. Size + the preview background stay inline per-swatch.
export const BgSwatch = style({
display: 'block',
padding: 0,
cursor: 'pointer',
overflow: 'hidden',
borderRadius: config.radii.R300,
borderWidth: config.borderWidth.B400,
borderStyle: 'solid',
borderColor: color.SurfaceVariant.ContainerLine,
transition: 'border-color 100ms ease-in-out',
selectors: {
'&:hover': {
borderColor: color.Primary.ContainerLine,
},
'&:focus-visible': {
outline: `${config.borderWidth.B400} solid ${color.Primary.Main}`,
outlineOffset: config.space.S100,
},
'&[data-selected="true"]': {
borderColor: color.Primary.Main,
},
},
});
@@ -0,0 +1,391 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Text } from 'folds';
import { DenoiseModelId } from '../../../state/settings';
import { DENOISE_MODELS } from '../../../utils/lotusDenoiseUtils';
import {
DenoiseNode,
buildGateNode,
buildModelNode,
readDb,
sampleRateFor,
} from '../../../utils/denoisePipeline';
const MAX_RECORD_MS = 6000;
// Live monitor mirrors the call's capture (respects the user's native-NS choice).
const MIC_CONSTRAINTS = (nativeNS: boolean): MediaStreamConstraints => ({
audio: {
noiseSuppression: nativeNS,
channelCount: 1,
echoCancellation: true,
autoGainControl: true,
},
});
// Record & compare captures fully RAW audio (no browser noise suppression / AGC
// / echo cancel) so each model's effect on real background noise is audible.
// Capturing with native NS on would pre-clean the clip and make Raw/RNNoise/
// Speex sound identical.
const RAW_CONSTRAINTS: MediaStreamConstraints = {
audio: {
noiseSuppression: false,
echoCancellation: false,
autoGainControl: false,
channelCount: 1,
},
};
/** A -100..0 dBFS bar with optional threshold marker. */
function DbMeter({ label, db, threshold }: { label: string; db: number; threshold?: number }) {
const pct = Math.max(0, Math.min(100, db + 100));
const markerPct = threshold === undefined ? null : Math.max(0, Math.min(100, threshold + 100));
return (
<Box direction="Column" gap="100">
<Box direction="Row" justifyContent="SpaceBetween">
<Text size="T200">{label}</Text>
<Text size="T200">{db <= -100 ? '−∞ dB' : `${Math.round(db)} dB`}</Text>
</Box>
<Box
style={{
position: 'relative',
height: '12px',
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
overflow: 'hidden',
}}
>
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: `${pct}%`,
background: 'var(--accent-green)',
transition: 'width 0.05s linear',
}}
/>
{markerPct !== null && (
<Box
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `${markerPct}%`,
width: '2px',
background: 'var(--accent-orange)',
}}
/>
)}
</Box>
</Box>
);
}
type DenoiseTesterProps = {
model: DenoiseModelId;
useGate: boolean;
gateThreshold: number;
nativeNS: boolean;
};
export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: DenoiseTesterProps) {
// ── Live loopback monitor ────────────────────────────────────────────────
const liveRef = useRef<{
ctx: AudioContext;
stream: MediaStream;
model: DenoiseNode;
gate: AudioWorkletNode | null;
raf: number;
inAnalyser: AnalyserNode;
outAnalyser: AnalyserNode;
} | null>(null);
const [live, setLive] = useState(false);
const [inDb, setInDb] = useState(-100);
const [outDb, setOutDb] = useState(-100);
const stopLive = useCallback(() => {
const s = liveRef.current;
liveRef.current = null;
if (s) {
cancelAnimationFrame(s.raf);
try {
s.gate?.disconnect();
s.model.dispose();
s.model.node.disconnect();
} catch {
/* noop */
}
s.stream.getTracks().forEach((t) => t.stop());
s.ctx.close().catch(() => undefined);
}
setLive(false);
setInDb(-100);
setOutDb(-100);
}, []);
const startLive = async () => {
try {
const ctx = new AudioContext({ sampleRate: sampleRateFor(model) });
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS(nativeNS));
const source = ctx.createMediaStreamSource(stream);
const inAnalyser = ctx.createAnalyser();
inAnalyser.fftSize = 1024;
source.connect(inAnalyser);
let head: AudioNode = source;
let gate: AudioWorkletNode | null = null;
if (useGate) {
gate = await buildGateNode(ctx, gateThreshold);
head.connect(gate);
head = gate;
}
const denoise = await buildModelNode(ctx, model);
head.connect(denoise.node);
const outAnalyser = ctx.createAnalyser();
outAnalyser.fftSize = 1024;
denoise.node.connect(outAnalyser);
denoise.node.connect(ctx.destination); // loopback to speakers
const tick = () => {
setInDb(readDb(inAnalyser));
setOutDb(readDb(outAnalyser));
if (liveRef.current) liveRef.current.raf = requestAnimationFrame(tick);
};
liveRef.current = {
ctx,
stream,
model: denoise,
gate,
raf: 0,
inAnalyser,
outAnalyser,
};
setLive(true);
tick();
} catch (e) {
console.error('[denoise-tester] live monitor failed', e);
stopLive();
}
};
// ── Record & compare ─────────────────────────────────────────────────────
const recRef = useRef<{
ctx: AudioContext;
stream: MediaStream;
recorder: MediaRecorder;
chunks: BlobPart[];
raf: number;
analyser: AnalyserNode;
timer: number;
} | null>(null);
const clipRef = useRef<AudioBuffer | null>(null);
const playRef = useRef<{ ctx: AudioContext; source: AudioBufferSourceNode } | null>(null);
const [recording, setRecording] = useState(false);
const [recDb, setRecDb] = useState(-100);
const [hasClip, setHasClip] = useState(false);
const [playing, setPlaying] = useState<string | null>(null);
const teardownRecorder = () => {
const r = recRef.current;
recRef.current = null;
if (r) {
cancelAnimationFrame(r.raf);
window.clearTimeout(r.timer);
r.stream.getTracks().forEach((t) => t.stop());
r.ctx.close().catch(() => undefined);
}
};
const stopRecord = useCallback(() => {
const r = recRef.current;
if (r && r.recorder.state !== 'inactive') r.recorder.stop();
setRecording(false);
}, []);
const startRecord = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia(RAW_CONSTRAINTS);
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
source.connect(analyser);
const recorder = new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunks.push(ev.data);
};
recorder.onstop = async () => {
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
teardownRecorder();
setRecDb(-100);
try {
const decodeCtx = new AudioContext({ sampleRate: 48000 });
clipRef.current = await decodeCtx.decodeAudioData(await blob.arrayBuffer());
decodeCtx.close().catch(() => undefined);
setHasClip(true);
} catch (e) {
console.error('[denoise-tester] decode failed', e);
}
};
const tick = () => {
setRecDb(readDb(analyser));
if (recRef.current) recRef.current.raf = requestAnimationFrame(tick);
};
const timer = window.setTimeout(stopRecord, MAX_RECORD_MS);
recRef.current = { ctx, stream, recorder, chunks, raf: 0, analyser, timer };
recorder.start();
setRecording(true);
tick();
} catch (e) {
console.error('[denoise-tester] record failed', e);
teardownRecorder();
setRecording(false);
}
};
const stopPlayback = useCallback(() => {
const p = playRef.current;
playRef.current = null;
if (p) {
try {
p.source.onended = null;
p.source.stop();
} catch {
/* noop */
}
p.ctx.close().catch(() => undefined);
}
setPlaying(null);
}, []);
const play = async (label: string, playModel: DenoiseModelId | null) => {
stopPlayback();
const clip = clipRef.current;
if (!clip) return;
try {
// bufferSource auto-resamples the 48 kHz clip to the context rate, so DTLN
// gets the 16 kHz it needs while raw/RNNoise/Speex stay at 48 kHz.
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
const source = ctx.createBufferSource();
source.buffer = clip;
if (playModel) {
let head: AudioNode = source;
if (useGate) {
const gate = await buildGateNode(ctx, gateThreshold);
head.connect(gate);
head = gate;
}
const denoise = await buildModelNode(ctx, playModel);
head.connect(denoise.node);
denoise.node.connect(ctx.destination);
} else {
source.connect(ctx.destination);
}
source.onended = () => {
if (playRef.current?.ctx === ctx) stopPlayback();
};
playRef.current = { ctx, source };
source.start();
setPlaying(label);
} catch (e) {
console.error('[denoise-tester] playback failed', e);
stopPlayback();
}
};
useEffect(
() => () => {
stopLive();
stopPlayback();
teardownRecorder();
},
[stopLive, stopPlayback],
);
const modelLabel = DENOISE_MODELS.find((m) => m.id === model)?.name ?? model;
return (
<Box direction="Column" gap="400">
{/* Live monitor */}
<Box direction="Column" gap="200">
<Text size="T300">Live monitor</Text>
<Text size="T200" priority="300">
Hear yourself through {modelLabel}
{useGate ? ' + gate' : ''} in real time. Use headphones to avoid feedback. Watch the In
meter while you speak and while silent set the gate threshold (orange line) just above
your silent level.
</Text>
<Box direction="Row" gap="200" alignItems="Center">
<Button
size="300"
variant={live ? 'Critical' : 'Secondary'}
outlined
onClick={live ? stopLive : startLive}
>
<Text size="T300">{live ? 'Stop' : 'Start monitor'}</Text>
</Button>
</Box>
{live && (
<Box direction="Column" gap="200">
<DbMeter label="In (raw)" db={inDb} threshold={useGate ? gateThreshold : undefined} />
<DbMeter label={`Out (${modelLabel})`} db={outDb} />
</Box>
)}
</Box>
{/* Record & compare */}
<Box direction="Column" gap="200">
<Text size="T300">Record &amp; compare</Text>
<Text size="T200" priority="300">
Record up to {MAX_RECORD_MS / 1000}s of yourself with your usual background noise, then
play the same clip back raw vs through each model to A/B them. Captured fully raw (browser
noise suppression off) so each model&apos;s effect is audible; uses the gate when enabled
above.
</Text>
<Box direction="Row" gap="200" alignItems="Center">
<Button
size="300"
variant={recording ? 'Critical' : 'Secondary'}
outlined
onClick={recording ? stopRecord : startRecord}
>
<Text size="T300">{recording ? 'Stop recording' : 'Record clip'}</Text>
</Button>
{recording && <Text size="T200">Recording</Text>}
</Box>
{recording && <DbMeter label="Recording level" db={recDb} />}
{hasClip && !recording && (
<Box direction="Row" gap="200" wrap="Wrap">
{(
[
{ label: 'Raw', model: null },
{ label: 'RNNoise', model: 'rnnoise' },
{ label: 'Speex', model: 'speex' },
{ label: 'DTLN', model: 'dtln' },
{ label: 'DeepFilterNet', model: 'deepfilternet' },
] as const
).map((b) => (
<Button
key={b.label}
size="300"
variant={playing === b.label ? 'Primary' : 'Secondary'}
outlined
onClick={() => (playing === b.label ? stopPlayback() : play(b.label, b.model))}
>
<Text size="T300">
{playing === b.label ? `Stop ${b.label}` : `Play ${b.label}`}
</Text>
</Button>
))}
</Box>
)}
</Box>
</Box>
);
}
+258 -238
View File
@@ -32,7 +32,10 @@ import {
toRem,
} from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { HexColorPicker } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import {
@@ -75,6 +78,7 @@ import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds';
import { DenoiseTester } from './DenoiseTester';
type ThemeSelectorProps = {
themeNames: Record<string, string>;
@@ -122,7 +126,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
<>
<Button
size="300"
variant="Primary"
variant="Secondary"
outlined
fill="Soft"
radii="300"
@@ -431,6 +435,7 @@ function Appearance() {
settingsAtom,
'seasonalThemeOverride',
);
const [, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
return (
<Box direction="Column" gap="100">
@@ -511,7 +516,10 @@ function Appearance() {
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
<SeasonalBgGrid
value={seasonalThemeOverride ?? 'auto'}
onChange={(v) => setSeasonalThemeOverride(v)}
onChange={(v) => {
setSeasonalThemeOverride(v);
if (v !== 'auto' && v !== 'off') setChatBackground('none');
}}
/>
</Box>
</SequenceCard>
@@ -544,7 +552,7 @@ function Appearance() {
gap="100"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
<Text size="T200" style={{ opacity: 0.7 }}>
<Text size="T200" priority="300">
Intensity: {nightLightOpacity}%
</Text>
<input
@@ -555,7 +563,7 @@ function Appearance() {
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNightLightOpacity(parseInt(e.target.value, 10))
}
style={{ width: '100%' }}
style={{ width: '100%', accentColor: color.Primary.Main }}
/>
</Box>
)}
@@ -635,38 +643,42 @@ function Appearance() {
title="@Mention Highlight Color"
description="Color used to highlight messages that mention you. Leave empty to use the theme default."
after={
<Box alignItems="Center" gap="200">
<input
type="color"
value={mentionHighlightColor || '#4caf50'}
onChange={(e) => setMentionHighlightColor(e.target.value)}
style={{
width: '36px',
height: '28px',
cursor: 'pointer',
borderRadius: '4px',
border: 'none',
padding: '2px',
}}
/>
{mentionHighlightColor && (
<button
<HexColorPickerPopOut
picker={
<HexColorPicker
color={mentionHighlightColor || '#4caf50'}
onChange={setMentionHighlightColor}
/>
}
onRemove={mentionHighlightColor ? () => setMentionHighlightColor('') : undefined}
>
{(openPicker, opened) => (
<Button
type="button"
onClick={() => setMentionHighlightColor('')}
style={{
background: 'none',
border: '1px solid var(--border-interactive-normal)',
borderRadius: '6px',
padding: '2px 8px',
cursor: 'pointer',
color: 'inherit',
fontSize: '12px',
}}
aria-pressed={opened}
onClick={openPicker}
size="300"
variant="Secondary"
fill="Soft"
radii="300"
before={
<span
style={{
width: toRem(16),
height: toRem(16),
borderRadius: config.radii.R300,
background: mentionHighlightColor || color.Primary.Main,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
display: 'inline-block',
flexShrink: 0,
}}
/>
}
>
Reset
</button>
<Text size="B300">{mentionHighlightColor ? 'Change' : 'Pick'}</Text>
</Button>
)}
</Box>
</HexColorPickerPopOut>
}
/>
</SequenceCard>
@@ -1203,91 +1215,6 @@ function useKeyBind(setter: (code: string) => void) {
const keyLabel = (code: string) =>
code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
function MicMeter() {
const [level, setLevel] = useState(0);
const [active, setActive] = useState(false);
const streamRef = useRef<MediaStream | null>(null);
const ctxRef = useRef<AudioContext | null>(null);
const rafRef = useRef<number | null>(null);
const stop = useCallback(() => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
ctxRef.current?.close();
ctxRef.current = null;
setActive(false);
setLevel(0);
}, []);
const start = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const ctx = new AudioContext();
ctxRef.current = ctx;
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
const buffer = new Uint8Array(analyser.frequencyBinCount);
const update = () => {
analyser.getByteFrequencyData(buffer);
let sum = 0;
for (let i = 0; i < buffer.length; i += 1) sum += buffer[i];
setLevel(sum / buffer.length);
rafRef.current = requestAnimationFrame(update);
};
update();
setActive(true);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Mic test failed', e);
}
};
useEffect(() => () => stop(), [stop]);
return (
<Box direction="Column" gap="100" style={{ padding: '8px 0' }}>
<Box direction="Row" gap="200" alignItems="Center">
<Button size="300" variant="Secondary" outlined onClick={active ? stop : start}>
<Text size="T300">{active ? 'Stop Test' : 'Test Microphone'}</Text>
</Button>
<Box
grow="Yes"
style={{
height: '10px',
background: 'var(--lt-bg-card, rgba(0,0,0,0.2))',
borderRadius: '5px',
overflow: 'hidden',
position: 'relative',
border: '1px solid var(--lt-border-color)',
}}
>
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: `${Math.min(100, (level / 128) * 100)}%`,
background: 'var(--lt-accent-green, #00FF88)',
transition: 'width 0.05s linear',
boxShadow: '0 0 8px var(--lt-accent-green)',
}}
/>
</Box>
</Box>
<Text size="T200" priority="300">
The green bar shows your live volume. Use this to tune the Gate Threshold.
</Text>
</Box>
);
}
function Calls() {
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(
@@ -1314,6 +1241,7 @@ function Calls() {
settingsAtom,
'callJoinLeaveSound',
);
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
setCallJoinLeaveSound(value);
@@ -1324,6 +1252,7 @@ function Calls() {
const deafenBind = useKeyBind(setDeafenKey);
const mlSupported = isMLDenoiseSupported();
const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel);
return (
<Box direction="Column" gap="100">
@@ -1342,46 +1271,9 @@ function Calls() {
<Box direction="Column" gap="200">
<Text>
Filter background noise from your mic during calls. Browser-native uses the built-in
WebRTC suppressor (Google NSNet2).
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
</Text>
<Box direction="Column" gap="100" style={{ overflowX: 'auto' }}>
<Box
direction="Row"
gap="100"
style={{ borderBottom: '1px solid var(--lt-border-color)', paddingBottom: '4px' }}
>
<Box style={{ width: '120px' }}>
<Text size="T200">Model</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200">CPU</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200">Quality</Text>
</Box>
<Box grow="Yes">
<Text size="T200">Transients</Text>
</Box>
</Box>
{DENOISE_MODELS.map((model) => (
<Box key={model.id} direction="Row" gap="100">
<Box style={{ width: '120px' }}>
<Text size="T200">{model.name}</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200">{model.cpuUsage}</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200">{model.voiceQuality}</Text>
</Box>
<Box grow="Yes">
<Text size="T200">{model.transients}</Text>
</Box>
</Box>
))}
</Box>
{!mlSupported && (
<Box direction="Column" gap="100">
<Text size="T200" priority="400">
@@ -1396,11 +1288,6 @@ function Calls() {
</Box>
</Box>
)}
{callNoiseSuppression === 'ml' && (
<Text size="T200" priority="400">
Note: Applying changes requires rejoining the call.
</Text>
)}
</Box>
}
after={
@@ -1423,68 +1310,187 @@ function Calls() {
{callNoiseSuppression === 'ml' && (
<Box
direction="Column"
gap="300"
gap="400"
style={{
padding: '16px',
marginTop: '8px',
borderTop: '1px solid var(--lt-border-color)',
background: 'rgba(0,0,0,0.1)',
borderTop: '1px solid var(--border-color)',
background: 'var(--bg-card)',
}}
>
<SettingTile
title="ML Model"
description="Choose the machine learning model to use for noise removal."
after={
<SettingsSelect<DenoiseModelId>
value={callDenoiseModel}
onChange={setCallDenoiseModel}
options={[
{ value: 'rnnoise', label: 'RNNoise' },
{ value: 'speex', label: 'Speex (Legacy)' },
{ value: 'dtln', label: 'DTLN (beta)' },
]}
/>
}
/>
<SettingTile
title="Series Suppression"
description="Run the browser's native stationary noise filter before the ML model. Recommended for eliminating fan hum."
after={
<Switch
variant="Primary"
value={callDenoiseNativeNS}
onChange={setCallDenoiseNativeNS}
/>
}
/>
<SettingTile
title="Noise Gate"
description="Hard-cut audio when you aren't speaking to ensure absolute silence between sentences."
after={
<Switch variant="Primary" value={callDenoiseGate} onChange={setCallDenoiseGate} />
}
/>
{callDenoiseGate && (
<Box direction="Column" gap="100">
<Box direction="Row" justifyContent="SpaceBetween">
<Text size="T200">Gate Threshold</Text>
<Text size="T200">{callDenoiseGateThreshold} dB</Text>
{/* ── Model selection ───────────────────────────────────────── */}
<Box direction="Column" gap="200">
<Text size="L400">Model</Text>
<SettingTile
title="ML Model"
description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
after={
<SettingsSelect<DenoiseModelId>
value={callDenoiseModel}
onChange={setCallDenoiseModel}
options={DENOISE_MODELS.map((m) => ({ value: m.id, label: m.name }))}
/>
}
/>
{selectedDenoiseModel && (
<Box
direction="Column"
gap="200"
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border-color)',
background: 'var(--bg-input)',
}}
>
<Text size="T300">{selectedDenoiseModel.name}</Text>
<Text size="T200" priority="300">
{selectedDenoiseModel.description}
</Text>
<Box direction="Row" gap="400" wrap="Wrap">
{(
[
{ label: 'CPU', value: selectedDenoiseModel.cpuUsage },
{ label: 'Voice quality', value: selectedDenoiseModel.voiceQuality },
{ label: 'Transients', value: selectedDenoiseModel.transients },
{ label: 'Download', value: selectedDenoiseModel.binarySize },
] as const
).map((stat) => (
<Box key={stat.label} direction="Column" gap="100">
<Text size="T200" priority="300">
{stat.label}
</Text>
<Text size="T300">{stat.value}</Text>
</Box>
))}
</Box>
</Box>
<input
type="range"
min="-100"
max="0"
step="1"
value={callDenoiseGateThreshold}
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
style={{ width: '100%', accentColor: 'var(--lt-accent-orange)' }}
/>
<MicMeter />
</Box>
)}
)}
<details>
<summary style={{ cursor: 'pointer' }}>
<Text as="span" size="T200" priority="300">
Compare all models
</Text>
</summary>
<Box direction="Column" gap="100" style={{ overflowX: 'auto', marginTop: '8px' }}>
<Box
direction="Row"
gap="100"
style={{
borderBottom: '1px solid var(--border-color)',
paddingBottom: '4px',
}}
>
<Box style={{ width: '160px' }}>
<Text size="T200" priority="300">
Model
</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200" priority="300">
CPU
</Text>
</Box>
<Box style={{ width: '90px' }}>
<Text size="T200" priority="300">
Quality
</Text>
</Box>
<Box grow="Yes">
<Text size="T200" priority="300">
Transients
</Text>
</Box>
</Box>
{DENOISE_MODELS.map((model) => (
<Box key={model.id} direction="Row" gap="100">
<Box style={{ width: '160px' }}>
<Text size="T200">{model.name}</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200">{model.cpuUsage}</Text>
</Box>
<Box style={{ width: '90px' }}>
<Text size="T200">{model.voiceQuality}</Text>
</Box>
<Box grow="Yes">
<Text size="T200">{model.transients}</Text>
</Box>
</Box>
))}
</Box>
</details>
<Text size="T200" priority="400">
Note: Applying changes requires rejoining the call.
</Text>
</Box>
{/* ── Enhancement toggles ───────────────────────────────────── */}
<Box
direction="Column"
gap="300"
style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }}
>
<Text size="L400">Enhancements</Text>
<SettingTile
title="Series Suppression"
description="Run the browser's native stationary noise filter before the ML model. Recommended for eliminating fan hum."
after={
<Switch
variant="Primary"
value={callDenoiseNativeNS}
onChange={setCallDenoiseNativeNS}
/>
}
/>
<SettingTile
title="Noise Gate"
description="Hard-cut audio when you aren't speaking to ensure absolute silence between sentences."
after={
<Switch variant="Primary" value={callDenoiseGate} onChange={setCallDenoiseGate} />
}
/>
{callDenoiseGate && (
<Box direction="Column" gap="100">
<Box direction="Row" justifyContent="SpaceBetween">
<Text size="T200">Gate Threshold</Text>
<Text size="T200">{callDenoiseGateThreshold} dB</Text>
</Box>
<input
type="range"
min="-100"
max="0"
step="1"
value={callDenoiseGateThreshold}
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
style={{ width: '100%', accentColor: 'var(--accent-orange)' }}
/>
</Box>
)}
</Box>
{/* ── Test & calibrate ──────────────────────────────────────── */}
<Box
direction="Column"
gap="200"
style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }}
>
<Text size="L400">Test &amp; calibrate</Text>
<Text size="T200" priority="300">
Audition the selected model and tune the gate without joining a call. Changes to the
settings above apply the next time you start a monitor or play a clip.
</Text>
<DenoiseTester
model={callDenoiseModel}
useGate={callDenoiseGate}
gateThreshold={callDenoiseGateThreshold}
nativeNS={callDenoiseNativeNS}
/>
</Box>
</Box>
)}
</SequenceCard>
@@ -1567,10 +1573,33 @@ function Calls() {
/>
)}
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Ringtone Volume"
description="Volume of the incoming call ringtone."
after={
<Box direction="Row" alignItems="Center" gap="200" style={{ minWidth: '160px' }}>
<input
type="range"
min="0"
max="100"
step="5"
value={ringtoneVolume}
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
aria-label="Ringtone volume"
style={{ flex: 1, accentColor: 'var(--accent-orange)' }}
/>
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
{ringtoneVolume}%
</Text>
</Box>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Join & Leave Sounds"
description="Play a sound when someone joins or leaves a call you are in."
description="Play a sound when someone joins or leaves a call you are in. Selecting an option plays a preview."
after={
<SettingsSelect
value={callJoinLeaveSound}
@@ -1627,19 +1656,13 @@ function SeasonalBgGrid({
type="button"
aria-label={opt.label}
aria-pressed={selected}
data-selected={selected}
className={BgSwatchStyle}
onClick={() => onChange(opt.value)}
style={{
position: 'relative',
display: 'block',
width: toRem(76),
height: toRem(56),
borderRadius: toRem(8),
cursor: 'pointer',
border: selected
? `2px solid ${color.Critical.Main}`
: '2px solid rgba(128,128,128,0.25)',
padding: 0,
overflow: 'hidden',
backgroundColor: '#030508',
}}
>
@@ -1665,7 +1688,7 @@ function SeasonalBgGrid({
)}
</div>
</button>
<Text size="T200" style={selected ? { color: color.Critical.Main } : undefined}>
<Text size="T200" style={selected ? { color: color.Primary.Main } : undefined}>
{opt.label}
</Text>
</Box>
@@ -1677,6 +1700,7 @@ function SeasonalBgGrid({
function ChatBgGrid() {
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
const [, setSeasonalThemeOverride] = useSetting(settingsAtom, 'seasonalThemeOverride');
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark;
@@ -1689,25 +1713,21 @@ function ChatBgGrid() {
type="button"
aria-label={opt.label}
aria-pressed={chatBackground === opt.value}
onClick={() => setChatBackground(opt.value as ChatBackground)}
data-selected={chatBackground === opt.value}
className={BgSwatchStyle}
onClick={() => {
setChatBackground(opt.value as ChatBackground);
if (opt.value !== 'none') setSeasonalThemeOverride('off');
}}
style={{
display: 'block',
width: toRem(76),
height: toRem(50),
borderRadius: toRem(8),
cursor: 'pointer',
border:
chatBackground === opt.value
? `2px solid ${color.Critical.Main}`
: '2px solid rgba(128,128,128,0.25)',
padding: 0,
overflow: 'hidden',
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations),
}}
/>
<Text
size="T200"
style={chatBackground === opt.value ? { color: color.Critical.Main } : undefined}
style={chatBackground === opt.value ? { color: color.Primary.Main } : undefined}
>
{opt.label}
</Text>
@@ -1,6 +1,6 @@
import React from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { Box, Text, IconButton, Icon, Icons, Scroll, config, toRem } from 'folds';
import { Box, Button, Text, IconButton, Icon, Icons, Scroll, config, toRem } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SystemNotification } from './SystemNotification';
import { AllMessagesNotifications } from './AllMessages';
@@ -68,37 +68,34 @@ function NotificationPresets() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<Box gap="300" style={{ padding: config.space.S300, flexWrap: 'wrap' }}>
{PRESETS.map((preset) => (
<button
<Button
key={preset.label}
type="button"
onClick={() => applyPreset(preset.patch)}
title={preset.description}
variant="Secondary"
fill="Soft"
radii="300"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: toRem(4),
padding: `${toRem(8)} ${toRem(16)}`,
borderRadius: config.radii.R300,
border: '1px solid var(--border-interactive-normal)',
background: 'var(--bg-surface-low)',
color: 'inherit',
cursor: 'pointer',
height: 'unset',
minWidth: toRem(80),
padding: `${config.space.S200} ${config.space.S400}`,
}}
>
<span style={{ fontSize: toRem(24) }}>{preset.emoji}</span>
<Text size="T300" style={{ fontWeight: 600 }}>
{preset.label}
</Text>
<Text
size="T200"
priority="300"
style={{ textAlign: 'center', maxWidth: toRem(120) }}
>
{preset.description}
</Text>
</button>
<Box direction="Column" alignItems="Center" gap="100">
<span style={{ fontSize: toRem(24) }}>{preset.emoji}</span>
<Text size="T300" style={{ fontWeight: config.fontWeight.W600 }}>
{preset.label}
</Text>
<Text
size="T200"
priority="300"
style={{ textAlign: 'center', maxWidth: toRem(120) }}
>
{preset.description}
</Text>
</Box>
</Button>
))}
</Box>
</SequenceCard>
+17 -13
View File
@@ -41,8 +41,12 @@ function ToastCard({ toast }: ToastCardProps) {
}, [dismiss, toast.id]);
const handleCardClick = () => {
// window.location.hash setter auto-prepends '#', so values must not include it
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`;
if (toast.onClick) {
toast.onClick();
} else {
// window.location.hash setter auto-prepends '#', so values must not include it
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`;
}
dismiss(toast.id);
};
@@ -53,13 +57,13 @@ function ToastCard({ toast }: ToastCardProps) {
const cardStyle: CSSProperties = {
position: 'relative',
background: 'var(--lt-bg-card, #1a1a2e)',
border: '1px solid var(--lt-border-color, rgba(255,255,255,0.1))',
background: 'var(--lt-bg-card)',
border: '1px solid var(--lt-border-color)',
borderRadius: '12px',
padding: '12px 14px',
minWidth: '280px',
maxWidth: '340px',
boxShadow: 'var(--lt-box-glow-orange, 0 4px 16px rgba(0,0,0,0.4))',
boxShadow: 'var(--lt-box-glow-orange)',
cursor: 'pointer',
animation: 'lotusToastIn 0.2s ease-out both',
userSelect: 'none',
@@ -84,19 +88,19 @@ function ToastCard({ toast }: ToastCardProps) {
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'var(--lt-accent-orange-dim, rgba(255,107,0,0.15))',
border: '1px solid var(--lt-accent-orange-border, rgba(255,107,0,0.35))',
background: 'var(--lt-accent-orange-dim)',
border: '1px solid var(--lt-accent-orange-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
fontWeight: 700,
color: 'var(--lt-accent-orange, #ff6b00)',
color: 'var(--lt-accent-orange)',
flexShrink: 0,
};
const nameStyle: CSSProperties = {
color: 'var(--lt-accent-orange, #ff6b00)',
color: 'var(--lt-accent-orange)',
fontWeight: 600,
fontSize: '0.85rem',
overflow: 'hidden',
@@ -110,7 +114,7 @@ function ToastCard({ toast }: ToastCardProps) {
right: '10px',
background: 'none',
border: 'none',
color: 'var(--lt-text-secondary, #7fa3bf)',
color: 'var(--lt-text-secondary)',
cursor: 'pointer',
fontSize: '14px',
lineHeight: 1,
@@ -119,7 +123,7 @@ function ToastCard({ toast }: ToastCardProps) {
};
const bodyStyle: CSSProperties = {
color: 'var(--lt-text-primary, #c4d9ee)',
color: 'var(--lt-text-primary)',
fontSize: '0.82rem',
margin: '4px 0 2px',
overflow: 'hidden',
@@ -128,7 +132,7 @@ function ToastCard({ toast }: ToastCardProps) {
};
const roomNameStyle: CSSProperties = {
color: 'var(--lt-text-secondary, #7fa3bf)',
color: 'var(--lt-text-secondary)',
fontSize: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -190,7 +194,7 @@ export function LotusToastContainer() {
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
zIndex: 9997,
zIndex: 10001,
display: 'flex',
flexDirection: 'column',
gap: '8px',
+13 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Method } from 'matrix-js-sdk';
import { MatrixError, Method } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
@@ -32,8 +32,18 @@ function fetchDecoration(
cache.set(userId, val);
return val;
})
.catch(() => {
cache.set(userId, null);
.catch((err: unknown) => {
// A 404 (M_NOT_FOUND) means the field is genuinely unset → cache "no
// decoration". A transient failure (429 rate-limit, 5xx, network) must
// NOT be cached: doing so permanently hides the user's decoration for the
// whole session. This matters most for the member list and timeline, which
// mount many avatars at once and can trip homeserver rate limits — a
// single 429 in that burst would otherwise wipe the decoration until a
// full reload. Leaving the cache unset lets the next mount retry.
const status = err instanceof MatrixError ? err.httpStatus : undefined;
if (status === 404) {
cache.set(userId, null);
}
return null;
})
.finally(() => {
+29
View File
@@ -0,0 +1,29 @@
import { CSSProperties } from 'react';
import { ScreenSize, useScreenSizeContext } from './useScreenSize';
/**
* Returns responsive modal box styles. On mobile the modal expands to fill the
* full viewport (no border radius, no max-width cap) so it doesn't float as a
* small centered card on a phone screen.
*/
export function useModalStyle(desktopMaxWidth: number): CSSProperties {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
if (isMobile) {
return {
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: 0,
overflow: 'hidden auto',
};
}
return {
width: '100%',
maxWidth: desktopMaxWidth,
overflow: 'hidden',
};
}
+30
View File
@@ -0,0 +1,30 @@
import { RefObject, useEffect, useState } from 'react';
/**
* Returns true once the observed element has come within `margin` pixels of
* the viewport. Disconnects the observer after the first intersection so there
* is no ongoing overhead.
*/
export function useNearViewport(ref: RefObject<Element | null>, margin = 200): boolean {
const [triggered, setTriggered] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || triggered) return undefined;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setTriggered(true);
observer.disconnect();
}
},
{ rootMargin: `${margin}px` },
);
observer.observe(el);
return () => observer.disconnect();
}, [ref, margin, triggered]);
return triggered;
}
+31 -26
View File
@@ -1,4 +1,4 @@
import { MouseEventHandler, useEffect, useState } from 'react';
import { MouseEventHandler, useEffect, useRef, useState } from 'react';
export type Pan = {
translateX: number;
@@ -16,36 +16,39 @@ export const usePan = (active: boolean) => {
active ? 'grab' : 'initial',
);
// Track the exact handler references that were passed to addEventListener so
// we can remove them even if the component re-renders or unmounts mid-drag.
const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
useEffect(() => {
setCursor(active ? 'grab' : 'initial');
}, [active]);
const handleMouseMove = (evt: MouseEvent) => {
evt.preventDefault();
evt.stopPropagation();
setPan((p) => {
const { translateX, translateY } = p;
const mX = translateX + evt.movementX;
const mY = translateY + evt.movementY;
return { translateX: mX, translateY: mY };
});
};
const handleMouseUp = (evt: MouseEvent) => {
evt.preventDefault();
setCursor('grab');
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
const handleMouseDown: MouseEventHandler<HTMLElement> = (evt) => {
if (!active) return;
evt.preventDefault();
setCursor('grabbing');
const handleMouseMove = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setPan((p) => ({
translateX: p.translateX + e.movementX,
translateY: p.translateY + e.movementY,
}));
};
const handleMouseUp = (e: MouseEvent) => {
e.preventDefault();
setCursor('grab');
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
attachedRef.current = null;
};
attachedRef.current = { move: handleMouseMove, up: handleMouseUp };
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
@@ -54,13 +57,15 @@ export const usePan = (active: boolean) => {
if (!active) setPan(INITIAL_PAN);
}, [active]);
// Clean up document listeners if component unmounts during an active drag
// Remove listeners if the component unmounts while a drag is in progress.
useEffect(
() => () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
if (attachedRef.current) {
document.removeEventListener('mousemove', attachedRef.current.move);
document.removeEventListener('mouseup', attachedRef.current.up);
attachedRef.current = null;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
export type Reminder = {
roomId: string;
eventId: string;
timestamp: number;
message: string;
};
const REMINDERS_KEY = 'io.lotus.reminders';
type RemindersContent = {
reminders: Reminder[];
};
function readReminders(mx: MatrixClient): Reminder[] {
return (
(mx.getAccountData(REMINDERS_KEY as any)?.getContent() as RemindersContent | undefined)
?.reminders ?? []
);
}
export function useReminders(): {
reminders: Reminder[];
addReminder: (r: Reminder) => Promise<void>;
removeReminder: (eventId: string, timestamp: number) => Promise<void>;
getReminders: () => Reminder[];
} {
const mx = useMatrixClient();
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx));
useAccountDataCallback(
mx,
useCallback(
(evt) => {
if (evt.getType() === REMINDERS_KEY) {
setReminders(evt.getContent<RemindersContent>()?.reminders ?? []);
}
},
[setReminders],
),
);
// Re-read on mx change
useEffect(() => {
setReminders(readReminders(mx));
}, [mx]);
const addReminder = useCallback(
async (r: Reminder) => {
const current = readReminders(mx);
const next = [...current, r];
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
},
[mx],
);
const removeReminder = useCallback(
async (eventId: string, timestamp: number) => {
const current = readReminders(mx);
const next = current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp));
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
},
[mx],
);
const getReminders = useCallback(() => reminders, [reminders]);
return { reminders, addReminder, removeReminder, getReminders };
}
+7 -3
View File
@@ -53,10 +53,14 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
export const usePresenceLabel = (): Record<Presence, string> =>
useMemo(
// Keep this vocabulary aligned with the status setter (PresencePicker in
// SettingsTab.tsx): online -> "Online", unavailable -> "Idle". Previously
// these read "Active"/"Busy"/"Away", so a user who set "Idle" showed as
// "Busy" to others.
() => ({
[Presence.Online]: 'Active',
[Presence.Unavailable]: 'Busy',
[Presence.Offline]: 'Away',
[Presence.Online]: 'Online',
[Presence.Unavailable]: 'Idle',
[Presence.Offline]: 'Offline',
}),
[],
);
+9 -4
View File
@@ -32,13 +32,18 @@ function AppearanceEffects() {
const color = settings.mentionHighlightColor;
if (color) {
document.body.style.setProperty('--mention-highlight-bg', color);
// compute black or white text based on hex luminance
// WCAG 2.1 relative luminance with gamma linearization
const toLinear = (c: number) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
document.body.style.setProperty('--mention-highlight-text', lum > 0.5 ? '#000' : '#fff');
document.body.style.setProperty('--mention-highlight-border', color);
const lum = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
document.body.style.setProperty('--mention-highlight-text', lum > 0.179 ? '#000' : '#fff');
// Derive a visible border: same hue, reduced alpha
document.body.style.setProperty('--mention-highlight-border', `rgba(${r},${g},${b},0.5)`);
} else {
document.body.style.removeProperty('--mention-highlight-bg');
document.body.style.removeProperty('--mention-highlight-text');
+4 -2
View File
@@ -44,9 +44,11 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
<Box grow="Yes" as="main" id="main-content">
{children}
</Box>
{bookmarksOpen && screenSize === ScreenSize.Desktop && (
{bookmarksOpen && (
<>
<Line variant="Background" direction="Vertical" size="300" />
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<BookmarksPanel onClose={() => setBookmarksOpen(false)} />
</>
)}
+99 -9
View File
@@ -15,12 +15,7 @@ import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { usePreviousValue } from '../../hooks/usePreviousValue';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
getDirectRoomPath,
getHomeRoomPath,
getInboxInvitesPath,
getInboxNotificationsPath,
} from '../pathUtils';
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
import { mDirectAtom } from '../../state/mDirectList';
import {
getMemberDisplayName,
@@ -36,6 +31,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
function isInQuietHours(start: string, end: string): boolean {
const now = new Date();
@@ -253,6 +250,10 @@ function MessageNotifications() {
eventId: string;
body?: string;
}) => {
const roomPath = mDirects.has(roomId)
? getDirectRoomPath(roomId, eventId)
: getHomeRoomPath(roomId, eventId);
if (document.hasFocus()) {
setToast({
id: `${roomId}-${eventId}-${Date.now()}`,
@@ -261,7 +262,7 @@ function MessageNotifications() {
body: (body ?? '').slice(0, 80),
roomName,
roomId,
hashPath: mDirects.has(roomId) ? getDirectRoomPath(roomId) : getHomeRoomPath(roomId),
hashPath: roomPath,
});
return;
}
@@ -269,12 +270,13 @@ function MessageNotifications() {
const noti = new window.Notification(roomName, {
icon: roomAvatar,
badge: roomAvatar,
body: `New inbox notification from ${username}`,
body: body ? `${username}: ${body}`.slice(0, 120) : username,
silent: true,
});
noti.onclick = () => {
if (!window.closed) navigate(getInboxNotificationsPath());
window.focus();
navigate(roomPath);
noti.close();
notifRef.current = undefined;
};
@@ -382,6 +384,92 @@ function DeepLinkNavigator() {
return null;
}
function ReminderMonitor() {
const mx = useMatrixClient();
const { reminders, removeReminder } = useReminders();
const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom);
const firedRef = useRef<Set<string>>(new Set());
useEffect(() => {
const check = () => {
const now = Date.now();
reminders.forEach((r) => {
const key = `${r.eventId}-${r.timestamp}`;
if (r.timestamp <= now && !firedRef.current.has(key)) {
firedRef.current.add(key);
const room = mx.getRoom(r.roomId);
const hashPath = mDirects.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(r.roomId, r.eventId);
setToast({
id: `reminder-${key}`,
displayName: 'Reminder',
body: r.message,
roomName: room?.name ?? 'Unknown Room',
roomId: r.roomId,
hashPath,
});
removeReminder(r.eventId, r.timestamp);
}
});
};
check();
const interval = setInterval(check, 30_000);
const onVisible = () => {
if (document.visibilityState === 'visible') check();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', onVisible);
};
}, [mx, reminders, setToast, removeReminder, mDirects]);
return null;
}
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
function TauriUpdateFeature() {
const { isTauri, status, check, install } = useTauriUpdater();
const setToast = useSetAtom(toastQueueAtom);
const firedRef = useRef<string | null>(null);
useEffect(() => {
if (!isTauri) return;
const runCheck = () => {
const last = Number(localStorage.getItem(TAURI_UPDATE_LAST_CHECK_KEY) ?? '0');
if (Date.now() - last < TAURI_UPDATE_CHECK_INTERVAL) return;
localStorage.setItem(TAURI_UPDATE_LAST_CHECK_KEY, String(Date.now()));
check();
};
runCheck();
const interval = setInterval(runCheck, TAURI_UPDATE_CHECK_INTERVAL);
return () => clearInterval(interval);
}, [isTauri, check]);
useEffect(() => {
if (status.state !== 'available') return;
if (firedRef.current === status.version) return;
firedRef.current = status.version;
setToast({
id: `tauri-update-${status.version}`,
displayName: 'Update Available',
body: `Lotus Chat ${status.version} is ready to install.`,
roomName: 'System',
roomId: '',
onClick: install,
});
}, [status, setToast, install]);
return null;
}
function LotusDenoiseFeature() {
const setToast = useSetAtom(toastQueueAtom);
@@ -417,6 +505,8 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<PresenceUpdater />
<InviteNotifications />
<MessageNotifications />
<ReminderMonitor />
<TauriUpdateFeature />
<LotusDenoiseFeature />
<DeepLinkNavigator />
{children}
+5 -1
View File
@@ -227,7 +227,11 @@ export function Direct() {
const virtualizer = useVirtualizer({
count: filteredDirects.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
// DM rows render a two-line layout (name + message preview), so they are
// taller than the 38px single-line rooms elsewhere. Estimating the common
// two-line height avoids the initial-render jump/overlap before
// `measureElement` corrects each row to its exact size.
estimateSize: () => 52,
overscan: 10,
});
+46 -27
View File
@@ -9,12 +9,15 @@ import {
PopOut,
RectCords,
Text,
Tooltip,
TooltipProvider,
color,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
import { stopPropagation } from '../../../utils/keyboard';
import { UserAvatar } from '../../../components/user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
@@ -82,12 +85,16 @@ function PresencePicker() {
initialFocus: false,
onDeactivate: closeMenu,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
}}
>
<Menu variant="Surface" style={{ padding: config.space.S100, minWidth: toRem(210) }}>
<Box direction="Column" gap="100">
<Box style={{ padding: `${config.space.S100} ${config.space.S200}` }}>
<Text size="L400" style={{ opacity: 0.6 }}>
<Text size="L400" priority="300">
Set Status
</Text>
</Box>
@@ -115,33 +122,45 @@ function PresencePicker() {
}
>
{/* Presence dot sits in the bottom-right corner of SidebarItem (which is position:relative) */}
<button
type="button"
aria-label={`Status: ${currentOption.label}. Click to change.`}
onClick={(e) => {
e.stopPropagation();
setMenuAnchor(e.currentTarget.getBoundingClientRect());
}}
style={{
position: 'absolute',
bottom: 2,
right: 2,
background: 'var(--bg-surface)',
border: 'none',
borderRadius: '50%',
padding: 2,
lineHeight: 0,
cursor: 'pointer',
zIndex: 1,
}}
<TooltipProvider
position="Right"
tooltip={
<Tooltip>
<Text size="T200">{`Status: ${currentOption.label}`}</Text>
</Tooltip>
}
>
<Badge
size="200"
variant={presenceVariant(presenceStatus)}
fill={presenceStatus === 'invisible' ? 'Soft' : 'Solid'}
radii="Pill"
/>
</button>
{(triggerRef) => (
<button
type="button"
ref={triggerRef}
aria-label={`Status: ${currentOption.label}. Click to change.`}
onClick={(e) => {
e.stopPropagation();
setMenuAnchor(e.currentTarget.getBoundingClientRect());
}}
style={{
position: 'absolute',
bottom: 2,
right: 2,
background: color.Background.Container,
border: 'none',
borderRadius: '50%',
padding: 2,
lineHeight: 0,
cursor: 'pointer',
zIndex: 1,
}}
>
<Badge
size="200"
variant={presenceVariant(presenceStatus)}
fill={presenceStatus === 'invisible' ? 'Soft' : 'Solid'}
radii="Pill"
/>
</button>
)}
</TooltipProvider>
</PopOut>
);
}
+1
View File
@@ -35,6 +35,7 @@ export type _SearchPathSearchParams = {
senders?: string;
fromTs?: string;
toTs?: string;
containsUrl?: string;
};
export const _SEARCH_PATH = 'search/';

Some files were not shown because too many files have changed in this diff Show More