Compare commits

...

15 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
64 changed files with 2098 additions and 1381 deletions
+279 -54
View File
@@ -129,23 +129,23 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
## 🔍 Technical & Performance Refinements
| 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 |
| 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 |
| 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 |
## 🏗️ Architectural & Hygiene Audit
@@ -157,50 +157,50 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
## 🏗️ TDS Compliance & Styling Issues
| 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` |
| 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"` |
| 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"` |
| 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"` |
---
@@ -260,3 +260,228 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
| 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 |
+34 -27
View File
@@ -16,9 +16,14 @@
## 📱 Quick Feature Additions
- [ ] **Full-Screen Camera Broadcasts**
- [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.
---
@@ -57,32 +62,32 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
### Confirmed facts
| Finding | Impact |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| 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 |
| **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 |
---
@@ -192,12 +197,14 @@ Features:
**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.
**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
+73 -24
View File
@@ -435,18 +435,19 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
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: '6px',
padding: '3px 7px',
borderRadius: config.radii.R300,
padding: `${config.space.S100} ${config.space.S200}`,
display: 'flex',
alignItems: 'center',
gap: '4px',
gap: config.space.S100,
pointerEvents: 'none',
fontSize: '12px',
lineHeight: 1,
userSelect: 'none',
};
@@ -456,21 +457,25 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
{localMicMuted && (
<div
aria-label={`Your microphone is muted (${localDisplayName})`}
title={`Your microphone is muted`}
style={{ ...badgeStyle, bottom: '8px', left: '8px', color: color.Critical.Main }}
title="Your microphone is muted"
style={{ ...badgeStyle, bottom: config.space.S200, left: config.space.S200 }}
>
<Icon size="100" src={Icons.MicMute} filled />
<span style={{ fontSize: '11px', fontWeight: 600 }}>You</span>
<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: '8px', right: '8px', color: color.Warning.Main }}
style={{ ...badgeStyle, top: config.space.S200, right: config.space.S200 }}
>
<Icon size="50" src={Icons.MicMute} />
<span style={{ fontSize: '11px' }}>All muted</span>
<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>
)}
</>
@@ -529,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;
@@ -910,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} />
@@ -1,5 +1,4 @@
import React, { FormEventHandler, forwardRef, useCallback, useState } from 'react';
import { useModalStyle } from '../hooks/useModalStyle';
import {
Dialog,
Header,
@@ -17,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';
+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,
+21 -3
View File
@@ -2,14 +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 { useModalStyle } from '../hooks/useModalStyle';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
type Modal500Props = {
requestClose: () => void;
children: ReactNode;
};
export function Modal500({ requestClose, children }: Modal500Props) {
const modalStyle = useModalStyle(560);
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -21,7 +21,25 @@ export function Modal500({ requestClose, children }: Modal500Props) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" variant="Background" style={modalStyle}>
<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>
+6 -1
View File
@@ -237,7 +237,12 @@ export function RenderMessageContent({
title={body}
src={src}
loading="lazy"
style={{ objectFit: 'cover', objectPosition: 'center top', width: '100%', height: '100%' }}
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)}
@@ -67,11 +67,11 @@ export const EventReaders = as<'div', EventReadersProps>(
<Header
className={css.Header}
variant="Surface"
size="600"
size="500"
style={
lotusTerminal
? {
borderBottom: '1px solid var(--lt-border-color)',
borderBottomWidth: config.borderWidth.B300,
boxShadow: 'var(--lt-box-glow-cyan)',
}
: 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: 'var(--lt-accent-amber)',
textShadow: 'var(--lt-glow-amber)',
fontSize: '0.72rem',
}
: { opacity: 0.6 }
: undefined
}
>
{formatReadTs(receiptTs, hour24Clock)}
+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>
);
}
+6 -1
View File
@@ -103,7 +103,12 @@ 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} aria-label="View thread" />
<ThreadIndicator
as="button"
data-event-id={threadRootId}
onClick={onClick}
aria-label="View thread"
/>
)}
<ReplyLayout
as="button"
@@ -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>
@@ -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' },
});
/**
@@ -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';
@@ -13,6 +23,7 @@ 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;
@@ -64,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
@@ -95,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',
}}
>
@@ -1,9 +1,9 @@
import React from 'react';
import parse from 'html-react-parser';
import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
import { useModalStyle } from '../../hooks/useModalStyle';
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';
@@ -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',
}}
>
@@ -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} />
@@ -89,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
@@ -97,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}
@@ -237,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}
@@ -252,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',
@@ -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',
});
+132 -169
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,187 +46,150 @@ 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>
);
}
type BookmarksPanelProps = {
onClose: () => void;
isMobile?: boolean;
};
export function BookmarksPanel({ onClose, isMobile }: BookmarksPanelProps) {
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
const { bookmarks, removeBookmark } = useBookmarks();
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: isMobile ? '100%' : '266px',
height: '100%',
position: isMobile ? 'absolute' : 'static',
top: isMobile ? 0 : 'auto',
left: isMobile ? 0 : 'auto',
zIndex: isMobile ? 100 : 'auto',
flexShrink: 0,
borderLeft: isMobile ? 'none' : `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('')}
@@ -227,56 +199,47 @@ export function BookmarksPanel({ onClose, isMobile }: 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>
</>
);
}
+1 -1
View File
@@ -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>
@@ -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>
@@ -26,7 +26,6 @@ import {
Badge,
RectCords,
} from 'folds';
import { color } from 'folds';
import { SearchOrderBy } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { useVirtualizer } from '@tanstack/react-virtual';
@@ -374,7 +373,10 @@ function SelectSenderButton({ selectedSenders, onChange }: SelectSenderButtonPro
const searchUser = useDebounce(_searchUser, SEARCH_DEBOUNCE_OPTS);
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.currentTarget.value.trim();
if (!value) { resetSearch(); return; }
if (!value) {
resetSearch();
return;
}
searchUser(value);
};
@@ -419,14 +421,30 @@ function SelectSenderButton({ selectedSenders, onChange }: SelectSenderButtonPro
>
<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 }}>
<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..." />
<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 }}>
<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>
<Text style={{ padding: config.space.S400 }} size="T300" align="Center">
No match found!
</Text>
)}
<div style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{vItems.map((vItem) => {
@@ -450,7 +468,9 @@ function SelectSenderButton({ selectedSenders, onChange }: SelectSenderButtonPro
aria-pressed={selected}
before={<Icon size="50" src={Icons.User} />}
>
<Text truncate size="T300">{name}</Text>
<Text truncate size="T300">
{name}
</Text>
</MenuItem>
</VirtualTile>
);
@@ -467,7 +487,14 @@ function SelectSenderButton({ selectedSenders, onChange }: SelectSenderButtonPro
<Text size="B300">Save</Text>
)}
</Button>
<Button size="300" radii="300" variant="Secondary" fill="Soft" onClick={handleDeselectAll} disabled={!localSelected || localSelected.length === 0}>
<Button
size="300"
radii="300"
variant="Secondary"
fill="Soft"
onClick={handleDeselectAll}
disabled={!localSelected || localSelected.length === 0}
>
<Text size="B300">Deselect All</Text>
</Button>
</Box>
@@ -477,7 +504,9 @@ function SelectSenderButton({ selectedSenders, onChange }: SelectSenderButtonPro
}
>
<Chip
onClick={(e: React.MouseEvent<HTMLButtonElement>) => setMenuAnchor(e.currentTarget.getBoundingClientRect())}
onClick={(e: React.MouseEvent<HTMLButtonElement>) =>
setMenuAnchor(e.currentTarget.getBoundingClientRect())
}
variant="SurfaceVariant"
radii="Pill"
before={<Icon size="100" src={Icons.User} />}
@@ -529,22 +558,28 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
<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 }) => {
{(
[
{ 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;
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); }}
onClick={() => {
onChange(from, now);
setMenuAnchor(undefined);
}}
>
<Text size="T200">{l}</Text>
</Chip>
@@ -555,36 +590,26 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
<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: color.SurfaceVariant.Container,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
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: color.SurfaceVariant.Container,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
color: 'inherit',
fontSize: '0.82rem',
padding: `${config.space.S100} ${config.space.S200}`,
}}
/>
</Box>
{hasRange && (
@@ -746,13 +771,11 @@ export function SearchFilters({
</Chip>
);
})}
<SelectSenderButton
selectedSenders={selectedSenders}
onChange={onSelectedSendersChange}
/>
<SelectSenderButton selectedSenders={selectedSenders} onChange={onSelectedSendersChange} />
<Box grow="Yes" data-spacing-node />
<Chip
variant={containsUrl ? 'Primary' : 'SurfaceVariant'}
variant={containsUrl ? 'Success' : 'SurfaceVariant'}
outlined={!!containsUrl}
radii="Pill"
aria-pressed={!!containsUrl}
before={<Icon size="100" src={Icons.Link} />}
@@ -761,7 +784,10 @@ export function SearchFilters({
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => { e.stopPropagation(); onContainsUrlChange(undefined); }}
onClick={(e) => {
e.stopPropagation();
onContainsUrlChange(undefined);
}}
/>
) : undefined
}
+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',
});
+83 -151
View File
@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNearViewport } from '../../hooks/useNearViewport';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
@@ -16,12 +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 { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './MediaGallery.css';
type GalleryTab = 'image' | 'video' | 'file';
@@ -373,8 +375,14 @@ function GalleryTile({
const mx = useMatrixClient();
const tileRef = useRef<HTMLButtonElement>(null);
const nearViewport = useNearViewport(tileRef, 300);
const media = useDecryptedMediaUrl(mx, mxcUrl, encInfo, useAuthentication, mimeType, nearViewport);
const [hovered, setHovered] = useState(false);
const media = useDecryptedMediaUrl(
mx,
mxcUrl,
encInfo,
useAuthentication,
mimeType,
nearViewport,
);
const relDate = formatRelativeDate(ts);
return (
@@ -383,22 +391,7 @@ function GalleryTile({
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' && (
@@ -412,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', objectPosition: 'center top', 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>
@@ -482,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;
}) {
@@ -513,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>
);
}
@@ -532,8 +479,6 @@ type MediaGalleryProps = {
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const [tab, setTab] = useState<GalleryTab>('image');
const [loading, setLoading] = useState(false);
@@ -548,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(
@@ -630,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[] = [];
@@ -646,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: isMobile ? '100%' : '320px',
zIndex: 500,
borderLeft: isMobile ? 'none' : `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)}
/>
@@ -720,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') && (
<>
@@ -744,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;
@@ -835,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} />
@@ -854,7 +786,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
</Text>
</Box>
<IconButton
variant="Background"
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={`Download ${body}`}
+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) => {
+5 -9
View File
@@ -3,6 +3,7 @@ import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
@@ -100,19 +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,
...modalStyle,
}}
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>
);
}
+10 -35
View File
@@ -5,6 +5,7 @@ import {
Text,
Input,
Button,
Dialog,
IconButton,
Icon,
Icons,
@@ -17,6 +18,7 @@ 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';
@@ -94,19 +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,
...modalStyle,
}}
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>
+10 -35
View File
@@ -5,6 +5,7 @@ import {
Text,
Input,
Button,
Dialog,
IconButton,
Icon,
Icons,
@@ -18,6 +19,7 @@ import {
} 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';
@@ -100,19 +102,14 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
escapeDeactivates: stopPropagation,
}}
>
<Box
<Dialog
as="form"
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="report-user-dialog-title"
onSubmit={handleSubmit}
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: color.Other.Shadow,
...modalStyle,
}}
style={modalStyle}
>
<Header
style={{
@@ -141,31 +138,12 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
<Text as="label" htmlFor="report-user-category" size="L400">
Category
</Text>
<Box
as="select"
<ReportCategorySelect
id="report-user-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">
@@ -192,9 +170,6 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
</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"
@@ -215,7 +190,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
</Button>
</Box>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
+13 -1
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,6 +82,14 @@ export function Room() {
<CallChatView />
</>
)}
{!callView && galleryOpen && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</>
)}
{!callView && isDrawer && (
<>
{screenSize === ScreenSize.Desktop && (
+5 -6
View File
@@ -866,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
@@ -1159,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}
+249 -257
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,9 +73,8 @@ 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 { useAtom } from 'jotai';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
type RoomMenuProps = {
@@ -85,247 +85,247 @@ type RoomMenuProps = {
};
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 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 [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
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 }}>
<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={<Icon size="100" src={Icons.User} filled={peopleDrawer} />}
radii="300"
aria-pressed={peopleDrawer}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Members
</Text>
</MenuItem>
)}
{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>
)}
{!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 = {
@@ -488,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 = () => {
@@ -567,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>
)}
@@ -741,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>
)}
@@ -827,7 +820,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
room={room}
requestClose={() => setMenuAnchor(undefined)}
galleryOpen={galleryOpen}
onToggleGallery={() => setGalleryOpen((v) => !v)}
onToggleGallery={() => setGalleryOpen((v: boolean) => !v)}
/>
</FocusTrap>
}
@@ -835,7 +828,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</Box>
</Box>
</PageHeader>
{galleryOpen && <MediaGallery room={room} onClose={() => setGalleryOpen(false)} />}
</>
);
}
@@ -3,6 +3,7 @@ import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
@@ -177,19 +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,
...modalStyle,
}}
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 && (
@@ -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,
@@ -40,11 +41,6 @@ type EditHistoryResponse = {
next_batch?: string;
};
type EditHistoryData = {
events: MatrixEvent[];
hasMore: boolean;
};
type EditHistoryModalProps = {
room: Room;
mEvent: MatrixEvent;
@@ -100,48 +96,71 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
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);
@@ -195,7 +214,7 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
paddingBottom: config.space.S700,
}}
>
{historyState.status === AsyncStatus.Loading && (
{initialLoading && (
<Box
justifyContent="Center"
alignItems="Center"
@@ -204,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">
@@ -223,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>
@@ -244,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,
@@ -134,14 +138,22 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
...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"
@@ -151,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
+12 -21
View File
@@ -7,6 +7,7 @@ import {
Header,
Icon,
IconButton,
IconSrc,
Icons,
Input,
Line,
@@ -95,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;
}
@@ -124,7 +122,6 @@ function DeliveryStatus({
display: 'inline-flex',
alignItems: 'center',
marginTop: '2px',
fontSize: '10px',
lineHeight: 1,
color: colorStyle,
opacity: 0.85,
@@ -134,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>
);
@@ -157,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
@@ -1147,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);
@@ -2,14 +2,14 @@ import React, { useMemo } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
color,
Button,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Line,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
@@ -42,7 +42,6 @@ function getPresets(): Array<{ label: string; ms: number }> {
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
const modalStyle = useModalStyle(320);
const { addReminder } = useReminders();
// eslint-disable-next-line react-hooks/exhaustive-deps
const presets = useMemo(() => getPresets(), []);
const handlePick = async (ms: number) => {
@@ -66,18 +65,12 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
escapeDeactivates: stopPropagation,
}}
>
<Box
<Dialog
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="remind-me-title"
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: color.Other.Shadow,
...modalStyle,
overflow: 'hidden',
}}
style={modalStyle}
>
<Header
variant="Surface"
@@ -109,14 +102,21 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
{presets.map((p) => (
<MenuItem key={p.label} size="300" radii="300" onClick={() => handlePick(p.ms)}>
<Text size="T300" truncate>
<Button
key={p.label}
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => handlePick(p.ms)}
>
<Text size="B300" truncate>
{p.label}
</Text>
</MenuItem>
</Button>
))}
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
@@ -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,
},
},
});
+46 -52
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 {
@@ -123,7 +126,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
<>
<Button
size="300"
variant="Primary"
variant="Secondary"
outlined
fill="Soft"
radii="300"
@@ -549,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
@@ -560,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>
)}
@@ -640,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>
@@ -1592,7 +1599,7 @@ function Calls() {
<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}
@@ -1649,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',
}}
>
@@ -1687,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>
@@ -1712,28 +1713,21 @@ function ChatBgGrid() {
type="button"
aria-label={opt.label}
aria-pressed={chatBackground === opt.value}
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>
@@ -194,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(() => {
+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');
+1 -4
View File
@@ -49,10 +49,7 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<BookmarksPanel
onClose={() => setBookmarksOpen(false)}
isMobile={screenSize !== ScreenSize.Desktop}
/>
<BookmarksPanel onClose={() => setBookmarksOpen(false)} />
</>
)}
</Box>
+1 -5
View File
@@ -15,11 +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,
} from '../pathUtils';
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
import { mDirectAtom } from '../../state/mDirectList';
import {
getMemberDisplayName,
+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>
);
}
+32
View File
@@ -288,6 +288,38 @@ export class CallControl extends EventEmitter implements CallControlState {
this.settingsButton?.click();
}
/**
* Focus a specific participant's camera tile in Element Call.
*
* EC renders video tiles as `[data-testid="videoTile"]`. Each tile wraps a
* mute-status indicator with `aria-label` set to the participant's Matrix
* user ID. We find the tile containing that user, switch to spotlight mode
* if needed, then click the tile so EC's internal focus handler runs.
*
* Falls back to a plain spotlight toggle if the tile is not found (e.g. the
* participant has their camera off and EC didn't render a video tile for
* them yet).
*/
public focusCameraParticipant(userId: string): void {
const doc = this.document;
if (!doc) return;
// Find the mute icon / aria-label element that identifies this participant
const userEl = doc.querySelector<HTMLElement>(`[aria-label="${CSS.escape(userId)}"]`);
// Walk up to the nearest video tile container
const tile =
userEl?.closest<HTMLElement>('[data-testid="videoTile"]') ??
userEl?.closest<HTMLElement>('[data-video-fit]');
if (!this.spotlight) {
this.spotlightButton?.click();
}
if (tile) {
tile.click();
}
}
public dispose() {
this.controlMutationObserver.disconnect();
}
+8 -2
View File
@@ -390,7 +390,10 @@ export class CallEmbed {
if (this.call === null) return;
const raw = ev.getEffectiveEvent();
this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => {
console.error('Error sending state update to widget:', e instanceof Error ? e.message : 'unknown error');
console.error(
'Error sending state update to widget:',
e instanceof Error ? e.message : 'unknown error',
);
});
}
@@ -496,7 +499,10 @@ export class CallEmbed {
} else {
const raw = ev.getEffectiveEvent();
this.call.feedEvent(raw as IRoomEvent).catch((e) => {
console.error('Error sending event to widget:', e instanceof Error ? e.message : 'unknown error');
console.error(
'Error sending event to widget:',
e instanceof Error ? e.message : 'unknown error',
);
});
}
}
+3
View File
@@ -0,0 +1,3 @@
import { atom } from 'jotai';
export const mediaGalleryAtom = atom<boolean>(false);
+13 -6
View File
@@ -306,19 +306,26 @@ export function tokenize(code: string, lang: string): SyntaxToken[] {
// ── Inline style helpers ────────────────────────────────────────────────────
/** Returns the React inline-style object for a given SyntaxToken type. */
/**
* Returns the React inline-style object for a given SyntaxToken type.
*
* Uses the `--prism-*` variable family (defined in `ReactPrism.css`), which has
* proper light / dark / TDS palettes unlike the raw `--lt-accent-*` vars,
* which only exist in TDS mode and otherwise fall back to dark-only Monokai
* colors with poor contrast on light themes.
*/
export function tokenStyle(type: SyntaxToken['type']): CSSProperties {
switch (type) {
case 'kw':
return { color: 'var(--lt-accent-cyan, #66d9ef)' };
return { color: 'var(--prism-keyword)' };
case 'str':
return { color: 'var(--lt-accent-green, #a6e22e)' };
return { color: 'var(--prism-selector)' };
case 'num':
return { color: 'var(--lt-accent-orange, #fd971f)' };
return { color: 'var(--prism-boolean)' };
case 'cmt':
return { opacity: 0.5, fontStyle: 'italic' as const };
return { color: 'var(--prism-comment)', fontStyle: 'italic' as const };
case 'fn':
return { color: 'var(--lt-accent-purple, #ae81ff)' };
return { color: 'var(--prism-atrule)' };
default:
return {};
}