Compare commits
15 Commits
fb66c0ed90
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 79f8fabb1b | |||
| dfd2c9c49e | |||
| 5470e25bb0 | |||
| 374d6dc396 | |||
| d0715774a8 | |||
| 6f544e2b1f | |||
| e713d47319 | |||
| b361d43088 | |||
| a33d28a7ae | |||
| 4a4dede105 | |||
| b818d3fc5a | |||
| cf839e7345 | |||
| c54cb126ff | |||
| 8dc4c4d072 | |||
| 9742eaea28 |
+279
-54
@@ -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 191–213
|
||||
- **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 246–265
|
||||
- **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 184–211; `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 250–358
|
||||
- **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` | 62–137 | 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` | 32–56 / 268–283 | 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` | 89–148 | `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` | 83–124 | 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` | 1076–1087 | 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` | 979–998 / 60–71 | `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 / 38–41 | 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` | 651–661 | 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` | 108–126 | 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` | 137–154 | 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` | 180–193 | 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` | 118–144 | 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` | 80–86 | `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:143–160`, `SettingsSelect`, sort menus) — without it Escape bubbles past the trap and arrow-key navigation is absent |
|
||||
| N18 | Profile Selects | `Profile.tsx` | 547–575 / 816–848 | `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` | 55–62 / 36–42 | `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` | 57–107 | 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:1100–1113`) |
|
||||
| N21 | Notification Sound Selects | `SystemNotification.tsx` | 111–305 | 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` | 608–627 / 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` | 100–115 / 298–309 | 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` | 245–264 | 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` | 258–292 | 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` | 66–73 | 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` | 103–110 | `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` | 1159–1174 | 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` | 103–116 | 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` | 163–189 | 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` | 97–105 | "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` | 95–103 | 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` | 72–77 | 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` | 143–151 | 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` | 155–196 | 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` | 240–244 | "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 / 479–490 | `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` | 554–565 | 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` | 597–677 | `<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` | 24–37 | `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:52–72`, `ExportRoomHistory.tsx:220,246`) |
|
||||
| N47 | RoomInsights Chart Radii | `RoomInsights.tsx` | 350–356 / 415–436 | 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` | 41–65 / 292–295 | `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` | 168–192 | 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` | 311–314 | 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 242–282
|
||||
- **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 284–301) 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 438–477
|
||||
- **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 1660–1661 and 1726–1728
|
||||
- **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 138–163; `src/app/features/room/ReportUserModal.tsx` lines 144–169
|
||||
- **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 63–114).
|
||||
|
||||
---
|
||||
|
||||
#### 🟠 Additional Moderate Findings
|
||||
|
||||
| # | Area | File | Lines | Issue | Native Pattern |
|
||||
| :-- | :--------------------------------------------------------------------------- | :-------------------------------------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| N57 | PiP Fullscreen Button | `CallEmbedProvider.tsx` | 929–951 | 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` | 303–360 | "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` | 1303–1487 | 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` | 744–782 | 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 651–677) uses `position: 'relative'` directly on `<IconButton>` + `toRem()` for inset; no extra wrapper div |
|
||||
| N61 | Knock Member Rows | `MembersDrawer.tsx` | 441–487 | 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` | 860–883 | 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` | 97–110 / 103–116 | 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` | 253–259 | 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` | 558–589 | "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 / 62–77 | `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` | 313–323 | `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` | 644–675 | 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:125–143` 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` | 1648–1689 / 1711–1742 | 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` | 63–85 | `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` | 454–466 | "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` | 415–422 | "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 506–519 use `className={css.MembersGroupLabel}` for all other section headers in the same virtualizer list |
|
||||
| N74 | Emoji Prefix Span | `RoomNavItem.tsx` | 730–736 | 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` | 741–757 | 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` | 189–191 / 195–197 | 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:675–691`) 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` | 172–239 | 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` | 1707–1742 | 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` | 1592–1609 | 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 335–358
|
||||
- **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 457–461
|
||||
- **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 82–89) 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:24–51`) 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` | 69–81 | 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` | 111–117 | 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` | 1100–1114 | 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` | 282–283 | 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` | 36–40 | 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` | 356–376 | 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` | 534–547 | "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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const mediaGalleryAtom = atom<boolean>(false);
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user