Compare commits
57 Commits
c0fd372529
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bf56d5748 | |||
| d5ce56930b | |||
| 349194e7e5 | |||
| 24d6460e4c | |||
| 127e783f66 | |||
| 198fd12bb2 | |||
| 34d5209165 | |||
| 9684ab75bb | |||
| 0a6b035a67 | |||
| cbfd3e5632 | |||
| 3faf0866a0 | |||
| bab3a160c2 | |||
| 1778cd0009 | |||
| 5204766276 | |||
| 6218012d3f | |||
| ccb0c1d18e | |||
| 65e24bd446 | |||
| de6cecaffc | |||
| da545ba9b9 | |||
| 3c4842df1e | |||
| 1ee0f0b57a | |||
| 4fbbd9680b | |||
| 259a5a2b3e | |||
| 8d62be9eff | |||
| 63139350e4 | |||
| 33b33e685a | |||
| a8038bb534 | |||
| 4d0e34c4cf | |||
| 70ffd252bd | |||
| 51d468fbcc | |||
| 1c84556600 | |||
| 34997bcbd1 | |||
| 78cb2acd6c | |||
| ce8a03ab16 | |||
| 19feca4964 | |||
| adbda094e7 | |||
| 7013da70bc | |||
| 49d9410e3a | |||
| 84a2e7a93e | |||
| 950b8a8128 | |||
| af58f7a32c | |||
| 91c6f2f091 | |||
| 31cf353463 | |||
| 8912423aeb | |||
| bc85cd4984 | |||
| fc8eb70617 | |||
| 1a5896ef84 | |||
| 7b94eeaa60 | |||
| 50076962f6 | |||
| d39aef0aac | |||
| 9f533b1077 | |||
| fdaba40ba9 | |||
| caf6318a5d | |||
| 23649d85b0 | |||
| c67aed01dc | |||
| 66cc51d6d0 | |||
| 4a87588435 |
@@ -1,2 +1 @@
|
|||||||
VITE_SENTRY_DSN=https://264a5e95c5d31fe080a2e92fb008294d@o4511430568378368.ingest.us.sentry.io/4511430571982849
|
|
||||||
VITE_APP_VERSION=lotus
|
VITE_APP_VERSION=lotus
|
||||||
|
|||||||
+18
-2
@@ -21,14 +21,30 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
# Harden against transient registry network failures (ECONNRESET etc.):
|
||||||
|
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
||||||
|
# 3 times with backoff before failing the build.
|
||||||
|
run: |
|
||||||
|
npm config set fetch-retries 5
|
||||||
|
npm config set fetch-retry-mintimeout 20000
|
||||||
|
npm config set fetch-retry-maxtimeout 120000
|
||||||
|
npm config set fetch-timeout 600000
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
echo "npm ci attempt $attempt…"
|
||||||
|
npm ci && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
echo "npm ci failed after 3 attempts" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "npm ci failed; retrying in $((attempt * 15))s…" >&2
|
||||||
|
sleep $((attempt * 15))
|
||||||
|
done
|
||||||
|
|
||||||
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
VITE_APP_VERSION: ${{ github.sha }}
|
VITE_APP_VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
# ── Quality checks (informational — pre-existing issues exist) ───────
|
# ── Quality checks (informational — pre-existing issues exist) ───────
|
||||||
|
|||||||
+62
-462
@@ -1,487 +1,87 @@
|
|||||||
# Lotus Chat — Bug Report & Technical Audit
|
# Lotus Chat — Open Bugs & Technical Debt
|
||||||
|
|
||||||
**Date:** June 2026
|
**Only OPEN and awaiting-verification items live here.** Resolved findings
|
||||||
|
(fixed-and-verified, false-positives, won't-fix) have been removed to keep this
|
||||||
|
actionable — the full history is in git. Items fixed in code but not yet
|
||||||
|
verified in a real environment are in **Needs Verification** below and have
|
||||||
|
step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
|
||||||
|
|
||||||
This document tracks identified bugs, edge cases, and architectural discrepancies found during the audit of the Lotus Chat codebase. Recommended fixes are provided for each item.
|
> Design rules for any fix here: follow the **Native-Cinny Law** and **TDS
|
||||||
|
> Design Law** in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚩 Critical & UI Bugs
|
## ⚠️ Needs Verification — fixed in code, awaiting live testing
|
||||||
|
|
||||||
### 12. PiP Mute Icon Misidentifies Whose Mic Is Muted
|
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
||||||
|
|
||||||
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
|
| ID | Item | File / area | Test |
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with at least one other participant who mutes/unmutes
|
| :--- | :------------------------------------------------------- | :--------------------------------------------------- | :---- |
|
||||||
- **Issue:** The muted-mic badge in the Picture-in-Picture window used `useRemoteAllMuted` (fires when ANY remote participant is muted) and rendered in the bottom-left corner — the conventional position for "YOUR" mic status. Users read it as their own mic being muted.
|
| #1 | Camera focus during screenshare ("Focus camera" menu) | `CallControl.ts`, `MemberGlance.tsx` | A5 |
|
||||||
- **Root Cause:** `PipMuteOverlay` was triggering on remote-mute events while displaying in a position that implies local-user status.
|
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
||||||
- **Fix Applied:**
|
| #3 | Avatar decorations on call tiles | `call/CallMemberCard.tsx` | A6 |
|
||||||
- **Bottom-left badge** now shows only when the LOCAL user's mic is muted (checked via `!controlState.microphone` from `useCallControlState`). Includes "You" label to make it unambiguous. Uses `color.Critical.Main`.
|
| #4 | DM/group ringtone selection + in-call banner | `CallEmbedProvider.tsx`, `ringtones.ts` | A1–A4 |
|
||||||
- **Top-right badge** (new) shows "All muted" in `color.Warning.Main` when all remote participants are muted — positioned and labeled so it's clearly about other people, not the local user.
|
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
||||||
- Both badges use `aria-label` / `title` for accessibility.
|
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
||||||
|
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
||||||
|
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
||||||
|
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
|
||||||
|
| #12 | PiP mute badge attribution (you vs. all-muted) | `CallEmbedProvider.tsx` | G1 |
|
||||||
|
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
|
||||||
|
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
|
||||||
|
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
|
||||||
|
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
|
||||||
|
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
||||||
|
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1. No Camera Focus During Screenshare
|
## 🔴 Open — Actionable
|
||||||
|
|
||||||
- **File:** `cinny/src/app/features/call/CallControls.tsx`
|
### Calls / Audio
|
||||||
- **Status:** **OPEN**
|
|
||||||
- **Issue:** Automatic screenshare spotlighting forces primary display override, preventing users from manually focusing on camera feeds.
|
|
||||||
- **Root Cause:** Current spotlighting logic prioritizes active screenshare streams over manual participant selections, effectively ignoring or overriding user-initiated focus states.
|
|
||||||
- **Proposed Fix:** Introduce a manual 'Focus' state that takes precedence over automatic screenshare spotlighting, implemented via a toggle/click UI on participant tiles. Update the video renderer to respect this manual override.
|
|
||||||
|
|
||||||
### 2. Chat Background Animation Flickering
|
- **N127 — ML denoise shim is never injected in `vite dev`.** The `lotusDenoise` plugin injects only on `closeBundle` (build), so ML noise suppression is silently inactive during local dev. Add a dev-mode injection (`configureServer` / `transformIndexHtml`). Dev-only impact.
|
||||||
|
|
||||||
- **File:** `cinny/src/app/features/lotus/chatBackground.ts`
|
### Security & Privacy
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on a real device with an animated background active
|
|
||||||
- **Issue:** Animated background properties cause visible flickering on message text and the composer area, particularly on browsers/GPUs susceptible to repaint-induced artifacts.
|
|
||||||
- **Root Cause:** Animation triggers excessive repaints or layout recalculations on descendant elements, likely due to animating non-GPU accelerated properties on parent containers without proper rendering context isolation.
|
|
||||||
- **Fix Applied:** `getChatBg()` now injects `willChange: 'background-position'` and `contain: 'paint'` for any animated variant. This promotes the element to its own compositor layer and isolates repaints from descendants. Background-position animation is already GPU-hinted on modern browsers; `contain: paint` prevents descendant elements from being invalidated during each frame.
|
|
||||||
|
|
||||||
### 3. Avatar Decorations in Element Call
|
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
|
||||||
|
- **Session writes are non-atomic and not cross-tab synced** (`state/sessions.ts`) — risks inconsistent state / races across tabs.
|
||||||
|
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
|
||||||
|
|
||||||
- **File:** `cinny/src/app/features/call/CallMemberCard.tsx`
|
### PWA / Offline / Notifications
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with a participant who has a decoration set
|
|
||||||
- **Issue:** Avatar decorations are failing to render within the call/room interface member lists.
|
|
||||||
- **Root Cause:** Member lists and the people drawer already wrapped avatars in `<AvatarDecoration userId={...}>`, but the call participant tile (`CallMemberCard`) rendered a bare `<UserAvatar>` with no decoration wrapper — so decorations were absent specifically on call tiles. (Note: avatars rendered _inside_ the Element Call iframe are EC-rendered and out of our control; this fix covers our own participant roster / prescreen.)
|
|
||||||
- **Fix Applied:** Wrapped the call-tile avatar in `<AvatarDecoration userId={userId}>` (commit `0394fce9`), matching the member-list pattern.
|
|
||||||
|
|
||||||
### 4. DM and Group Message Calls
|
- **N105 — Service worker has no `notificationclick` handler** — notification clicks are broken when the tab is closed. Needs `showNotification()` via the SW + a `notificationclick` listener.
|
||||||
|
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
|
||||||
|
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
|
||||||
|
- **`manifest: false`** in `vite.config.js` — may block correct PWA install if not handled externally.
|
||||||
|
|
||||||
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
|
### Dependencies & Build
|
||||||
- **Status:** **PARTIALLY FIXED ⚠️ UNTESTED** — Volume control added. Remaining: ringtone selection, suppression during active calls.
|
|
||||||
- **Issue:** Incoming call ringtone is hardcoded, lacks volume control, and is suppressed if the user is already in an active call.
|
|
||||||
- **Root Cause:** Ringing logic is tightly coupled to `RTCNotification` events in `CallEmbedProvider.tsx`, using a hardcoded audio file path. It lacks an abstraction for sound management or user-configurable settings for ringtones/volumes.
|
|
||||||
- **Fix Applied:** Added `ringtoneVolume` setting (0–100, default 70). `IncomingCall` reads this setting and applies `audioElement.volume = ringtoneVolume / 100` before `play()`. Slider added to Settings → General → Calls section.
|
|
||||||
- **Remaining:** (a) Ringtone selection (still hardcoded to `call.ogg`); (b) Suppression during active calls — not investigated.
|
|
||||||
|
|
||||||
### 5. Seasonal Themes and Chat Backgrounds Design
|
- **`matrix-js-sdk` pinned to a Release Candidate** (`41.6.0-rc.0`); `@atlaskit` and build tools (`vite`, `typescript`, `eslint`) on unstable/experimental pins — review for stable versions; RC SDK is a tree-shaking/bundle-size risk.
|
||||||
|
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
|
||||||
|
|
||||||
- **File:** `cinny/src/app/hooks/useTheme.ts`, `cinny/src/app/features/lotus/chatBackground.ts`
|
### Code Hygiene / DevEx
|
||||||
- **Status:** **OPEN**
|
|
||||||
- **Issue:** Basic CSS or random moving lines are insufficient for high-fidelity wallpaper/theming. They lack professional design theory, coherence, and aesthetic depth.
|
|
||||||
- **Root Cause:** Current implementation relies on basic CSS, lacks advanced design theory, and does not leverage modern, performant CSS wallpaper techniques.
|
|
||||||
- **Proposed Fix (Extreme Depth Redesign):**
|
|
||||||
- **Research-Backed Implementation:** Implement advanced design techniques (layered `oklch` gradients, `backdrop-filter` for refractive "liquid glass" effects, GPU-accelerated `transform` animations) to create living, breathing backgrounds.
|
|
||||||
- **Performance Optimization:** Ensure all animations strictly use compositor-thread properties (`transform`, `opacity`) and apply `contain: paint` / `will-change: transform` to prevent layout thrashing/flickering.
|
|
||||||
- **Design Resources (Examples/Inspiration):**
|
|
||||||
- [Uiverse.io Patterns](https://uiverse.io/patterns)
|
|
||||||
- [MagicPattern CSS Backgrounds](https://www.magicpattern.design/tools/css-backgrounds)
|
|
||||||
- [Prismic Blog: CSS Background Effects](https://prismic.io/blog/css-background-effects)
|
|
||||||
- [CSS-Pattern.com](https://css-pattern.com) (Pure CSS pattern library)
|
|
||||||
- [BGJar](https://bgjar.com) (Performance-focused generators)
|
|
||||||
- **Goal:** Treat each theme/background as a week-long development sprint to ensure professional polish, WCAG AA contrast compliance for overlaying UI, and seamless integration with the Lotus TDS.
|
|
||||||
|
|
||||||
### 6. Exclusive Background vs. Seasonal Choice
|
- **No automated test suite** (`src/`) — no unit/integration tests configured.
|
||||||
|
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
|
||||||
|
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
|
||||||
|
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
|
||||||
|
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
|
||||||
|
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
|
||||||
|
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
|
||||||
|
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
|
||||||
|
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
|
||||||
|
|
||||||
- **File:** `cinny/src/app/state/settings.ts`
|
### Native-Cinny polish (remaining from the design-law audit)
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification: (a) pick a background, confirm seasonal theme auto-clears; (b) pick a seasonal theme, confirm background auto-clears; (c) set both via old localStorage data and reload, confirm SeasonalEffect guard suppresses the overlay
|
|
||||||
- **Issue:** Concurrent application of both Chat Backgrounds and Seasonal Themes causes visual clutter and high GPU usage.
|
|
||||||
- **Root Cause:** These are currently handled as independent settings in the `settingsAtom` and applied simultaneously without mutual exclusion.
|
|
||||||
- **Fix Applied:** Mutual exclusion enforced at two layers: (1) `General.tsx` — ChatBgGrid clears seasonalThemeOverride→'off' when any non-'none' background is picked; SeasonalBgGrid clears chatBackground→'none' when any real seasonal theme is selected. (2) `SeasonalEffect.tsx` — runtime guard returns null if `chatBackground !== 'none'`, protecting against legacy persisted state.
|
|
||||||
|
|
||||||
### 7. Tiny Touch Targets in Composer Toolbar
|
The "renders-broken-on-stock-themes" cluster (ungated invented CSS vars across
|
||||||
|
~13 files + the toast rebuild) is fixed; Sentry was removed. Lower-priority
|
||||||
|
pattern items left:
|
||||||
|
|
||||||
- **File:** `cinny/src/app/features/room/RoomInput.tsx`
|
- **Profile timezone `<select>`** (`settings/account/Profile.tsx`) — still a raw native select (`colorScheme:'dark'`); it's wired to native form submission + a disabled state, so converting to `SettingsSelect` needs care.
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on a real mobile device: open composer, confirm all toolbar buttons are tappable without mis-taps
|
- **MediaGallery lightbox** (`room/MediaGallery.tsx`) — raw `<div role="dialog">` + `#fff`/rgba chrome over forced-black media. Should be folds `Overlay`/`Modal`; the over-media light-on-dark scheme is a borderline-justified scrim.
|
||||||
- **Issue:** Toolbar buttons have hit areas smaller than the WCAG-recommended 44x44px for touch, hindering mobile accessibility.
|
- **Nits:** scattered `opacity:` → `priority`, the poll `✓` Unicode glyph → folds `Icon`, a few `zIndex` magic numbers.
|
||||||
- **Fix Applied:** Added `touchTarget = { minWidth: '44px', minHeight: '44px' }` computed from `mobileOrTablet()` and applied as `style={touchTarget}` to all 8 composer toolbar `IconButton` elements (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
|
|
||||||
|
|
||||||
### 8. Horizontal Overflow in Room Settings
|
### Big Projects
|
||||||
|
|
||||||
- **File:** `cinny/src/app/components/page/style.css.ts`
|
- **#5 — Seasonal themes & chat-background redesign.** Current backgrounds are basic CSS; goal is high-fidelity, research-backed, GPU-accelerated designs (layered `oklch`, `backdrop-filter`, `contain:paint`) with WCAG-AA overlay contrast. Treat each as its own design sprint.
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification: open Room Settings on a narrow mobile screen, confirm nav panel fills full width and no horizontal scrollbar appears
|
|
||||||
- **Issue:** Wide tables and input elements in room settings cause horizontal overflow on mobile viewports.
|
|
||||||
- **Fix Applied:** Added `@media (max-width: 750px) { width: '100%' }` to both `'400'` and `'300'` size variants of the `PageNav` vanilla-extract recipe in `style.css.ts`.
|
|
||||||
|
|
||||||
### 9. Modal Float-Style Responsiveness
|
|
||||||
|
|
||||||
- **File:** Multiple modal files
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification by opening each modal on a real mobile device
|
|
||||||
- **Issue:** Modals appear as floating boxes on mobile, creating navigation and readability challenges.
|
|
||||||
- **Fix Applied:** Created `useModalStyle(desktopMaxWidth)` hook (`src/app/hooks/useModalStyle.ts`) that returns fullscreen styles on mobile (no border-radius, no max-width, `height: 100%`) and desktop box styles otherwise. Applied to all 22+ modal files: `LeaveRoomPrompt`, `LeaveSpacePrompt`, `ReportRoomModal`, `ReportUserModal`, `DeviceVerification`, `InviteUserPrompt`, `LogoutDialog`, `DeviceVerificationSetup`, `DeviceVerificationReset`, `JoinAddressPrompt`, `JumpToTime`, `EditHistoryModal`, `ForwardMessageDialog`, `RemindMeDialog`, `CreateRoomModal`, `CreateSpaceModal`, `ScheduleMessageModal`, `PollCreator`, `AddExistingModal`, `RoomEncryption`, `RoomUpgrade`, `Modal500`, `ReadReceiptAvatars`, `RoomTopicViewer`.
|
|
||||||
- **Note:** `UIAFlowOverlay` already fullscreen via `<Overlay>` — no change needed. `JoinRulesSwitcher`/`RoomNotificationSwitcher` are dropdowns, not modals.
|
|
||||||
|
|
||||||
### 10. Composer Keyboard Obscurity
|
|
||||||
|
|
||||||
- **File:** `src/index.css`
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on iOS Safari specifically (the worst offender); on Android Chrome `100dvh` has been standard since Chrome 108
|
|
||||||
- **Issue:** The chat composer is often partially or fully obscured by the virtual keyboard on mobile.
|
|
||||||
- **Fix Applied:** Added `height: 100dvh` (dynamic viewport height) to `html` alongside the existing `height: 100%` fallback. `dvh` updates when the software keyboard appears, ensuring the layout shrinks correctly and the composer stays visible.
|
|
||||||
|
|
||||||
### 11. Inline Jotai atom creation
|
|
||||||
|
|
||||||
- **File:** `cinny/src/app/hooks/useSpaceHierarchy.ts`
|
|
||||||
- **Status:** **FALSE POSITIVE — CLOSED**
|
|
||||||
- **Issue:** Inline Jotai atom creation in a hook risks re-rendering components unnecessarily.
|
|
||||||
- **Resolution:** `useState(() => atom(...))` IS the correct Jotai pattern for local stable atom references. The factory function form of `useState` ensures the atom is created only once per component mount. No change warranted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Barrel File Audit
|
|
||||||
|
|
||||||
| File Path | Note | Status |
|
|
||||||
| :------------------------------------------ | :------------------------- | :----- |
|
|
||||||
| `cinny/src/app/plugins/call/index.ts` | Extensive `export *` usage | OPEN |
|
|
||||||
| `cinny/src/app/plugins/text-area/index.ts` | Extensive `export *` usage | OPEN |
|
|
||||||
| `cinny/src/app/components/message/index.ts` | Extensive `export *` usage | OPEN |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Technical & Performance Refinements
|
|
||||||
|
|
||||||
| 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` | FIXED (`d2946c00`) — unload path now uses `fetch({ keepalive: true })` so the request survives page teardown (`sendBeacon` was unusable here: it can't set the auth header). |
|
|
||||||
| 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` | FIXED (`d2946c00`) — errors are now surfaced via `warnPresenceFailure` (redacted logging) instead of being silently swallowed. |
|
|
||||||
| 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` | FIXED (`d2946c00`) — bounded retry (`UPLOAD_MAX_RETRY_COUNT=3`) gated by `isRetryableUploadError` (transient/network/5xx/429 only, not 4xx), reusing the `rateLimitedActions` capped-exponential backoff. |
|
|
||||||
| 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` | FALSE POSITIVE — returning `null` for unrendered types is the intended contract. Callers opt into rendering unknowns via the `renderStateEvent` / `renderEvent` fallback params; `null` only results when the caller deliberately supplies no fallback. No change warranted. |
|
|
||||||
| Data Contract | `MatrixError` instantiation with `UploadResponse` might be brittle. | `cinny/src/app/utils/matrix.ts` | FIXED (`d2946c00`) — replaced the brittle direct construction with `matrixErrorFromUploadResponse` / `matrixErrorFromUnknown` guards that validate shape before building a `MatrixError`. |
|
|
||||||
| Type Safety | `addRoomIdToMDirect` uses `as any` cast for `AccountDataEvent.Direct`, bypassing type contract validation. | `cinny/src/app/utils/matrix.ts` | FIXED (`d2946c00`) — `addRoomIdToMDirect` / `removeRoomIdFromMDirect` now use `EventType.Direct` + a typed `MDirectContent`, dropping the `as any` cast. |
|
|
||||||
| 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
|
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
|
||||||
| :------- | :--------------------------------------------------------------- | :-------- | :----- |
|
|
||||||
| Hygiene | No stale development notes or TypeScript strictness issues found | N/A | OPEN |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ 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` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌐 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` | FALSE POSITIVE — `Lobby` already routes its render loop through the memoized `useGetRoom(allJoinedRooms)`. The two remaining `mx.getRoom()` calls are inside drag/drop event handlers (not render loops) and are O(1) SDK map lookups. No change warranted. |
|
|
||||||
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/components/emoji-board/EmojiBoard.tsx` | FIXED (`b7e1f89c`) — pack-label `mx.getRoom()` lookups in `EmojiSidebar`/`StickerSidebar` hoisted into a `useMemo`'d `Map` built once per pack list. |
|
|
||||||
| 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` | FIXED (`b7e1f89c`) — `handleJumpToLatest`/`handleJumpToUnread`/`handleMarkAsRead` wrapped in `useCallback`. |
|
|
||||||
| 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` | FIXED (`b7e1f89c`) — `handleCancelUpload`/`handleSendUpload`/`handleShareLocation`/`handleEmoticonSelect`/`handleStickerSelect` wrapped in `useCallback`. |
|
|
||||||
| Accessibility | `button` for edit history lacks `aria-label` | `cinny/src/app/components/message/content/FallbackContent.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View edit history"` |
|
|
||||||
| Accessibility | `button` for reaction lacks `aria-label` | `cinny/src/app/components/message/Reaction.tsx` | **FIXED ⚠️ UNTESTED** — `Reaction` component now computes `aria-label="{shortcode} reaction, N people"` internally using `getShortcodeFor`; custom (mxc://) emoji falls back to "custom emoji reaction". |
|
|
||||||
| Accessibility | `button` for ThreadIndicator lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View thread"` |
|
|
||||||
| Accessibility | `button` for ReplyLayout lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="Jump to original message"` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Infrastructure, DevEx & Type Safety
|
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
|
||||||
| :------------- | :----------------------------------------------------------------------------------------------------------- | :--------------------------------------------------- | :----- |
|
|
||||||
| Dependencies | `lodash` pinned to non-existent version `4.18.1` | `cinny/package.json` | OPEN |
|
|
||||||
| Dependencies | Various pinned versions of `@atlaskit`, `matrix-js-sdk` | `cinny/package.json` | OPEN |
|
|
||||||
| Dependencies | `matrix-js-sdk` pinned to Release Candidate (`41.6.0-rc.0`) | `cinny/package.json` | OPEN |
|
|
||||||
| Dependencies | Unstable/experimental versions for build tools (`vite` 8.0.14, `typescript` 6.0.3, `eslint` 9.39.4) | `cinny/package.json` | OPEN |
|
|
||||||
| CI/CD | `package-manager-cache` set to `false` | `cinny/.github/workflows/build-pull-request.yml` | OPEN |
|
|
||||||
| CI/CD | Inefficient sequential execution in deployment | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
|
|
||||||
| CI/CD | Aggressive 1-minute timeout for Netlify deploy | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
|
|
||||||
| DevEx | Stale upstream bug tracker link/donations/CLA | `cinny/CONTRIBUTING.md` | OPEN |
|
|
||||||
| DevEx | Alignment issue between README and CONTRIBUTING | `cinny/README.md` | OPEN |
|
|
||||||
| Testing | No evident automated testing configuration/files | `cinny/src/` | OPEN |
|
|
||||||
| Type Safety | Extensive use of `as any` type assertions | `cinny/src/` | OPEN |
|
|
||||||
| Security | Hardcoded public CDN URL; consider moving to environment variable | /root/code/cinny/scripts/syncDecorations.mjs | OPEN |
|
|
||||||
| Architecture | Modifying node_modules directly is brittle; use patch-package instead | /root/code/cinny/scripts/patch-folds.mjs | OPEN |
|
|
||||||
| Robustness | Missing security headers (HSTS, CSP, etc.) and inefficient asset serving using rewrites instead of try_files | /root/code/cinny/contrib/nginx/cinny.domain.tld.conf | OPEN |
|
|
||||||
| Robustness | Incomplete documentation/placeholder path in Caddyfile | /root/code/cinny/contrib/caddy/caddyfile | OPEN |
|
|
||||||
| Matrix SDK | Inefficient listener management (`setMaxListeners: 150`) and incomplete SDK state transition handling. | `src/client/initMatrix.ts` | OPEN |
|
|
||||||
| PWA Robustness | Service worker lacks caching strategy for application assets, resulting in no offline capability. | `cinny/src/sw.ts` | OPEN |
|
|
||||||
| PWA Integrity | `manifest: false` in `vite.config.js` might prevent correct PWA installation if not handled externally. | `cinny/vite.config.js` | OPEN |
|
|
||||||
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/plugins/call/CallEmbed.ts` | VERIFIED COMPLIANT — reviewed during the logging pass (`203568c9`); the existing log path already records only `e.message`, not raw event payloads. No change needed. |
|
|
||||||
| PII Leakage | Potential PII exposure via console.warn (parameter imgError/videoError/thumbError object). | `cinny/src/app/features/room/msgContent.ts` | FIXED (`203568c9`) — media-error warnings now log only `error.name` + `error.message`, never the raw error/event object. |
|
|
||||||
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/features/room/RoomInput.tsx` | VERIFIED COMPLIANT — reviewed during the logging pass (`203568c9`); the existing log path already records only `e.message`. No change needed. |
|
|
||||||
|
|
||||||
## 🏗️ Architectural & Resilience Audit
|
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
|
||||||
| :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------ | :----- |
|
|
||||||
| Element Call Integration | Lacks robust iframe failure monitoring beyond initial 'preparing' event; can result in a permanently hung 'Loading...' state with no user-visible error or recovery path. | `src/app/plugins/call/CallEmbed.ts` | FIXED (`0394fce9`) — added a `CALL_LOAD_WATCHDOG_MS` (25s) timeout that settles on ready/capabilities/joined and fails on iframe error/timeout, exposing a `loadFailed` getter + `onLoadError(cb)`. `CallView` renders a `CallLoadErrorMessage` overlay (Retry/Leave) instead of a permanent spinner. ⚠️ UNTESTED — needs a live call. |
|
|
||||||
| Component Resilience | `RoomTimeline` has no `ErrorBoundary` wrapper — a single malformed event crashing the renderer takes down the entire timeline with no fallback UI. | `src/app/features/room/RoomTimeline.tsx` | FALSE POSITIVE — `RoomView.tsx` (lines 113–137) already wraps `<RoomTimeline>` in a react-error-boundary `ErrorBoundary` with a "Timeline unavailable" fallback. A wave-1 agent's redundant nested boundary was reverted. No change needed. |
|
|
||||||
| Component Resilience | `RoomInput` has no `ErrorBoundary` wrapper — a crash in the composer leaves users unable to send messages. | `src/app/features/room/RoomInput.tsx` | FALSE POSITIVE — `RoomView.tsx` (lines 151–171) already wraps `<RoomInput>` in an `ErrorBoundary` with a "Message composer encountered an error" `RoomInputPlaceholder` fallback. No change needed. |
|
|
||||||
| Fallback Logic | No explicit empty/error fallback for Matrix SDK data calls in `RoomTimeline`; relies purely on SDK internal error propagation, meaning silent failures show a blank timeline. | `src/app/features/room/RoomTimeline.tsx` | ADDRESSED — the `RoomView` `ErrorBoundary` (above) provides the explicit render-error fallback; a thrown SDK/render error now surfaces "Timeline unavailable" rather than a blank timeline. |
|
|
||||||
| Dependency | Potential for complex dependency chains due to deep nesting in `src/app/features/` and `src/app/hooks/`. | `src/app/` | OPEN |
|
|
||||||
| Hydration/Race Condition | The SyncState listener registered by useSyncState may miss the initial 'PREPARED' event if the client initializes synchronously from IndexedDB before the effect runs, leading to an infinite loading state. | `cinny/src/app/pages/client/ClientRoot.tsx` | OPEN |
|
|
||||||
| Structure | High number of small, highly coupled utility hooks (`src/app/hooks/`) may obscure dependency graphs. | `src/app/hooks/` | OPEN |
|
|
||||||
| Dead Code | Potential for unused CSS modules or UI components in `src/app/features/`. | `src/app/` | OPEN |
|
|
||||||
| Security | Sensitive session data (access tokens, device ID) stored in `localStorage` is vulnerable to XSS. | `src/app/state/sessions.ts` | OPEN |
|
|
||||||
| Privacy | Sensitive user status messages and expiry timestamps are persisted in `localStorage`. | `src/app/features/settings/account/Profile.tsx` | OPEN |
|
|
||||||
| Privacy | Unsent composer drafts stored in `localStorage` without encryption could leak info on shared devices. | `src/app/features/room/RoomInput.tsx` | OPEN |
|
|
||||||
| Persistence | Scheduled messages relying on fragile `localStorage` parsing are prone to data loss on session expiry or error. | `src/app/state/scheduledMessages.ts` | OPEN |
|
|
||||||
| Bundle Bloat | Inefficient `lodash` import; risks including entire library instead of necessary utilities. | `cinny/package.json` | OPEN |
|
|
||||||
| Bundle Bloat | Large `matrix-js-sdk` (RC version) dependency; high potential for tree-shaking overhead. | `cinny/package.json` | OPEN |
|
|
||||||
| Build-Time Overhead | `lotusDenoise` plugin performs heavy, sequential `fs` operations during `closeBundle`, significantly slowing build times. | `cinny/vite.config.js` | OPEN |
|
|
||||||
| Build-Time Overhead | Complex manual `viteStaticCopy` configuration requiring multiple renames and path manipulations; risks redundant processing. | `cinny/vite.config.js` | OPEN |
|
|
||||||
| Architectural Debt | Redundant style variant logic in `SpacingVariant` could be simplified. | `cinny/src/app/components/message/layout/layout.css.ts` | OPEN |
|
|
||||||
| Overhead Analysis | Potential CSS bloat from `DropTarget` composition across multiple recipes (`SidebarItem`, `SidebarFolder`). | `cinny/src/app/components/sidebar/Sidebar.css.ts` | OPEN |
|
|
||||||
|
|
||||||
## 🏗️ Git Workflow & History Audit
|
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
|
||||||
| :------- | :------------------------------------------------------------------------------------------------------ | :---------- | :----- |
|
|
||||||
| Workflow | Monolithic "Fix all bugs" commits (e.g., `10f6544e`, `aa48c9ef`) make `git bisect` difficult. | Git History | OPEN |
|
|
||||||
| Workflow | Inconsistent commit message prefixes (e.g., `fix`, `feat`, `docs`, `assets`). | Git History | OPEN |
|
|
||||||
| Workflow | Use of `fix` or `feat` for large-scale changes affecting multiple disparate systems (e.g., `938ead79`). | Git History | OPEN |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Native UI/UX Consistency — Lotus vs. Cinny Baseline
|
|
||||||
|
|
||||||
> Audit of every Lotus-custom UI feature against Cinny's native folds design-system conventions. "Native pattern" means the `folds` component library, vanilla-extract tokens (`color.*`, `config.radii.*`, `config.space.*`), and established Cinny component patterns. 52 findings, organized by severity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔴 Major — Broken Styling / Functional Regressions
|
|
||||||
|
|
||||||
#### N1. `ProfileDecoration` Save Button — Undefined `--accent-cyan` Variable (border invisible on all non-TDS themes)
|
|
||||||
|
|
||||||
- **File:** `src/app/features/settings/account/ProfileDecoration.tsx`, lines 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 |
|
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
# Lotus Chat — Manual Testing Guide
|
||||||
|
|
||||||
|
**Generated:** June 2026
|
||||||
|
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
||||||
|
|
||||||
|
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
||||||
|
|
||||||
|
## Environment notes
|
||||||
|
|
||||||
|
- You push from your own machine; these commits are local on `lotus` until you do.
|
||||||
|
- Test the **web** build (LXC 106 / `code.lotusguild.org`) first; re-run the **call** + **poll** sections on the **desktop (Tauri)** build too, since CSP and the EC iframe behave differently there.
|
||||||
|
- Several call features need a **second participant** (second account on another device/browser, or a colleague). Items that need this are marked **👥 2 people**.
|
||||||
|
- A couple of call items need a **third room/call** in parallel — marked **👥👥**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commits covered
|
||||||
|
|
||||||
|
| Commit | Area |
|
||||||
|
| :--------- | :--------------------------------------------------------------------------- |
|
||||||
|
| `caf6318a` | Poll vote buttons → folds tokens (N4) |
|
||||||
|
| `c67aed01` | In-call incoming-call banner (#4b) |
|
||||||
|
| `4a875884` | Selectable ringtone (#4a) |
|
||||||
|
| `0394fce9` | EC iframe load watchdog + recovery UI; avatar decorations on call tiles (#3) |
|
||||||
|
| `d2946c00` | Upload retry/backoff, presence-on-unload, typed m.direct |
|
||||||
|
| `b7e1f89c` | Timeline/composer/emoji perf memoization |
|
||||||
|
| `c0f98672` | Upstream **Element Call 0.20.1** merge (regression sweep) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Calls — new ringtone + notification work (highest priority)
|
||||||
|
|
||||||
|
### A1. Ringtone selection — preview in Settings
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. Open **Settings → General**, scroll to the **Calls** section.
|
||||||
|
2. Find the new **Ringtone** dropdown (just above **Ringtone Volume**).
|
||||||
|
3. Select each option in turn: **Classic, Chime, Soft, Retro, Silent**.
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Selecting **Classic** plays the existing `call.ogg` clip (cut off after a few seconds).
|
||||||
|
- **Chime / Soft / Retro** each play a short, distinct synthesized preview.
|
||||||
|
- **Silent** plays nothing.
|
||||||
|
- Changing **Ringtone Volume** then re-selecting a ringtone previews at the new volume.
|
||||||
|
- No console errors.
|
||||||
|
|
||||||
|
> ⚠️ **Known browser limitation:** the synthesized tones use WebAudio. If a preview is ever silent, click anywhere on the page once (a "user gesture") and retry — browsers suspend audio until the page has been interacted with. The Settings preview is _after_ a click so it should always sound; this note matters more for A3.
|
||||||
|
|
||||||
|
### A2. Ringtone selection persists
|
||||||
|
|
||||||
|
1. Set Ringtone to **Retro**, reload the app.
|
||||||
|
2. **Expected:** the dropdown still shows **Retro** (setting persisted).
|
||||||
|
3. Bonus: in devtools, set `localStorage.settings` to a bogus `ringtoneId` and reload → it should fall back to **Classic**, not break.
|
||||||
|
|
||||||
|
### A3. Incoming call uses the selected ringtone — 👥 2 people
|
||||||
|
|
||||||
|
**Setup:** Account A (you) and Account B in a **DM** or a **private (invite-only) group** room.
|
||||||
|
|
||||||
|
1. As A, pick a non-silent ringtone (e.g. **Chime**).
|
||||||
|
2. From B, **start a call** in that DM/room. Do **not** answer on A.
|
||||||
|
|
||||||
|
**Expected on A**
|
||||||
|
|
||||||
|
- The full-screen **Incoming Call** dialog appears (caller name, room avatar, Answer / Reject).
|
||||||
|
- The **selected ringtone loops** until you answer/reject/ignore (at the set volume).
|
||||||
|
- Answer → joins the call. Reject (DM) / Ignore (group) → dialog dismisses and ring stops.
|
||||||
|
- Set ringtone to **Silent** and repeat → dialog still appears, **no sound**.
|
||||||
|
|
||||||
|
### A4. In-call banner for a second incoming call — 👥👥 (the trickiest one)
|
||||||
|
|
||||||
|
**Setup:** You (A) already **in a call** in Room 1. Account B can call you in a **different** Room 2 (a DM or private group you share). Ideally a third account C, or B leaves Room 1's call first.
|
||||||
|
|
||||||
|
1. While A is **actively in Room 1's call**, trigger an incoming call to A from **Room 2**.
|
||||||
|
|
||||||
|
**Expected on A**
|
||||||
|
|
||||||
|
- **No** full-screen takeover. Instead a **compact banner appears in the top-right corner** with the caller's avatar, room name, "Incoming voice/video call", and **Answer / Reject (or Ignore)** buttons.
|
||||||
|
- It plays a **single soft ping**, _not_ a looping ring (so it doesn't talk over your active call).
|
||||||
|
- The banner does **not** cover your active call's controls/PiP in a way that blocks them.
|
||||||
|
- **Answer** → switches you into Room 2's call. **Reject/Ignore** → banner disappears.
|
||||||
|
- The banner auto-dismisses if the caller hangs up / the call times out.
|
||||||
|
|
||||||
|
**Also verify the no-op case:** while in Room 1's call, if a notification for **Room 1 itself** arrives, **nothing** should pop up (no banner, no dialog).
|
||||||
|
|
||||||
|
### A5. Camera focus during screenshare (#1) — 👥 2 people
|
||||||
|
|
||||||
|
**Setup:** You (A) and B in a call; B (or another participant) **sharing their screen**, and at least one person with **camera on**.
|
||||||
|
|
||||||
|
1. As A, open the **participant glance** (the stacked avatars / member list for the call) and click a participant who has their **camera on**.
|
||||||
|
2. In the menu, click **"Focus camera"**.
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- The view switches to **spotlight** and **pins that person's camera tile**, overriding the auto-spotlighted screenshare.
|
||||||
|
- It **stays** on that camera (doesn't immediately snap back to the screenshare).
|
||||||
|
- If you pick someone with their camera **off**, it should at worst just toggle spotlight (graceful fallback), not error.
|
||||||
|
|
||||||
|
### A6. Avatar decorations on call tiles (#3) — 👥 2 people
|
||||||
|
|
||||||
|
**Setup:** A participant in the call has an **avatar decoration** set (Settings → Profile decoration).
|
||||||
|
|
||||||
|
1. Join a call with that participant.
|
||||||
|
2. Look at **our** participant roster / prescreen tiles (not the avatars rendered inside the Element Call video grid — those are EC's and out of scope).
|
||||||
|
|
||||||
|
**Expected:** the decoration ring/overlay renders around that participant's avatar on the call tile, the same way it does in member lists.
|
||||||
|
|
||||||
|
### A7. EC iframe load watchdog + recovery UI (#EC, N96)
|
||||||
|
|
||||||
|
This guards against a permanently-stuck "Loading…" call. Also covers the N96 button-label fix (the old "Retry" and "Leave" buttons were identical — now there is a single **"Back"** button).
|
||||||
|
|
||||||
|
1. Normal case: **join a call** → it should connect within a few seconds as usual (the watchdog stays invisible).
|
||||||
|
2. Failure case (best-effort to reproduce): throttle your network hard (devtools → Network → Offline) **right as** you click join, or block the Element Call origin, so the iframe can't finish loading.
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with a single "Back" button** (the old "Retry" + "Leave" pair is gone — they did the same thing and "Retry" was misleading).
|
||||||
|
- Clicking **Back** returns you to the call prescreen, where you can manually click Join to try again.
|
||||||
|
- Normal joins must **not** trigger the error overlay (no false positives) — this is the important part to confirm.
|
||||||
|
- **Self-heal:** if the error overlay appears on a slow network but EC then finishes loading anyway, the overlay should **dismiss itself** and drop you into the live call. Worth confirming on a deliberately throttled-but-not-blocked connection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Polls (N4) — render correctly on non-TDS themes
|
||||||
|
|
||||||
|
This was the actual bug: poll buttons used undefined CSS variables, so on the **default (non-Lotus-Terminal) themes** they rendered with invisible borders / no selected state.
|
||||||
|
|
||||||
|
### B1. Poll renders on a default theme — ✅ PASS
|
||||||
|
|
||||||
|
1. Switch to a **default Cinny theme** (Settings → Appearance — **not** Lotus Terminal / TDS). Test both a **dark** and a **light** theme.
|
||||||
|
2. In any room, create a poll (composer → poll button): a **single-choice** poll with 3 options.
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Each option is a clearly **bordered** button with visible rounded corners.
|
||||||
|
- A **radio circle** indicator is visible on the left of each option.
|
||||||
|
- Text, and (after votes) the percentage, are legible.
|
||||||
|
|
||||||
|
### B2. Voting + selected/progress state
|
||||||
|
|
||||||
|
1. **Vote** on an option.
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- The selected option shows a **filled accent border + filled radio**, and an **accent progress-bar fill** grows behind it proportional to the vote %.
|
||||||
|
- The percentage and total vote count update.
|
||||||
|
- Click again / pick another option → selection moves correctly (single-choice replaces; the bar redraws).
|
||||||
|
|
||||||
|
### B3. Multiple-choice poll
|
||||||
|
|
||||||
|
1. Create a poll allowing **multiple selections**.
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Indicators are **square checkboxes** (not circles); selected ones show a **✓** that's legible against the filled box.
|
||||||
|
- You can select **several** options; each shows its own progress fill.
|
||||||
|
|
||||||
|
### B4. Lotus Terminal theme regression — ✅ PASS
|
||||||
|
|
||||||
|
1. Switch to **Lotus Terminal / TDS** theme and re-open a poll.
|
||||||
|
**Expected:** still looks correct (the fix uses theme tokens, so the TDS accent should now drive it) — no worse than before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Robustness / background behavior
|
||||||
|
|
||||||
|
### C1. Presence updates on tab close
|
||||||
|
|
||||||
|
1. Open the app, then **close the tab** (or quit the browser).
|
||||||
|
2. From another session/device, check your **presence** shortly after.
|
||||||
|
**Expected:** you go **offline/away** reliably (the unload now uses `fetch({keepalive})`). Previously this could be missed.
|
||||||
|
|
||||||
|
### C2. Upload retry on flaky network (best-effort)
|
||||||
|
|
||||||
|
1. In devtools → Network, set a throttle that drops/slows requests, or toggle Offline briefly **during** a file upload.
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- A transient failure **retries** (up to 3×, with backoff) and the upload can still succeed once the network recovers.
|
||||||
|
- A genuine, permanent rejection (e.g. file too large / 4xx) still **fails fast** with the usual error — it should **not** spin retrying.
|
||||||
|
|
||||||
|
### C3. General timeline/composer perf (no functional regression)
|
||||||
|
|
||||||
|
The memoization changes are invisible if correct. Just confirm **nothing broke**:
|
||||||
|
|
||||||
|
- Open a busy room; scrolling, jump-to-latest, mark-as-read all still work.
|
||||||
|
- Composer: send a message, upload a file, share a location, pick an emoji and a sticker — all still work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Element Call 0.20.1 merge — regression sweep (👥 2 people)
|
||||||
|
|
||||||
|
The upstream bump changed EC's internals and DOM selectors; our call controls drive that iframe, so sweep them. In a live call with 2 people, confirm **each** of our control-bar buttons works:
|
||||||
|
|
||||||
|
- [ ] **Mic** mute/unmute (icon + actual audio)
|
||||||
|
- [ ] **Camera** on/off
|
||||||
|
- [ ] **Deafen / Sound** toggle (your deafen key too)
|
||||||
|
- [ ] **Screenshare** start/stop (and the "Share your screen?" confirm)
|
||||||
|
- [ ] **Screenshare audio** mute toggle
|
||||||
|
- [ ] **Fullscreen** toggle
|
||||||
|
- [ ] **⋮ More** menu → **Spotlight/Grid**, **Reactions**, **Settings** each open the right EC panel
|
||||||
|
- [ ] **End** call leaves cleanly
|
||||||
|
- [ ] **PTT** (push-to-talk) if enabled: hold key = transmit, release = mute; releasing on blur works
|
||||||
|
- [ ] **AFK auto-mute** if enabled: goes muted after the timeout
|
||||||
|
- [ ] **PiP** (picture-in-picture) mini window: drag, resize, fullscreen button, return-to-call; the "You muted" / "All muted" badges show on the right person
|
||||||
|
- [ ] **Denoise** (if ML noise suppression enabled): call audio still flows, no silence
|
||||||
|
|
||||||
|
If any control does nothing, that usually means an EC DOM selector changed — capture the console and tell me which button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backlog of previously-fixed-but-unverified items
|
||||||
|
|
||||||
|
> Sections A–D above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** in `LOTUS_BUGS.md` / `LOTUS_TODO.md`. They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way A–D are; do them as you have the right device handy.
|
||||||
|
|
||||||
|
## E. Mobile / responsive (needs a real phone, or devtools device emulation)
|
||||||
|
|
||||||
|
### E1. Composer toolbar touch targets (#7)
|
||||||
|
|
||||||
|
On a phone, open a room and the composer toolbar. Tap each button (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
|
||||||
|
**Expected:** every button is comfortably tappable (≥44×44px), no mis-taps hitting the wrong icon.
|
||||||
|
|
||||||
|
### E2. Room Settings — no horizontal overflow (#8)
|
||||||
|
|
||||||
|
On a narrow phone screen, open **Room Settings**.
|
||||||
|
**Expected:** the settings nav panel fills the full width; **no** horizontal scrollbar / sideways scrolling anywhere in the panel.
|
||||||
|
|
||||||
|
### E3. Modals go fullscreen on mobile (#9)
|
||||||
|
|
||||||
|
On a phone, open several dialogs: Leave Room, Create Room, Create Space, Invite User, Report (room/user/message), Edit History, Forward Message, Remind Me, Schedule Message, Device Verification, Poll Creator.
|
||||||
|
**Expected:** each opens **fullscreen** (no floating box, no rounded corners / max-width margins). On desktop the same modals should still be the normal centered boxes.
|
||||||
|
|
||||||
|
### E4. Composer not hidden by the keyboard (#10) — iOS Safari especially
|
||||||
|
|
||||||
|
On a phone (priority: **iOS Safari**), tap into the composer so the on-screen keyboard appears.
|
||||||
|
**Expected:** the composer input stays **visible above** the keyboard; the layout shrinks rather than the composer sliding under the keyboard.
|
||||||
|
|
||||||
|
### E5. Mobile "Saved Messages" access (Mobile Bookmarks)
|
||||||
|
|
||||||
|
On a phone, **inside a room**, open the room header **··· More Options** menu.
|
||||||
|
**Expected:** a **"Saved Messages"** item is present; tapping it opens the bookmarks panel. (This was the only in-room access point missing on mobile.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. Visual / theming
|
||||||
|
|
||||||
|
### F1. Animated chat background — no flicker (#2)
|
||||||
|
|
||||||
|
Settings → set an **animated** chat background (e.g. anim-rain / anim-aurora / anim-stars). Watch the message text and composer while it animates.
|
||||||
|
**Expected:** smooth animation, **no flickering / shimmering** on message text or the composer, especially after scrolling. Note your GPU/browser if you see artifacts.
|
||||||
|
|
||||||
|
### F2. Background vs. Seasonal theme are mutually exclusive (#6)
|
||||||
|
|
||||||
|
In Settings → Appearance:
|
||||||
|
|
||||||
|
1. Pick a **chat background** → confirm any **seasonal theme** auto-switches off.
|
||||||
|
2. Pick a **seasonal theme** → confirm the **chat background** auto-clears to none.
|
||||||
|
3. (Edge) If you have old data with both set, after reload only one should visibly apply (no double-overlay clutter).
|
||||||
|
|
||||||
|
### F3. Background / seasonal picker grid layout (N81)
|
||||||
|
|
||||||
|
In Settings → Appearance, look at the **Chat Background** and **Seasonal Theme** swatch grids; resize the window narrow→wide.
|
||||||
|
**Expected:** swatches reflow to fill each row evenly (responsive grid), with no lopsided/orphaned last row at any width.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G. Calls — additional unverified (👥 2 people)
|
||||||
|
|
||||||
|
### G1. PiP mute badges point at the right person (#12)
|
||||||
|
|
||||||
|
In a call with at least one other person, pop out the **Picture-in-Picture** mini window.
|
||||||
|
|
||||||
|
- **You** mute your own mic → a **"You"/muted badge appears bottom-left** (your status).
|
||||||
|
- A **remote** participant (or all of them) mutes → an **"All muted"** badge appears **top-right** (clearly about other people).
|
||||||
|
**Expected:** the bottom-left badge is **never** triggered by someone else muting — that was the original bug (it looked like your own mic was muted when it wasn't).
|
||||||
|
|
||||||
|
### G2. Full-screen camera broadcasts
|
||||||
|
|
||||||
|
1. In a **camera-only** call (no screenshare), confirm the **Fullscreen** button is available (previously only showed during screenshare).
|
||||||
|
2. Use **MemberGlance → Focus camera** to full-screen/spotlight a specific person's camera. (Overlaps **A5**; if you've done A5 you can skip.)
|
||||||
|
|
||||||
|
### G3. PTT badge renders on all themes (N53)
|
||||||
|
|
||||||
|
Enable **Push-to-talk** (Settings → Calls) and join a call. Hold the PTT key.
|
||||||
|
**Expected:** the floating PTT badge above the controls shows "PTT — Hold KEY" when idle and "● Live" (green) while held — on **both** a default theme and Lotus Terminal (it's now a single folds Chip; the old terminal-only variant was removed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## H. Media / performance (needs a room with many images)
|
||||||
|
|
||||||
|
### H1. Lazy image decryption (P5-5 / MediaGallery)
|
||||||
|
|
||||||
|
Open a room / media gallery with **many images** (ideally encrypted). Scroll down through them.
|
||||||
|
**Expected:** images decrypt/load as they **approach the viewport**, not all at once on open; scrolling stays smooth and memory doesn't balloon. Off-screen images shouldn't all decode up front.
|
||||||
|
|
||||||
|
### H2. Thumbnail framing (P5-6)
|
||||||
|
|
||||||
|
Look at **tall portrait** images in the timeline and in the media gallery.
|
||||||
|
**Expected:** thumbnails are framed **center-top** (so faces/subjects at the top aren't cropped out); no awkward stretching. Opening the full-size viewer still shows the **whole** image (contain, not cropped).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## I. Accessibility (needs a screen reader: VoiceOver / NVDA / TalkBack)
|
||||||
|
|
||||||
|
With a screen reader on, navigate message hover-actions and content and confirm each control **announces a meaningful label** (not "button" / blank):
|
||||||
|
|
||||||
|
- [ ] **Reaction** buttons announce the emoji + count (e.g. "thumbsup reaction, 3 people").
|
||||||
|
- [ ] **Edit history** button announces "View edit history".
|
||||||
|
- [ ] **Thread indicator** announces "View thread".
|
||||||
|
- [ ] **Reply** (jump to original) announces "Jump to original message".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## J. Desktop / Tauri build only
|
||||||
|
|
||||||
|
### J1. Proactive update notifications (P5-40)
|
||||||
|
|
||||||
|
In the **desktop (Tauri)** build, with an update available, launch the app (and/or leave it running ~12h).
|
||||||
|
**Expected:** an in-app toast/badge alerts you that an update is available, without manually checking Settings. (Needs an actual newer release to point at.)
|
||||||
|
|
||||||
|
### J2. DTLN noise suppression sanity
|
||||||
|
|
||||||
|
In Settings → Calls, enable **ML noise suppression** with the **DTLN** model, then join a call.
|
||||||
|
**Expected:** your mic audio still flows (no silence/robotic dropouts) and background noise is reduced. Confirmed working earlier but flagged for a final real-call check; verify on **both** web and desktop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## K. Features — end-to-end unverified
|
||||||
|
|
||||||
|
### K1. Remind Me Later
|
||||||
|
|
||||||
|
On a message, **··· → Remind Me**, pick a short preset (the 20-min one, or wait one out).
|
||||||
|
**Expected:** when due, a Lotus toast fires linking to that message; the reminder then clears itself. Survives a reload while pending (stored in account data).
|
||||||
|
|
||||||
|
### K2. Advanced search filters (P4-9)
|
||||||
|
|
||||||
|
In message search: use the **sender picker** (instead of typing `from:@user`), the **date-range** quick presets (Today / Last week / Last month / Last year), and the **Has link** toggle.
|
||||||
|
**Expected:** each narrows results correctly and reflects in the search.
|
||||||
|
|
||||||
|
### K3. Notification content + click target (P5-20 partial)
|
||||||
|
|
||||||
|
Trigger a desktop/browser notification for a new message.
|
||||||
|
**Expected:** it shows the **real message body** (`username: message`, not "New inbox notification from…"); **clicking it** brings the window to front and navigates **directly to that message** (not just the inbox).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L. Fixed — verify
|
||||||
|
|
||||||
|
### L1. AFK auto-mute releases the OS microphone indicator on mute (N95) — 👥 live call
|
||||||
|
|
||||||
|
**Context (now FIXED):** `useAfkAutoMute.ts` opened its own `getUserMedia` level-monitor capture for the whole call, so the OS recording indicator (green dot on macOS, mic icon on Windows/Linux) stayed lit even when muted. The capture is now gated on the reactive mic-on state — it runs only while unmuted, so muting releases the stream.
|
||||||
|
|
||||||
|
**To verify:**
|
||||||
|
|
||||||
|
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
|
||||||
|
2. Manually **mute your mic** using the call controls → the **OS recording indicator should clear** within ~a second.
|
||||||
|
3. **Unmute** → the indicator should re-appear (capture re-acquired).
|
||||||
|
4. Also confirm AFK still works end-to-end: stay unmuted and silent past the configured timeout → mic auto-mutes with the "muted after inactivity" toast, and the indicator clears.
|
||||||
|
|
||||||
|
### L2. Maskable PWA icon (N108) — Android install
|
||||||
|
|
||||||
|
1. On **Android Chrome**, install Lotus Chat as a PWA (Add to Home Screen).
|
||||||
|
2. Look at the **home-screen icon**.
|
||||||
|
|
||||||
|
**Expected:** the icon fills the adaptive-icon shape cleanly (the logo centered with safe-zone padding on the dark background), **not** clipped at the corners or floating in an odd box. Also worth a quick check in Chrome DevTools → Application → Manifest that the two `purpose: maskable` icons load without a 404 (this also validates the manifest's icon paths resolve in production — a pre-existing path convention I couldn't verify statically).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M. New features (this round)
|
||||||
|
|
||||||
|
### M1. Search: `has:image` / `has:file` / `has:video` filters
|
||||||
|
|
||||||
|
1. Open message search (in a room with shared images/files/videos in history).
|
||||||
|
2. Run a broad search, then toggle the **Images**, **Files**, **Video** chips (in the filter bar, next to "Has link").
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
|
||||||
|
- Each chip narrows the visible results to that message type; multiple active chips = union (any of them).
|
||||||
|
- Toggling them off restores the full results. The existing room/sender/date/has-link filters still work alongside.
|
||||||
|
- **Known limitation (by design):** filtering is client-side over already-fetched results, so the visible count can be lower than the server's total for that query — paginating/loading more pulls in more to filter. Confirm this reads acceptably.
|
||||||
|
|
||||||
|
### M2. Search: recent searches
|
||||||
|
|
||||||
|
1. Run a few different searches, then **clear the search box** and focus it.
|
||||||
|
|
||||||
|
**Expected:** your last (up to 10) distinct searches appear as clickable chips; clicking one re-runs it. A **Clear** affordance wipes the list. The list **persists across a page refresh** (localStorage).
|
||||||
|
|
||||||
|
### M3. Custom accent color (non-TDS themes) — ⚠️ needs your visual judgment
|
||||||
|
|
||||||
|
1. Make sure **Lotus Terminal (TDS)** is **off**. Settings → Appearance → **Custom Accent Color** → pick a color.
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
|
||||||
|
- The app's accent (buttons, selected/active states, links, primary chips) recolors to your choice **live**.
|
||||||
|
- **Look critically at quality** (this is the part I can't verify): button **text legibility** (OnMain contrast) on the accent buttons; **hover/active** shades; and **selected-row / chip** backgrounds (the translucent "Container" tints). Try a **light** color and a **dark** color and a **saturated** one.
|
||||||
|
- If a dark accent makes selected-row text (OnContainer) hard to read, tell me — that's the one spot in the auto-derived palette most likely to need tuning.
|
||||||
|
- **Reset** clears it back to the theme default.
|
||||||
|
- Turn **Lotus Terminal ON** → the custom accent should be **ignored** (TDS fixed palette wins) and the picker shows a "non-TDS only" note; turn it back off → custom accent returns.
|
||||||
|
- Reload → the chosen accent **persists**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M4. Search: "Pinned only" filter
|
||||||
|
|
||||||
|
In message search, toggle the **Pinned** chip.
|
||||||
|
**Expected:** results narrow to messages currently pinned in their room; composes with the Images/Files/Video chips and room/sender/date filters; toggling off restores results. It also narrows the **encrypted/local-cache** results section (not just server results). Needs a room with actually pinned messages.
|
||||||
|
|
||||||
|
### M5. New theme presets (Cyberpunk / Ocean / Blood Red / Classic Matrix / Midnight) — ⚠️ visual judgment
|
||||||
|
|
||||||
|
Settings → Appearance → theme picker → try each of the 5 new themes.
|
||||||
|
**Expected:** each applies a complete, legible dark palette. Code review computed WCAG contrast and all pass AA, but **eyeball these specifically**: **Midnight** (lowest-contrast accent `#6b7ca8` — selected/focus states), **Classic Matrix** (green accents, light-green body text on near-black), **Blood Red** (white-ish text on bright-red buttons). Confirm Success/Warning/Critical (save/leave/delete) still look correctly green/amber/red, not recolored. Switching back to a stock theme should fully revert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority if you're short on time
|
||||||
|
|
||||||
|
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
|
||||||
|
2. **B1–B3** (polls on a default theme) — the confirmed visual bug.
|
||||||
|
3. **D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls.
|
||||||
|
4. **A7** false-positive check (normal joins don't show the error overlay).
|
||||||
|
5. Everything else.
|
||||||
+37
-95
@@ -5,28 +5,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Infrastructure & Maintenance
|
|
||||||
|
|
||||||
- [x] **Upgrade Synapse to v1.155.0** ✅ Done 2026-06-18
|
|
||||||
- **Context:** 1.155.0 is the last version supporting Debian 12 Bookworm. LXC 151 is already on Debian 13 Trixie — OS migration was completed prior to this upgrade.
|
|
||||||
- **What changed (1.154→1.155):** No breaking changes, no config changes, no DB migrations. Bugfixes: to-device EDU size limiting, restricted room joins, sliding sync subscription response timing. Rust port of more internal classes (perf only).
|
|
||||||
- **MSC4452** (Preview URL capabilities) shipped in 1.154 — opt-in via `msc4452_enabled`, not enabled.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 Quick Feature Additions
|
|
||||||
|
|
||||||
- [x] **Full-Screen Camera Broadcasts** ⚠️ UNTESTED — verify in a real call
|
|
||||||
- **Context:** Element Call currently supports full-screening screenshares. We need to parity this functionality for camera broadcasts.
|
|
||||||
- **Goal:** Users should be able to toggle any camera feed to full-screen mode, similar to the existing screenshare full-screen implementation.
|
|
||||||
- **Implemented 2026-06-18:**
|
|
||||||
1. **Fullscreen button always shows** — removed `screenshare &&` gate in `CallControls.tsx`. The fullscreen button is now available in camera-only calls, not just during screenshares.
|
|
||||||
2. **Per-participant camera focus** — `CallControl.focusCameraParticipant(userId)` added. Finds the participant's video tile via `[data-testid="videoTile"]` / `[data-video-fit]` + `[aria-label="${userId}"]`, enables spotlight mode, then clicks the tile to focus them.
|
|
||||||
3. **MemberGlance "Focus camera" action** — clicking a participant avatar in the call status bar now opens a mini popup with "Focus camera" (triggers focusCameraParticipant) and "View profile" options, rather than immediately opening the profile.
|
|
||||||
4. **PiP fullscreen button** — a small fullscreen toggle button (⛶/⊡) is shown in the PiP overlay top-right, allowing users to go fullscreen directly from PiP mode without navigating back to the call room.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
|
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
|
||||||
|
|
||||||
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
|
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
|
||||||
@@ -37,10 +15,42 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
|
||||||
|
|
||||||
|
> **Every feature we implement must feel native to the upstream Cinny app — indistinguishable from something the Cinny team would have shipped.** Reference: <https://github.com/cinnyapp/cinny>.
|
||||||
|
>
|
||||||
|
> Concretely this means:
|
||||||
|
>
|
||||||
|
> - **Use the `folds` design system, not bespoke UI.** Build with folds primitives (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, etc.) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`, `config.borderWidth.*`). No hardcoded hex/`rgba()` for UI chrome, no invented/undefined CSS variables.
|
||||||
|
> - **Match Cinny's existing patterns.** Before adding UI, find the closest existing Cinny component/flow and mirror it (e.g. a new dropdown uses `Button`+`PopOut`+`Menu`+`MenuItem` like the rest; a new modal has a `Header` with a close `IconButton`; a new setting is a `SettingTile` inside a `SequenceCard`). Consistency with stock Cinny beats personal style.
|
||||||
|
> - **Lotus-custom additions should be unobtrusive** and fit Cinny's visual language, spacing, and interaction conventions — a stranger using Cinny should not be able to tell which features are ours.
|
||||||
|
>
|
||||||
|
> **The ONE exception:** explicit **Lotus Terminal Design System (TDS)** features, which intentionally have their own distinct look and follow the **TDS Design Law** above. TDS styling is opt-in (only active in Lotus Terminal mode); everything else must look and feel like native Cinny.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md).
|
Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ✅ Done — Awaiting Verification
|
||||||
|
|
||||||
|
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then they graduate to LOTUS_FEATURES.md. (Bug-side fixes awaiting verification live in LOTUS_BUGS.md.)
|
||||||
|
|
||||||
|
| Feature | Test guide |
|
||||||
|
| :-------------------------------------------------------------------------------- | :---------------- |
|
||||||
|
| Full-Screen Camera Broadcasts (per-participant focus) | A5 / G2 |
|
||||||
|
| Advanced search filters (sender/date/has-link/has:image·file·video/pinned/recent) | K2 / M1 / M2 / M4 |
|
||||||
|
| Custom Accent Color Picker (non-TDS themes) | M3 |
|
||||||
|
| 5 Color Theme Presets (Cyberpunk/Ocean/Blood Red/Matrix/Midnight) | M5 |
|
||||||
|
| Intersection-based lazy media loading | H1 |
|
||||||
|
| Context-aware thumbnail previews | H2 |
|
||||||
|
| Desktop — proactive update notifications (Tauri) | J1 |
|
||||||
|
| Remind Me Later | K1 |
|
||||||
|
| Mobile Bookmarks access | E5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Legend:
|
Legend:
|
||||||
|
|
||||||
- `[AUDIT REQUIRED]` — at least one assumption needs code/server verification before implementing
|
- `[AUDIT REQUIRED]` — at least one assumption needs code/server verification before implementing
|
||||||
@@ -193,24 +203,6 @@ Features:
|
|||||||
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
|
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
|
||||||
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
|
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
|
||||||
|
|
||||||
### [x] P4-9 · Advanced Search Filter UI — PARTIALLY DONE (UNTESTED)
|
|
||||||
|
|
||||||
**What:** Improve search filter UX in `SearchFilters.tsx`.
|
|
||||||
**Completed 2026-06-18:**
|
|
||||||
|
|
||||||
- ✅ `SelectSenderButton` — picker UI for sender filter (previously required typing `from:@user` by hand)
|
|
||||||
- ✅ `DateRangeButton` — quick-pick presets: Today / Last week / Last month / Last year
|
|
||||||
- ✅ `Has link` chip — `contains_url: true` filter, wired to Matrix API and URL param
|
|
||||||
**UNTESTED** — needs verification at chat.lotusguild.org.
|
|
||||||
|
|
||||||
**Remaining for parity with Discord/Slack:**
|
|
||||||
|
|
||||||
- [ ] `has:image` / `has:file` / `has:video` — msgtype filters (require client-side post-filtering, no server API)
|
|
||||||
- [ ] Pinned messages filter
|
|
||||||
- [ ] Saved searches / search history
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
|
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
|
||||||
|
|
||||||
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
|
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
|
||||||
@@ -262,51 +254,12 @@ Features:
|
|||||||
|
|
||||||
## Priority 5 — Gamer / Aesthetic / Customization
|
## Priority 5 — Gamer / Aesthetic / Customization
|
||||||
|
|
||||||
### [ ] P5-1 · Custom Accent Color Picker (non-TDS mode only)
|
|
||||||
|
|
||||||
**What:** A hex/HSL color picker in Settings → Appearance. Chosen color replaces the primary accent throughout the UI: buttons, badges, active states, highlights, presence dot, links. Applied via a CSS custom property override injected into `<head>`.
|
|
||||||
**IMPORTANT:** This feature is completely inactive when TDS is enabled — TDS has its own fixed palette. Add this setting under a "Non-TDS Themes" section that is hidden when TDS is active.
|
|
||||||
**[AUDIT REQUIRED]** Identify all CSS custom properties that constitute the "accent color" in non-TDS mode. Map them to the folds/vanilla-extract token names. (Confirmed: folds uses vanilla-extract, NOT CSS custom properties — must create a new vanilla-extract theme variant dynamically.)
|
|
||||||
**Complexity:** Medium.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [ ] P5-2 · Additional Color Theme Presets
|
|
||||||
|
|
||||||
**What:** 5 new one-click theme presets alongside TDS. Each must be a complete, polished system with proper contrast ratios (WCAG AA). All implemented as vanilla-extract themes matching the existing TDS pattern.
|
|
||||||
|
|
||||||
Themes:
|
|
||||||
|
|
||||||
1. **Cyberpunk** — deep navy bg (`#0a0015`), electric purple (`#bf5fff`) + hot pink (`#ff2d9b`) accents, neon glow
|
|
||||||
2. **Ocean** — deep sea blue bg (`#020b18`), teal (`#00c9b1`) + aqua (`#0096d6`) accents, soft feel
|
|
||||||
3. **Blood Red** — near-black bg (`#0d0203`), deep crimson (`#7a0010`) + bright red (`#ff2233`) accents
|
|
||||||
4. **Classic Matrix** — pure black bg (`#000000`), phosphor green (`#00ff41`) text + accents
|
|
||||||
5. **Midnight** — dark charcoal (`#111827`), cool blue-grey (`#6b7ca8`) accents, clean minimal
|
|
||||||
|
|
||||||
**[AUDIT REQUIRED]** Study `src/lotus-terminal.css.ts` for the full token list before designing themes. All tokens must be covered (~50 CSS custom properties each).
|
|
||||||
**Complexity:** Medium (design effort is the main cost).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [MOVED] P5-9 · LFG (Looking for Group) Command → LotusBot
|
### [MOVED] P5-9 · LFG (Looking for Group) Command → LotusBot
|
||||||
|
|
||||||
**Decision:** Implemented as `!lfg` in LotusBot rather than a client slash command. Bot-side rendering works consistently across all Matrix clients; client-side enhanced cards would only be visible to Lotus Chat users and require sanitizer auditing. The bot can also support richer flows (list active LFGs, DM interested players, auto-expire posts).
|
**Decision:** Implemented as `!lfg` in LotusBot rather than a client slash command. Bot-side rendering works consistently across all Matrix clients; client-side enhanced cards would only be visible to Lotus Chat users and require sanitizer auditing. The bot can also support richer flows (list active LFGs, DM interested players, auto-expire posts).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [x] P5-5 · Intersection-Based Lazy Loading ⚠️ UNTESTED — needs verification in timeline with many images
|
|
||||||
|
|
||||||
**What:** Use `IntersectionObserver` to trigger media decryption and loading only when components approach the viewport.
|
|
||||||
**Approach:** Reduce initial memory footprint and improve timeline load times by deferring decryption of images/videos until they are visible.
|
|
||||||
|
|
||||||
### [x] P5-6 · Context-Aware Thumbnail Previews ⚠️ UNTESTED
|
|
||||||
|
|
||||||
**What:** Enhance thumbnail rendering in the timeline for consistent, polished aesthetics.
|
|
||||||
**Approach:** Use CSS `object-fit: cover` with improved focal-point centering within `ThumbnailContent` to prevent media stretching or awkward aspect-ratio cropping.
|
|
||||||
**Fix Applied:** Added `objectPosition: 'center top'` to: (1) `media.css.ts` → `Image` component (timeline images), (2) video thumbnail inline style in `RenderMessageContent.tsx`, (3) `GalleryTile` `<img>` in `MediaGallery.tsx`. Full-size viewers retain `objectFit: 'contain'` — no change. `objectPosition: 'center top'` prevents face/subject cropping on tall portrait images capped at 600px by `AttachmentBox`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [ ] P5-15 · In-Call Soundboard
|
### [ ] P5-15 · In-Call Soundboard
|
||||||
|
|
||||||
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
|
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
|
||||||
@@ -367,15 +320,6 @@ Themes:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) ⚠️ UNTESTED (requires Tauri build)
|
|
||||||
|
|
||||||
**What:** Automatically check for app updates on launch and periodically during long sessions. If an update is available, show an in-app toast or badge (e.g., on the Settings icon) to alert the user without requiring a manual check in settings.
|
|
||||||
**Mechanism:** Use the `useTauriUpdater` hook in a global component like `ClientNonUIFeatures.tsx`.
|
|
||||||
**Note:** Ensure the check is throttled (e.g., once every 12 hours) to avoid redundant Tauri commands.
|
|
||||||
**Complexity:** Low-Medium.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications
|
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications
|
||||||
|
|
||||||
**What:** Replace emulated notifications with native WinRT Toast notifications.
|
**What:** Replace emulated notifications with native WinRT Toast notifications.
|
||||||
@@ -454,13 +398,6 @@ Themes:
|
|||||||
## 🚀 Features to Add
|
## 🚀 Features to Add
|
||||||
|
|
||||||
- [ ] **Mobile Audit:** Comprehensive audit of all features in LOTUS_FEATURES.md for mobile PWA usability and layout responsiveness.
|
- [ ] **Mobile Audit:** Comprehensive audit of all features in LOTUS_FEATURES.md for mobile PWA usability and layout responsiveness.
|
||||||
- [x] **Remind Me Later:** Slack-style reminders for messages — fully implemented ⚠️ UNTESTED end-to-end
|
|
||||||
- **Storage:** `useReminders.ts` — persists to `io.lotus.reminders` account data with `addReminder` / `removeReminder` / `getReminders`
|
|
||||||
- **UI:** `RemindMeDialog.tsx` — 4 presets (20 min, 1 hr, 3 hr, tomorrow 9am); wired into `Message.tsx` context menu via `remindOpen` state; `useModalStyle(320)` for mobile fullscreen
|
|
||||||
- **Monitor:** `ReminderMonitor` in `ClientNonUIFeatures.tsx` — polls every 30s + on tab visibility; fires Lotus toast when due and calls `removeReminder`
|
|
||||||
- [x] **Mobile Bookmarks:** Fixed ⚠️ UNTESTED — bookmarks now accessible from within any room on mobile
|
|
||||||
- **Root Cause:** `BookmarksPanel` renders correctly on mobile but `BookmarksTab` lives in `SidebarNav`, which is hidden when inside a room on mobile (`MobileFriendlyClientNav` returns `null`). No trigger existed.
|
|
||||||
- **Fix:** Added "Saved Messages" `MenuItem` to the `RoomMenu` (···More Options) in `RoomViewHeader.tsx`. Toggles `bookmarksPanelAtom` and closes the menu. Works on all screen sizes — desktop users see it as a duplicate of the sidebar star, mobile users now have their only in-room access point.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -543,6 +480,7 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
|
|||||||
**Mechanism:** KaTeX injection into the HTML parser.
|
**Mechanism:** KaTeX injection into the HTML parser.
|
||||||
|
|
||||||
- **Sanitizer (`src/app/utils/sanitize.ts`):** Allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
|
- **Sanitizer (`src/app/utils/sanitize.ts`):** Allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
|
||||||
|
> [Gemini_Found] `sanitize.ts` uses **`sanitize-html`** (not DOMPurify) with an explicit allowlist (`allowedTags`) and `disallowedTagsMode: 'discard'`. All MathML tags are currently absent from the allowlist and are silently stripped. Update `permittedHtmlTags` to include: `<math>`, `<mi>`, `<mo>`, `<mn>`, `<ms>`, `<mtext>`, `<mspace>`, `<mrow>`, `<mfrac>`, `<msqrt>`, `<mroot>`, `<mstyle>`, `<merror>`, `<mpadded>`, `<mphantom>`, `<mfenced>`, `<menclose>`, `<msub>`, `<msup>`, `<msubsup>`, `<munder>`, `<mover>`, `<munderover>`, `<mmultiscripts>`, `<mtable>`, `<mtr>`, `<mtd>`, `<maligngroup>`, `<malignmark>`, and `annotation`. Also add the required MathML attributes (e.g. `xmlns`, `display`, `mathvariant`) to `permittedTagToAttributes`.
|
||||||
- **Parser (`src/app/plugins/react-custom-html-parser.tsx`):** Detect `$ ... $` and `$$ ... $$` patterns in text nodes:
|
- **Parser (`src/app/plugins/react-custom-html-parser.tsx`):** Detect `$ ... $` and `$$ ... $$` patterns in text nodes:
|
||||||
```tsx
|
```tsx
|
||||||
if (node.type === 'text') {
|
if (node.type === 'text') {
|
||||||
@@ -592,12 +530,16 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
|
|||||||
- Route the mic `MediaStream` and the clip source to the destination node.
|
- Route the mic `MediaStream` and the clip source to the destination node.
|
||||||
- Pass the destination's `.stream` to the call bridge.
|
- Pass the destination's `.stream` to the call bridge.
|
||||||
|
|
||||||
|
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### P5-20 · Quick Reply from Browser Notification
|
### P5-20 · Quick Reply from Browser Notification
|
||||||
|
|
||||||
**Mechanism:** Service Worker `notificationclick` Action.
|
**Mechanism:** Service Worker `notificationclick` Action.
|
||||||
|
|
||||||
|
> [Gemini_Found] Implementation detail: `serviceWorkerRegistration.showNotification()` should be used instead of `new Notification()` so that the service worker can listen to the `notificationclick` event. `new Notification()` creates notifications that are bound to the client page, not the SW.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// src/sw.ts
|
// src/sw.ts
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
|
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
|
||||||
|
|
||||||
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1
|
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.3
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ A Matrix chat client built for Lotus Guild — fast, private, and packed with th
|
|||||||
|
|
||||||
The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny).
|
The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny).
|
||||||
|
|
||||||
The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+19
-2
@@ -30,6 +30,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive the parent origin for postMessage targetOrigin from the parentUrl
|
||||||
|
// widget param (a full URL) so denoise-status messages aren't broadcast with
|
||||||
|
// '*'. Fall back to this frame's own origin if parentUrl is missing/malformed.
|
||||||
|
var targetOrigin;
|
||||||
|
try {
|
||||||
|
var parentUrl = params.get('parentUrl');
|
||||||
|
targetOrigin = parentUrl ? new URL(parentUrl).origin : window.location.origin;
|
||||||
|
} catch (e) {
|
||||||
|
targetOrigin = window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
var md = navigator.mediaDevices;
|
var md = navigator.mediaDevices;
|
||||||
if (!md || typeof md.getUserMedia !== 'function') return;
|
if (!md || typeof md.getUserMedia !== 'function') return;
|
||||||
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
|
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
|
||||||
@@ -274,6 +285,9 @@
|
|||||||
source.disconnect();
|
source.disconnect();
|
||||||
mlNode.disconnect();
|
mlNode.disconnect();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
if (gateNode) gateNode.disconnect();
|
||||||
|
} catch (e) {}
|
||||||
try {
|
try {
|
||||||
origTrack.stop();
|
origTrack.stop();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -301,7 +315,7 @@
|
|||||||
nativeNS: USE_NATIVE_NS,
|
nativeNS: USE_NATIVE_NS,
|
||||||
gate: USE_GATE,
|
gate: USE_GATE,
|
||||||
},
|
},
|
||||||
'*',
|
targetOrigin,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +330,10 @@
|
|||||||
.catch(function (e) {
|
.catch(function (e) {
|
||||||
var msg = e instanceof Error ? e.message : String(e);
|
var msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error('[lotus-denoise] Setup failed:', msg);
|
console.error('[lotus-denoise] Setup failed:', msg);
|
||||||
window.parent.postMessage({ type: 'lotus-denoise-status', active: false, error: msg }, '*');
|
window.parent.postMessage(
|
||||||
|
{ type: 'lotus-denoise-status', active: false, error: msg },
|
||||||
|
targetOrigin,
|
||||||
|
);
|
||||||
return stream;
|
return stream;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-497
@@ -21,7 +21,6 @@
|
|||||||
"@giphy/js-util": "5.2.0",
|
"@giphy/js-util": "5.2.0",
|
||||||
"@giphy/react-components": "10.1.2",
|
"@giphy/react-components": "10.1.2",
|
||||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||||
"@sentry/react": "10.53.1",
|
|
||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
@@ -54,7 +53,6 @@
|
|||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
"lodash": "4.18.1",
|
|
||||||
"matrix-js-sdk": "41.6.0-rc.0",
|
"matrix-js-sdk": "41.6.0-rc.0",
|
||||||
"matrix-widget-api": "1.17.0",
|
"matrix-widget-api": "1.17.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
@@ -82,7 +80,6 @@
|
|||||||
"@element-hq/element-call-embedded": "0.20.1",
|
"@element-hq/element-call-embedded": "0.20.1",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@sentry/vite-plugin": "5.3.0",
|
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
@@ -3783,403 +3780,6 @@
|
|||||||
"integrity": "sha512-jh3+V9yM+zxLriQexoGm0GatoPaJWjs6ypFIbFYwQp+AoUb55eUXrjKtKQyuC5zShzzeAQUl0M5JzqB7SSrsRA==",
|
"integrity": "sha512-jh3+V9yM+zxLriQexoGm0GatoPaJWjs6ypFIbFYwQp+AoUb55eUXrjKtKQyuC5zShzzeAQUl0M5JzqB7SSrsRA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@sentry-internal/browser-utils": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/feedback": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/replay": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/browser-utils": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/replay-canvas": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/replay": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/babel-plugin-component-annotate": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/browser": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/browser-utils": "10.53.1",
|
|
||||||
"@sentry-internal/feedback": "10.53.1",
|
|
||||||
"@sentry-internal/replay": "10.53.1",
|
|
||||||
"@sentry-internal/replay-canvas": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/core": "^7.18.5",
|
|
||||||
"@sentry/babel-plugin-component-annotate": "5.3.0",
|
|
||||||
"@sentry/cli": "^2.58.5",
|
|
||||||
"dotenv": "^16.3.1",
|
|
||||||
"find-up": "^5.0.0",
|
|
||||||
"glob": "^13.0.6",
|
|
||||||
"magic-string": "~0.30.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/glob": {
|
|
||||||
"version": "13.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
|
||||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"minimatch": "^10.2.2",
|
|
||||||
"minipass": "^7.1.3",
|
|
||||||
"path-scurry": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/minimatch": {
|
|
||||||
"version": "10.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
|
||||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^5.0.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/minipass": {
|
|
||||||
"version": "7.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
|
||||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16 || 14 >=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"https-proxy-agent": "^5.0.0",
|
|
||||||
"node-fetch": "^2.6.7",
|
|
||||||
"progress": "^2.0.3",
|
|
||||||
"proxy-from-env": "^1.1.0",
|
|
||||||
"which": "^2.0.2"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"sentry-cli": "bin/sentry-cli"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@sentry/cli-darwin": "2.58.6",
|
|
||||||
"@sentry/cli-linux-arm": "2.58.6",
|
|
||||||
"@sentry/cli-linux-arm64": "2.58.6",
|
|
||||||
"@sentry/cli-linux-i686": "2.58.6",
|
|
||||||
"@sentry/cli-linux-x64": "2.58.6",
|
|
||||||
"@sentry/cli-win32-arm64": "2.58.6",
|
|
||||||
"@sentry/cli-win32-i686": "2.58.6",
|
|
||||||
"@sentry/cli-win32-x64": "2.58.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-darwin": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-arm": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-arm64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-i686": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==",
|
|
||||||
"cpu": [
|
|
||||||
"x86",
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-x64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-arm64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-i686": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==",
|
|
||||||
"cpu": [
|
|
||||||
"x86",
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-x64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/core": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/react": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/browser": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/rollup-plugin": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/bundler-plugin-core": "5.3.0",
|
|
||||||
"magic-string": "~0.30.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"rollup": ">=3.2.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"rollup": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/vite-plugin": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/bundler-plugin-core": "5.3.0",
|
|
||||||
"@sentry/rollup-plugin": "5.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@simple-libs/stream-utils": {
|
"node_modules/@simple-libs/stream-utils": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz",
|
||||||
@@ -4894,18 +4494,6 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/agent-base": {
|
|
||||||
"version": "6.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
|
||||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -6635,19 +6223,6 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
|
||||||
"version": "16.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
|
||||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -8474,19 +8049,6 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "6",
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/husky": {
|
"node_modules/husky": {
|
||||||
"version": "9.1.7",
|
"version": "9.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||||
@@ -10600,26 +10162,6 @@
|
|||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/node-fetch": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"whatwg-url": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "4.x || >=6.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"encoding": "^0.1.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"encoding": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||||
@@ -11179,16 +10721,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/progress": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -11199,13 +10731,6 @@
|
|||||||
"react-is": "^16.13.1"
|
"react-is": "^16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -12784,12 +12309,6 @@
|
|||||||
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
|
||||||
"version": "0.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -13337,22 +12856,6 @@
|
|||||||
"defaults": "^1.0.3"
|
"defaults": "^1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/webidl-conversions": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/whatwg-url": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tr46": "~0.0.3",
|
|
||||||
"webidl-conversions": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"@giphy/js-util": "5.2.0",
|
"@giphy/js-util": "5.2.0",
|
||||||
"@giphy/react-components": "10.1.2",
|
"@giphy/react-components": "10.1.2",
|
||||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||||
"@sentry/react": "10.53.1",
|
|
||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
@@ -78,7 +77,6 @@
|
|||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
"lodash": "4.18.1",
|
|
||||||
"matrix-js-sdk": "41.6.0-rc.0",
|
"matrix-js-sdk": "41.6.0-rc.0",
|
||||||
"matrix-widget-api": "1.17.0",
|
"matrix-widget-api": "1.17.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
@@ -106,7 +104,6 @@
|
|||||||
"@element-hq/element-call-embedded": "0.20.1",
|
"@element-hq/element-call-embedded": "0.20.1",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@sentry/vite-plugin": "5.3.0",
|
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
|
|||||||
@@ -2,6 +2,57 @@
|
|||||||
"Organisms": {
|
"Organisms": {
|
||||||
"RoomCommon": {
|
"RoomCommon": {
|
||||||
"changed_room_name": " changed room name"
|
"changed_room_name": " changed room name"
|
||||||
|
},
|
||||||
|
"CreateRoom": {
|
||||||
|
"chat_room": "Chat Room",
|
||||||
|
"chat_room_desc": "Messages, photos, and videos.",
|
||||||
|
"voice_room": "Voice Room",
|
||||||
|
"voice_room_desc": "Live audio and video conversations."
|
||||||
|
},
|
||||||
|
"ImageViewer": {
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
|
"Message": {
|
||||||
|
"open_location": "Open Location",
|
||||||
|
"thread": "Thread"
|
||||||
|
},
|
||||||
|
"ImageContent": {
|
||||||
|
"view": "View",
|
||||||
|
"spoiler": "Spoiler",
|
||||||
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"DeviceVerification": {
|
||||||
|
"close": "Close",
|
||||||
|
"accept": "Accept",
|
||||||
|
"they_match": "They Match",
|
||||||
|
"okay": "Okay",
|
||||||
|
"do_not_match": "Do not Match",
|
||||||
|
"please_accept": "Please accept the request from other device.",
|
||||||
|
"waiting_accept": "Waiting for request to be accepted...",
|
||||||
|
"click_accept": "Click accept to start the verification process.",
|
||||||
|
"request_accepted": "Verification request has been accepted.",
|
||||||
|
"waiting_response": "Waiting for the response from other device...",
|
||||||
|
"starting_emoji": "Starting verification using emoji comparison...",
|
||||||
|
"confirm_emoji": "Confirm the emoji below are displayed on both devices, in the same order:",
|
||||||
|
"device_verified": "Your device is verified.",
|
||||||
|
"verification_canceled": "Verification has been canceled."
|
||||||
|
},
|
||||||
|
"UrlPreview": {
|
||||||
|
"join_server": "Join Server"
|
||||||
|
},
|
||||||
|
"InviteUser": {
|
||||||
|
"invite": "Invite"
|
||||||
|
},
|
||||||
|
"UploadBoard": {
|
||||||
|
"files": "Files",
|
||||||
|
"send": "Send",
|
||||||
|
"upload_failed": "Upload Failed"
|
||||||
|
},
|
||||||
|
"PasswordStage": {
|
||||||
|
"account_password": "Account Password",
|
||||||
|
"password": "Password",
|
||||||
|
"invalid_password": "Invalid Password!",
|
||||||
|
"authenticate_prompt": "To perform this action you need to authenticate yourself by entering you account password."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,18 @@
|
|||||||
"src": "./res/android/android-chrome-512x512.png",
|
"src": "./res/android/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./res/android/maskable-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./res/android/maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": ["social", "communication", "productivity"],
|
"categories": ["social", "communication", "productivity"],
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
+11
-2
@@ -19,8 +19,17 @@ try {
|
|||||||
writeFileSync(foldsPath, content, 'utf8');
|
writeFileSync(foldsPath, content, 'utf8');
|
||||||
console.log('Applied defensive Icon src guard to folds.');
|
console.log('Applied defensive Icon src guard to folds.');
|
||||||
} else {
|
} else {
|
||||||
console.warn('Warning: folds Icon patch target not found - may need updating.');
|
// Genuine "patch could not be applied" case: the target string is gone
|
||||||
|
// (folds renamed/restructured it) AND it isn't already patched. Fail hard
|
||||||
|
// so the postinstall hook / CI breaks loudly instead of silently shipping
|
||||||
|
// an unpatched folds (which crashes at render with "src is not a function").
|
||||||
|
console.error(
|
||||||
|
'ERROR: folds Icon patch target not found - folds may have updated. ' +
|
||||||
|
'Update the patch target string in scripts/patch-folds.mjs before building.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Warning: Could not patch folds:', e.message);
|
console.error('ERROR: Could not patch folds:', e.message);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
const root = join(__dirname, '..');
|
const root = join(__dirname, '..');
|
||||||
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
|
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
|
||||||
|
|
||||||
const CDN = 'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
// Single source of truth: the CDN base URL lives in avatarDecorations.ts as
|
||||||
|
// `export const DECORATION_CDN`. We extract it from there at runtime rather than
|
||||||
|
// re-declaring it here, so the build script and the app can never drift. This
|
||||||
|
// .mjs script can't cleanly import the browser-side .ts module (it's outside the
|
||||||
|
// Vite/TS app graph), so we parse the constant out of the file text instead.
|
||||||
|
// If you migrate the CDN, change it ONLY in avatarDecorations.ts.
|
||||||
|
const catalog = readFileSync(catalogPath, 'utf8');
|
||||||
|
|
||||||
|
const cdnMatch = catalog.match(/export const DECORATION_CDN\s*=\s*['"]([^'"]+)['"]/);
|
||||||
|
if (!cdnMatch) {
|
||||||
|
console.error(
|
||||||
|
'Could not find `export const DECORATION_CDN` in avatarDecorations.ts — ' +
|
||||||
|
'the constant may have been renamed. Update scripts/syncDecorations.mjs.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const CDN = cdnMatch[1];
|
||||||
|
|
||||||
// Extract all slugs from the catalog file
|
// Extract all slugs from the catalog file
|
||||||
const catalog = readFileSync(catalogPath, 'utf8');
|
|
||||||
const slugMatches = [...catalog.matchAll(/slug: '([^']+)'/g)].map((m) => m[1]);
|
const slugMatches = [...catalog.matchAll(/slug: '([^']+)'/g)].map((m) => m[1]);
|
||||||
|
|
||||||
if (slugMatches.length === 0) {
|
if (slugMatches.length === 0) {
|
||||||
@@ -41,7 +56,8 @@ async function headCheck(slug) {
|
|||||||
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
|
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
|
||||||
return { slug, ok: res.ok, status: res.status };
|
return { slug, ok: res.ok, status: res.status };
|
||||||
} catch {
|
} catch {
|
||||||
return { slug, ok: false, status: 0 };
|
// Network/DNS/TLS failure — NOT a confirmation the file is gone.
|
||||||
|
return { slug, ok: false, status: 0, networkError: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +69,27 @@ for (let i = 0; i < slugMatches.length; i += BATCH) {
|
|||||||
results.push(...batchResults);
|
results.push(...batchResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
const missing = results.filter((r) => !r.ok);
|
// Only a CONFIRMED HTTP 404 means the file is genuinely gone and safe to
|
||||||
|
// remove. A network error or any other non-ok status (5xx, 403, timeout) is
|
||||||
|
// ambiguous — the CDN may be unreachable — so refuse to remove anything and
|
||||||
|
// abort, otherwise a transient outage would wipe the whole catalog from source
|
||||||
|
// control (N119).
|
||||||
|
const transient = results.filter((r) => !r.ok && r.status !== 404);
|
||||||
|
if (transient.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`Aborting: ${transient.length} decoration(s) returned a non-404 failure ` +
|
||||||
|
`(network error / server error). The CDN may be unreachable — refusing to ` +
|
||||||
|
`remove entries to avoid wiping the catalog.`,
|
||||||
|
);
|
||||||
|
transient
|
||||||
|
.slice(0, 8)
|
||||||
|
.forEach((r) =>
|
||||||
|
console.error(` ${r.slug}: ${r.networkError ? 'network error' : `HTTP ${r.status}`}`),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = results.filter((r) => r.status === 404);
|
||||||
const found = results.filter((r) => r.ok);
|
const found = results.filter((r) => r.ok);
|
||||||
|
|
||||||
if (missing.length === 0) {
|
if (missing.length === 0) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
config,
|
config,
|
||||||
Dialog,
|
Dialog,
|
||||||
Icon,
|
Icon,
|
||||||
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
Overlay,
|
Overlay,
|
||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
@@ -40,7 +41,7 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
|
|||||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import CallSound from '../../../public/sound/call.ogg';
|
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
||||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||||
@@ -51,6 +52,7 @@ import { mxcUrlToHttp, getMxIdLocalPart } from '../utils/matrix';
|
|||||||
import { RoomAvatar, RoomIcon } from './room-avatar';
|
import { RoomAvatar, RoomIcon } from './room-avatar';
|
||||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||||
import { getChatBg } from '../features/lotus/chatBackground';
|
import { getChatBg } from '../features/lotus/chatBackground';
|
||||||
|
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
|
||||||
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
@@ -103,8 +105,8 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
const canAnswer = livekitSupported && rtcSupported;
|
const canAnswer = livekitSupported && rtcSupported;
|
||||||
const { room } = info;
|
const { room } = info;
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
|
||||||
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
|
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
|
||||||
const roomName = useRoomName(room);
|
const roomName = useRoomName(room);
|
||||||
const roomAvatar = useRoomAvatar(room, dm);
|
const roomAvatar = useRoomAvatar(room, dm);
|
||||||
@@ -125,25 +127,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const playSound = useCallback(() => {
|
|
||||||
const audioElement = audioRef.current;
|
|
||||||
if (!audioElement) return;
|
|
||||||
audioElement.volume = Math.max(0, Math.min(1, ringtoneVolume / 100));
|
|
||||||
audioElement.play().catch(() => undefined);
|
|
||||||
}, [ringtoneVolume]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audioEl = audioRef.current;
|
if (info.notificationType !== 'ring') return undefined;
|
||||||
if (info.notificationType === 'ring') {
|
const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
playSound();
|
return stop;
|
||||||
}
|
}, [info.notificationType, ringtoneId, ringtoneVolume]);
|
||||||
return () => {
|
|
||||||
if (audioEl) {
|
|
||||||
audioEl.pause();
|
|
||||||
audioEl.currentTime = 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [playSound, info.notificationType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const remaining = info.senderTs + info.lifetime - Date.now();
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||||
@@ -156,7 +144,6 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
}, [info.senderTs, info.lifetime, onIgnore]);
|
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
@@ -258,10 +245,154 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</OverlayCenter>
|
</OverlayCenter>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
<audio ref={audioRef} loop style={{ display: 'none' }}>
|
);
|
||||||
<source src={CallSound} type="audio/ogg" />
|
}
|
||||||
</audio>
|
|
||||||
</>
|
type IncomingCallBannerProps = {
|
||||||
|
dm: boolean;
|
||||||
|
info: IncomingCallInfo;
|
||||||
|
onIgnore: () => void;
|
||||||
|
onAnswer: (room: Room, video: boolean) => void;
|
||||||
|
onReject: (room: Room, eventId: string) => void;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Compact, non-intrusive incoming-call notification shown when the user is
|
||||||
|
* ALREADY in a call. Unlike the full-screen `IncomingCall` overlay this is a
|
||||||
|
* corner banner that does not take over the screen, and it plays a single
|
||||||
|
* soft ping (via the one-shot ringtone preview) rather than the looping ring,
|
||||||
|
* so it doesn't talk over the active call.
|
||||||
|
*/
|
||||||
|
function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallBannerProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const { room } = info;
|
||||||
|
const isVideo = info.intent === 'video';
|
||||||
|
|
||||||
|
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
|
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
|
||||||
|
const roomName = useRoomName(room);
|
||||||
|
const roomAvatar = useRoomAvatar(room, dm);
|
||||||
|
const avatarUrl = roomAvatar
|
||||||
|
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const session = useCallSession(room);
|
||||||
|
useCallMembersChange(
|
||||||
|
session,
|
||||||
|
useCallback(
|
||||||
|
(members) => {
|
||||||
|
if (members.length === 0) {
|
||||||
|
onIgnore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onIgnore],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Single soft ping (non-looping) on arrival, respecting the chosen ringtone
|
||||||
|
// + volume. We intentionally do NOT loop here — the user is mid-call — and we
|
||||||
|
// ping exactly once per incoming call, not again if the user happens to tweak
|
||||||
|
// ringtone settings while the banner is showing.
|
||||||
|
const pingedRef = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
if (info.notificationType !== 'ring') return;
|
||||||
|
if (pingedRef.current === info.refEventId) return;
|
||||||
|
pingedRef.current = info.refEventId;
|
||||||
|
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
|
}, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
onIgnore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = setTimeout(onIgnore, remaining);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||||
|
|
||||||
|
const callerName =
|
||||||
|
getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="300"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: config.space.S400,
|
||||||
|
right: config.space.S400,
|
||||||
|
zIndex: 9990,
|
||||||
|
width: toRem(300),
|
||||||
|
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||||
|
padding: config.space.S300,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
boxShadow: `0 8px 32px ${color.Other.Shadow}`,
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
aria-label={`Incoming ${isVideo ? 'video' : 'voice'} call from ${roomName}`}
|
||||||
|
>
|
||||||
|
<Box gap="300" alignItems="Center">
|
||||||
|
<Box shrink="No">
|
||||||
|
<Avatar size="300" className={CallAvatarAnimation}>
|
||||||
|
<RoomAvatar
|
||||||
|
roomId={room.roomId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={roomName}
|
||||||
|
renderFallback={() => (
|
||||||
|
<RoomIcon
|
||||||
|
roomType={room.getType()}
|
||||||
|
size="200"
|
||||||
|
joinRule={room.getJoinRule()}
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0 }}>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{roomName}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300" truncate>
|
||||||
|
{isVideo ? 'Incoming video call' : 'Incoming voice call'}
|
||||||
|
{dm ? '' : ` · ${callerName}`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant="Success"
|
||||||
|
fill="Solid"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => onAnswer(room, isVideo)}
|
||||||
|
before={<Icon size="100" src={isVideo ? Icons.VideoCamera : Icons.Phone} filled />}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B300">
|
||||||
|
Answer
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant={dm ? 'Critical' : 'Secondary'}
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||||
|
before={<Icon size="100" src={Icons.Cross} filled />}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B300">
|
||||||
|
{dm ? 'Reject' : 'Ignore'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,10 +521,25 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
[startCall, navigateRoom],
|
[startCall, navigateRoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
|
if (!callInfo) return null;
|
||||||
|
// Already in this room's own call — no notification at all.
|
||||||
|
if (callEmbed?.roomId === callInfo.room.roomId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return !joined && callInfo ? (
|
// In a different call already: show the compact, non-intrusive banner
|
||||||
|
// instead of the full-screen takeover overlay.
|
||||||
|
if (joined) {
|
||||||
|
return (
|
||||||
|
<IncomingCallBanner
|
||||||
|
dm={dm}
|
||||||
|
info={callInfo}
|
||||||
|
onIgnore={handleIgnore}
|
||||||
|
onAnswer={handleAnswer}
|
||||||
|
onReject={handleReject}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<IncomingCall
|
<IncomingCall
|
||||||
dm={dm}
|
dm={dm}
|
||||||
info={callInfo}
|
info={callInfo}
|
||||||
@@ -401,7 +547,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
onAnswer={handleAnswer}
|
onAnswer={handleAnswer}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
/>
|
/>
|
||||||
) : null;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||||
@@ -576,7 +722,25 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
if (pipMode) {
|
if (pipMode) {
|
||||||
if (!wasInPip) {
|
if (!wasInPip) {
|
||||||
const saved = localStorage.getItem('pip-position');
|
const saved = localStorage.getItem('pip-position');
|
||||||
const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : null;
|
let savedPos: { left: number; top: number } | null = null;
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(saved) as { left?: unknown; top?: unknown };
|
||||||
|
// Validate shape + finiteness: a corrupt value would otherwise feed
|
||||||
|
// NaN into Math.min and produce an invalid `NaNpx` CSS value.
|
||||||
|
if (
|
||||||
|
raw &&
|
||||||
|
typeof raw.left === 'number' &&
|
||||||
|
Number.isFinite(raw.left) &&
|
||||||
|
typeof raw.top === 'number' &&
|
||||||
|
Number.isFinite(raw.top)
|
||||||
|
) {
|
||||||
|
savedPos = { left: raw.left, top: raw.top };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
savedPos = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
el.style.right = 'auto';
|
el.style.right = 'auto';
|
||||||
el.style.bottom = 'auto';
|
el.style.bottom = 'auto';
|
||||||
if (savedPos) {
|
if (savedPos) {
|
||||||
@@ -933,10 +1097,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
||||||
{document.fullscreenEnabled && (
|
{document.fullscreenEnabled && (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||||
title={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handlePipFullscreen();
|
handlePipFullscreen();
|
||||||
@@ -945,19 +1112,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
// Dark scrim is intentional for legibility over arbitrary video.
|
// Dark scrim is intentional for legibility over arbitrary video.
|
||||||
background: 'rgba(0,0,0,0.65)',
|
background: 'rgba(0,0,0,0.65)',
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(4px)',
|
||||||
border: 'none',
|
|
||||||
borderRadius: config.radii.R300,
|
|
||||||
padding: `${config.space.S100} ${config.space.S200}`,
|
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: '13px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
lineHeight: 1,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pipIsFullscreen ? '⊡' : '⛶'}
|
{pipIsFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Verifier,
|
Verifier,
|
||||||
} from 'matrix-js-sdk/lib/crypto-api';
|
} from 'matrix-js-sdk/lib/crypto-api';
|
||||||
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
|
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
|
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -51,21 +52,23 @@ function WaitingMessage({ message }: WaitingMessageProps) {
|
|||||||
|
|
||||||
type VerificationUnexpectedProps = { message: string; onClose: () => void };
|
type VerificationUnexpectedProps = { message: string; onClose: () => void };
|
||||||
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
|
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>{message}</Text>
|
<Text>{message}</Text>
|
||||||
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
||||||
<Text size="B400">Close</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VerificationWaitAccept() {
|
function VerificationWaitAccept() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Please accept the request from other device.</Text>
|
<Text>{t('Organisms.DeviceVerification.please_accept')}</Text>
|
||||||
<WaitingMessage message="Waiting for request to be accepted..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_accept')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,12 +77,13 @@ type VerificationAcceptProps = {
|
|||||||
onAccept: () => Promise<void>;
|
onAccept: () => Promise<void>;
|
||||||
};
|
};
|
||||||
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [acceptState, accept] = useAsyncCallback(onAccept);
|
const [acceptState, accept] = useAsyncCallback(onAccept);
|
||||||
|
|
||||||
const accepting = acceptState.status === AsyncStatus.Loading;
|
const accepting = acceptState.status === AsyncStatus.Loading;
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Click accept to start the verification process.</Text>
|
<Text>{t('Organisms.DeviceVerification.click_accept')}</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
fill="Solid"
|
fill="Solid"
|
||||||
@@ -87,17 +91,18 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
|||||||
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
|
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
|
||||||
disabled={accepting}
|
disabled={accepting}
|
||||||
>
|
>
|
||||||
<Text size="B400">Accept</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.accept')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VerificationWaitStart() {
|
function VerificationWaitStart() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Verification request has been accepted.</Text>
|
<Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
|
||||||
<WaitingMessage message="Waiting for the response from other device..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -106,18 +111,20 @@ type VerificationStartProps = {
|
|||||||
onStart: () => Promise<void>;
|
onStart: () => Promise<void>;
|
||||||
};
|
};
|
||||||
function AutoVerificationStart({ onStart }: VerificationStartProps) {
|
function AutoVerificationStart({ onStart }: VerificationStartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStart();
|
onStart();
|
||||||
}, [onStart]);
|
}, [onStart]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<WaitingMessage message="Starting verification using emoji comparison..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
|
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
|
||||||
|
|
||||||
const confirming =
|
const confirming =
|
||||||
@@ -125,7 +132,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
|
<Text>{t('Organisms.DeviceVerification.confirm_emoji')}</Text>
|
||||||
<Box
|
<Box
|
||||||
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
||||||
style={{
|
style={{
|
||||||
@@ -157,7 +164,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
|||||||
disabled={confirming}
|
disabled={confirming}
|
||||||
before={confirming && <Spinner size="100" variant="Primary" />}
|
before={confirming && <Spinner size="100" variant="Primary" />}
|
||||||
>
|
>
|
||||||
<Text size="B400">They Match</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.they_match')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
@@ -165,7 +172,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
|||||||
onClick={() => sasData.mismatch()}
|
onClick={() => sasData.mismatch()}
|
||||||
disabled={confirming}
|
disabled={confirming}
|
||||||
>
|
>
|
||||||
<Text size="B400">Do not Match</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.do_not_match')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -177,6 +184,7 @@ type SasVerificationProps = {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [sasData, setSasData] = useState<ShowSasCallbacks>();
|
const [sasData, setSasData] = useState<ShowSasCallbacks>();
|
||||||
|
|
||||||
useVerifierShowSas(verifier, setSasData);
|
useVerifierShowSas(verifier, setSasData);
|
||||||
@@ -192,7 +200,7 @@ function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<WaitingMessage message="Starting verification using emoji comparison..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -201,13 +209,14 @@ type VerificationDoneProps = {
|
|||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
};
|
};
|
||||||
function VerificationDone({ onExit }: VerificationDoneProps) {
|
function VerificationDone({ onExit }: VerificationDoneProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<div>
|
<div>
|
||||||
<Text>Your device is verified.</Text>
|
<Text>{t('Organisms.DeviceVerification.device_verified')}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="Primary" fill="Solid" onClick={onExit}>
|
<Button variant="Primary" fill="Solid" onClick={onExit}>
|
||||||
<Text size="B400">Okay</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.okay')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -217,11 +226,12 @@ type VerificationCanceledProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
|
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Verification has been canceled.</Text>
|
<Text>{t('Organisms.DeviceVerification.verification_canceled')}</Text>
|
||||||
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
||||||
<Text size="B400">Close</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
|
import { color, Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus';
|
import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus';
|
||||||
|
|
||||||
type MemberVerificationBadgeProps = {
|
type MemberVerificationBadgeProps = {
|
||||||
@@ -9,8 +9,7 @@ type MemberVerificationBadgeProps = {
|
|||||||
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
|
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
|
||||||
const vs = useUserVerifiedStatus(userId);
|
const vs = useUserVerifiedStatus(userId);
|
||||||
if (vs === 'unknown') return null;
|
if (vs === 'unknown') return null;
|
||||||
const color =
|
const iconColor = vs === 'verified' ? color.Success.Main : color.Warning.Main;
|
||||||
vs === 'verified' ? 'var(--tc-positive-normal, #5effc4)' : 'var(--tc-warning-normal, #ffcc55)';
|
|
||||||
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
|
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
|
||||||
return (
|
return (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
@@ -27,7 +26,7 @@ export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps
|
|||||||
title={label}
|
title={label}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<Icon size="100" src={Icons.ShieldUser} style={{ color }} />
|
<Icon size="100" src={Icons.ShieldUser} style={{ color: iconColor }} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
|
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
|
||||||
import { SequenceCard } from '../sequence-card';
|
import { SequenceCard } from '../sequence-card';
|
||||||
import { SettingTile } from '../setting-tile';
|
import { SettingTile } from '../setting-tile';
|
||||||
@@ -17,6 +18,7 @@ export function CreateRoomTypeSelector({
|
|||||||
disabled,
|
disabled,
|
||||||
getIcon,
|
getIcon,
|
||||||
}: CreateRoomTypeSelectorProps) {
|
}: CreateRoomTypeSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
@@ -36,10 +38,10 @@ export function CreateRoomTypeSelector({
|
|||||||
>
|
>
|
||||||
<Box gap="200" alignItems="Baseline">
|
<Box gap="200" alignItems="Baseline">
|
||||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||||
Chat Room
|
{t('Organisms.CreateRoom.chat_room')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T300" priority="300" truncate>
|
<Text size="T300" priority="300" truncate>
|
||||||
- Messages, photos, and videos.
|
- {t('Organisms.CreateRoom.chat_room_desc')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</SettingTile>
|
</SettingTile>
|
||||||
@@ -61,10 +63,10 @@ export function CreateRoomTypeSelector({
|
|||||||
>
|
>
|
||||||
<Box gap="200" alignItems="Baseline">
|
<Box gap="200" alignItems="Baseline">
|
||||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||||
Voice Room
|
{t('Organisms.CreateRoom.voice_room')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T300" priority="300" truncate>
|
<Text size="T300" priority="300" truncate>
|
||||||
- Live audio and video conversations.
|
- {t('Organisms.CreateRoom.voice_room_desc')}
|
||||||
</Text>
|
</Text>
|
||||||
<BetaNoticeBadge />
|
<BetaNoticeBadge />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import FileSaver from 'file-saver';
|
import FileSaver from 'file-saver';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||||
@@ -15,6 +16,7 @@ export type ImageViewerProps = {
|
|||||||
|
|
||||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
radii="300"
|
radii="300"
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
before={<Icon size="50" src={Icons.Download} />}
|
||||||
>
|
>
|
||||||
<Text size="B300">Download</Text>
|
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Overlay,
|
Overlay,
|
||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
@@ -66,6 +67,7 @@ type InviteUserProps = {
|
|||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const modalStyle = useModalStyle(560);
|
const modalStyle = useModalStyle(560);
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
@@ -194,7 +196,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
Invite
|
{t('Organisms.InviteUser.invite')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" gap="100" alignItems="Center">
|
<Box shrink="No" gap="100" alignItems="Center">
|
||||||
@@ -351,7 +353,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
disabled={!validUserId || inviting}
|
disabled={!validUserId || inviting}
|
||||||
before={inviting && <Spinner size="200" variant="Primary" fill="Solid" />}
|
before={inviting && <Spinner size="200" variant="Primary" fill="Solid" />}
|
||||||
>
|
>
|
||||||
<Text size="B400">Invite</Text>
|
<Text size="B400">{t('Organisms.InviteUser.invite')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
|
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Box, Button, config, 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 { IContent } from 'matrix-js-sdk';
|
||||||
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
|
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
|
||||||
@@ -507,6 +508,7 @@ type MLocationProps = {
|
|||||||
content: IContent;
|
content: IContent;
|
||||||
};
|
};
|
||||||
export function MLocation({ content }: MLocationProps) {
|
export function MLocation({ content }: MLocationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const geoUri = content.geo_uri;
|
const geoUri = content.geo_uri;
|
||||||
if (typeof geoUri !== 'string') return <BrokenContent />;
|
if (typeof geoUri !== 'string') return <BrokenContent />;
|
||||||
const location = parseGeoUri(geoUri);
|
const location = parseGeoUri(geoUri);
|
||||||
@@ -527,7 +529,7 @@ export function MLocation({ content }: MLocationProps) {
|
|||||||
style={{
|
style={{
|
||||||
width: '280px',
|
width: '280px',
|
||||||
height: '160px',
|
height: '160px',
|
||||||
border: '1px solid var(--bg-surface-border)',
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
}}
|
}}
|
||||||
@@ -549,7 +551,7 @@ export function MLocation({ content }: MLocationProps) {
|
|||||||
radii="300"
|
radii="300"
|
||||||
before={<Icon src={Icons.External} size="50" />}
|
before={<Icon src={Icons.External} size="50" />}
|
||||||
>
|
>
|
||||||
<Text size="B300">Open Location</Text>
|
<Text size="B300">{t('Organisms.Message.open_location')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
|
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
|
||||||
import { EventTimelineSet, Room } from 'matrix-js-sdk';
|
import { EventTimelineSet, Room } from 'matrix-js-sdk';
|
||||||
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
|
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
|
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
@@ -37,7 +38,9 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
shrink="No"
|
shrink="No"
|
||||||
className={css.ThreadIndicator}
|
className={css.ThreadIndicator}
|
||||||
@@ -47,9 +50,10 @@ export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<Icon size="50" src={Icons.Thread} />
|
<Icon size="50" src={Icons.Thread} />
|
||||||
<Text size="L400">Thread</Text>
|
<Text size="L400">{t('Organisms.Message.thread')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
));
|
);
|
||||||
|
});
|
||||||
|
|
||||||
type ReplyProps = {
|
type ReplyProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -81,6 +82,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [viewer, setViewer] = useState(false);
|
const [viewer, setViewer] = useState(false);
|
||||||
@@ -168,7 +170,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
onClick={loadSrc}
|
onClick={loadSrc}
|
||||||
before={<Icon size="Inherit" src={Icons.Photo} filled />}
|
before={<Icon size="Inherit" src={Icons.Photo} filled />}
|
||||||
>
|
>
|
||||||
<Text size="B300">View</Text>
|
<Text size="B300">{t('Organisms.ImageContent.view')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -212,7 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="B300">Spoiler</Text>
|
<Text size="B300">{t('Organisms.ImageContent.spoiler')}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -247,7 +249,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
onClick={handleRetry}
|
onClick={handleRetry}
|
||||||
before={<Icon size="Inherit" src={Icons.Warning} filled />}
|
before={<Icon size="Inherit" src={Icons.Warning} filled />}
|
||||||
>
|
>
|
||||||
<Text size="B300">Retry</Text>
|
<Text size="B300">{t('Organisms.ImageContent.retry')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Text } from 'folds';
|
import { Box, color, config, Text, toRem } from 'folds';
|
||||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import { RoomEvent } from 'matrix-js-sdk';
|
import { RoomEvent } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
@@ -175,7 +175,7 @@ export function PollContent({
|
|||||||
|
|
||||||
if (!poll) {
|
if (!poll) {
|
||||||
return (
|
return (
|
||||||
<Text style={{ opacity: 0.6 }}>
|
<Text priority="300">
|
||||||
<i>Poll (unreadable format)</i>
|
<i>Poll (unreadable format)</i>
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -244,21 +244,20 @@ export function PollContent({
|
|||||||
gap="200"
|
gap="200"
|
||||||
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
|
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
|
||||||
>
|
>
|
||||||
<Box
|
<Text
|
||||||
alignItems="Center"
|
as="div"
|
||||||
gap="100"
|
size="T200"
|
||||||
|
priority="300"
|
||||||
data-poll-content-label
|
data-poll-content-label
|
||||||
style={{
|
style={{
|
||||||
fontSize: '0.68rem',
|
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: '0.12em',
|
letterSpacing: '0.12em',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
opacity: 0.55,
|
marginBottom: config.space.S100,
|
||||||
marginBottom: '2px',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
|
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
|
||||||
</Box>
|
</Text>
|
||||||
<Text size="T400" style={{ fontWeight: 600 }}>
|
<Text size="T400" style={{ fontWeight: 600 }}>
|
||||||
{questionText}
|
{questionText}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -280,18 +279,19 @@ export function PollContent({
|
|||||||
data-selected={selected}
|
data-selected={selected}
|
||||||
onClick={canVote ? () => handleVote(id) : undefined}
|
onClick={canVote ? () => handleVote(id) : undefined}
|
||||||
style={{
|
style={{
|
||||||
padding: '7px 12px',
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
borderRadius: '8px',
|
borderRadius: config.radii.R300,
|
||||||
background: selected ? 'var(--accent-cyan-dim)' : 'rgba(255,255,255,0.04)',
|
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
|
||||||
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
|
border: `${config.borderWidth.B300} solid ${
|
||||||
fontSize: '0.88rem',
|
selected ? color.Primary.Main : color.SurfaceVariant.ContainerLine
|
||||||
|
}`,
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
cursor: canVote ? 'pointer' : 'default',
|
cursor: canVote ? 'pointer' : 'default',
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '4px',
|
gap: config.space.S100,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -306,58 +306,59 @@ export function PollContent({
|
|||||||
inset: 0,
|
inset: 0,
|
||||||
right: 'auto',
|
right: 'auto',
|
||||||
width: `${pct}%`,
|
width: `${pct}%`,
|
||||||
background: selected ? 'var(--accent-cyan-dim)' : 'rgba(255,255,255,0.03)',
|
background: selected
|
||||||
|
? color.Primary.ContainerActive
|
||||||
|
: color.SurfaceVariant.ContainerActive,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
transition: 'width 0.3s ease',
|
transition: 'width 0.3s ease',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isMultiple && (
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
width: '14px',
|
width: toRem(14),
|
||||||
height: '14px',
|
height: toRem(14),
|
||||||
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--accent-cyan-border)'}`,
|
border: `${config.borderWidth.B300} solid ${
|
||||||
borderRadius: '3px',
|
selected ? color.Primary.Main : color.Primary.ContainerLine
|
||||||
background: selected ? 'var(--accent-cyan)' : 'none',
|
}`,
|
||||||
|
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
|
||||||
|
background: selected ? color.Primary.Main : 'transparent',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '10px',
|
color: color.Primary.OnMain,
|
||||||
color: '#fff',
|
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected ? '✓' : ''}
|
{selected && isMultiple ? (
|
||||||
|
<Text as="span" size="T200" style={{ lineHeight: 1 }}>
|
||||||
|
✓
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
)}
|
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||||
{!isMultiple && (
|
{text}
|
||||||
<span
|
</Text>
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
width: '14px',
|
|
||||||
height: '14px',
|
|
||||||
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--accent-cyan-border)'}`,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: selected ? 'var(--accent-cyan)' : 'none',
|
|
||||||
transition: 'all 0.15s',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span style={{ flexGrow: 1 }}>{text}</span>
|
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<span style={{ opacity: 0.55, fontSize: '0.78rem', flexShrink: 0 }}>{pct}%</span>
|
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
|
<Text size="T200" priority="300" style={{ marginTop: '2px' }}>
|
||||||
<i>
|
<i>
|
||||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
|
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
|
||||||
{canVote
|
{canVote
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { MouseEventHandler, useState } from 'react';
|
||||||
|
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
export type SettingsSelectOption<T extends string> = {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A folds-native dropdown (Button + PopOut + Menu) matching Cinny's select
|
||||||
|
* pattern — used instead of a raw `<select>`, which renders OS-styled and
|
||||||
|
* breaks under non-default themes.
|
||||||
|
*/
|
||||||
|
export function SettingsSelect<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
options: SettingsSelectOption<T>[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}) {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
|
||||||
|
|
||||||
|
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (v: T) => {
|
||||||
|
onChange(v);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
outlined
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleMenu}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={!!menuCords}
|
||||||
|
>
|
||||||
|
<Text size="T300">{selectedLabel}</Text>
|
||||||
|
</Button>
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<MenuItem
|
||||||
|
key={opt.value}
|
||||||
|
size="300"
|
||||||
|
variant={opt.value === value ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
disabled={opt.disabled}
|
||||||
|
onClick={() => !opt.disabled && handleSelect(opt.value)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{opt.label}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,10 +17,10 @@ export const Sidebar = style([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export const SidebarGlass = style({
|
export const SidebarGlass = style({
|
||||||
backgroundColor: 'rgba(3, 5, 8, 0.55)',
|
backgroundColor: `color-mix(in srgb, ${color.Surface.Container} 55%, transparent)`,
|
||||||
backdropFilter: 'blur(12px)',
|
backdropFilter: 'blur(12px)',
|
||||||
WebkitBackdropFilter: 'blur(12px)',
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
borderRight: '1px solid rgba(255,255,255,0.06)',
|
borderRight: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SidebarStack = style([
|
export const SidebarStack = style([
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
|
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
|
||||||
import React, { FormEventHandler } from 'react';
|
import React, { FormEventHandler } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AuthType } from 'matrix-js-sdk';
|
import { AuthType } from 'matrix-js-sdk';
|
||||||
import { StageComponentProps } from './types';
|
import { StageComponentProps } from './types';
|
||||||
import { ErrorCode } from '../../cs-errorcode';
|
import { ErrorCode } from '../../cs-errorcode';
|
||||||
@@ -13,6 +14,7 @@ export function PasswordStage({
|
|||||||
}: StageComponentProps & {
|
}: StageComponentProps & {
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { errorCode, error, session } = stageData;
|
const { errorCode, error, session } = stageData;
|
||||||
|
|
||||||
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
@@ -44,7 +46,7 @@ export function PasswordStage({
|
|||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text as="h2" size="H4">
|
<Text as="h2" size="H4">
|
||||||
Account Password
|
{t('Organisms.PasswordStage.account_password')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
|
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
|
||||||
@@ -59,12 +61,9 @@ export function PasswordStage({
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text size="T200">
|
<Text size="T200">{t('Organisms.PasswordStage.authenticate_prompt')}</Text>
|
||||||
To perform this action you need to authenticate yourself by entering you account
|
|
||||||
password.
|
|
||||||
</Text>
|
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Password</Text>
|
<Text size="L400">{t('Organisms.PasswordStage.password')}</Text>
|
||||||
<PasswordInput size="400" name="passwordInput" outlined autoFocus required />
|
<PasswordInput size="400" name="passwordInput" outlined autoFocus required />
|
||||||
{errorCode && (
|
{errorCode && (
|
||||||
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
|
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
|
||||||
@@ -72,7 +71,7 @@ export function PasswordStage({
|
|||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
<b>
|
<b>
|
||||||
{errorCode === ErrorCode.M_FORBIDDEN
|
{errorCode === ErrorCode.M_FORBIDDEN
|
||||||
? 'Invalid Password!'
|
? t('Organisms.PasswordStage.invalid_password')
|
||||||
: `${errorCode}: ${error}`}
|
: `${errorCode}: ${error}`}
|
||||||
</b>
|
</b>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react';
|
import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds';
|
import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
@@ -43,6 +44,7 @@ export function UploadBoardHeader({
|
|||||||
onSend,
|
onSend,
|
||||||
imperativeHandlerRef,
|
imperativeHandlerRef,
|
||||||
}: UploadBoardHeaderProps) {
|
}: UploadBoardHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const sendingRef = useRef(false);
|
const sendingRef = useRef(false);
|
||||||
const uploads = useAtomValue(uploadFamilyObserverAtom);
|
const uploads = useAtomValue(uploadFamilyObserverAtom);
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export function UploadBoardHeader({
|
|||||||
gap="100"
|
gap="100"
|
||||||
>
|
>
|
||||||
<Icon src={open ? Icons.ChevronTop : Icons.ChevronRight} size="50" />
|
<Icon src={open ? Icons.ChevronTop : Icons.ChevronRight} size="50" />
|
||||||
<Text size="H6">Files</Text>
|
<Text size="H6">{t('Organisms.UploadBoard.files')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className={css.UploadBoardHeaderContent} alignItems="Center" gap="100">
|
<Box className={css.UploadBoardHeaderContent} alignItems="Center" gap="100">
|
||||||
{isSuccess && (
|
{isSuccess && (
|
||||||
@@ -100,12 +102,12 @@ export function UploadBoardHeader({
|
|||||||
outlined
|
outlined
|
||||||
after={<Icon src={Icons.Send} size="50" filled />}
|
after={<Icon src={Icons.Send} size="50" filled />}
|
||||||
>
|
>
|
||||||
<Text size="B300">Send</Text>
|
<Text size="B300">{t('Organisms.UploadBoard.send')}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{isError && !open && (
|
{isError && !open && (
|
||||||
<Badge variant="Critical" fill="Solid" radii="300">
|
<Badge variant="Critical" fill="Solid" radii="300">
|
||||||
<Text size="L400">Upload Failed</Text>
|
<Text size="L400">{t('Organisms.UploadBoard.upload_failed')}</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!isSuccess && !isError && !open && (
|
{!isSuccess && !isError && !open && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
||||||
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
||||||
import { ImageOverlay } from '../ImageOverlay';
|
import { ImageOverlay } from '../ImageOverlay';
|
||||||
@@ -1343,6 +1344,7 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
|
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||||
@@ -1383,7 +1385,9 @@ function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
|
|||||||
priority="300"
|
priority="300"
|
||||||
>
|
>
|
||||||
<SiteBadge label="Discord" colorClass={previewCss.BadgeDiscord} />
|
<SiteBadge label="Discord" colorClass={previewCss.BadgeDiscord} />
|
||||||
<span style={{ marginLeft: '6px', opacity: 0.7, fontSize: '0.85em' }}>Join Server</span>
|
<span style={{ marginLeft: '6px', opacity: 0.7, fontSize: '0.85em' }}>
|
||||||
|
{t('Organisms.UrlPreview.join_server')}
|
||||||
|
</span>
|
||||||
</Text>
|
</Text>
|
||||||
{title && (
|
{title && (
|
||||||
<Text truncate priority="400">
|
<Text truncate priority="400">
|
||||||
|
|||||||
@@ -91,10 +91,10 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
|
|||||||
{(status) => {
|
{(status) => {
|
||||||
const deviceColor =
|
const deviceColor =
|
||||||
status === VerificationStatus.Verified
|
status === VerificationStatus.Verified
|
||||||
? 'var(--tc-positive-normal, #5effc4)'
|
? color.Success.Main
|
||||||
: status === VerificationStatus.Unverified
|
: status === VerificationStatus.Unverified
|
||||||
? 'var(--tc-warning-normal, #ffcc55)'
|
? color.Warning.Main
|
||||||
: 'var(--tc-surface-low-contrast)';
|
: color.SurfaceVariant.OnContainer;
|
||||||
return (
|
return (
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
<Icon size="100" src={Icons.ShieldUser} style={{ color: deviceColor, flexShrink: 0 }} />
|
<Icon size="100" src={Icons.ShieldUser} style={{ color: deviceColor, flexShrink: 0 }} />
|
||||||
@@ -106,7 +106,7 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
|
|||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
truncate
|
truncate
|
||||||
style={{ color: 'var(--tc-surface-low-contrast)', fontFamily: 'monospace' }}
|
style={{ color: color.SurfaceVariant.OnContainer, fontFamily: 'monospace' }}
|
||||||
>
|
>
|
||||||
{device.deviceId}
|
{device.deviceId}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -160,7 +160,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="100"
|
gap="100"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-surface-variant)',
|
background: color.SurfaceVariant.Container,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
padding: config.space.S300,
|
padding: config.space.S300,
|
||||||
}}
|
}}
|
||||||
@@ -171,7 +171,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
|
|||||||
<Text size="T300">
|
<Text size="T300">
|
||||||
<b>Sessions</b>
|
<b>Sessions</b>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" style={{ color: 'var(--tc-surface-low-contrast)' }}>
|
<Text size="T200" style={{ color: color.SurfaceVariant.OnContainer }}>
|
||||||
{devices.length}
|
{devices.length}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -87,7 +88,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
|
||||||
const [pttActive, setPttActive] = useState(false);
|
const [pttActive, setPttActive] = useState(false);
|
||||||
|
|
||||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||||
@@ -244,49 +244,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
justifyContent="Center"
|
justifyContent="Center"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
>
|
>
|
||||||
{pttMode &&
|
{pttMode && (
|
||||||
(lotusTerminal ? (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-2.5rem',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
background: pttActive ? 'var(--lt-accent-green-dim)' : 'var(--lt-accent-orange-dim)',
|
|
||||||
border: `1px solid ${pttActive ? 'var(--lt-accent-green-border)' : 'var(--lt-accent-orange-border)'}`,
|
|
||||||
borderRadius: '99px',
|
|
||||||
padding: '0.2rem 0.9rem',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
size="T200"
|
|
||||||
style={{
|
|
||||||
color: pttActive ? 'var(--lt-accent-green)' : 'var(--lt-accent-orange)',
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '0.08em',
|
|
||||||
fontFamily: 'JetBrains Mono, monospace',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pttActive ? (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
animation: 'pttLivePulse 900ms ease-in-out infinite',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
●
|
|
||||||
</span>
|
|
||||||
{' LIVE'}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
`PTT — Hold ${pttKeyLabel}`
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Chip
|
<Chip
|
||||||
variant={pttActive ? 'Success' : 'Warning'}
|
variant={pttActive ? 'Success' : 'Warning'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
@@ -305,7 +263,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
|
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
)}
|
||||||
{shareConfirm && (
|
{shareConfirm && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -319,8 +277,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
bottom: '110%',
|
bottom: '110%',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
background: 'var(--bg-surface)',
|
background: color.Surface.Container,
|
||||||
border: '1px solid var(--bg-surface-border)',
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
borderRadius: '0.75rem',
|
borderRadius: '0.75rem',
|
||||||
padding: '1rem 1.25rem',
|
padding: '1rem 1.25rem',
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ function CallLoadErrorMessage() {
|
|||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
// Disposing the embed tears down the hung iframe and returns the user to the
|
// Disposing the embed tears down the hung iframe and returns the user to the
|
||||||
// prescreen, from which they can join again ("Retry") or simply walk away.
|
// prescreen, where they can choose to join again.
|
||||||
const dismiss = () => setCallEmbed(undefined);
|
const dismiss = () => setCallEmbed(undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,11 +180,8 @@ function CallLoadErrorMessage() {
|
|||||||
The call failed to load. Check your connection and try again.
|
The call failed to load. Check your connection and try again.
|
||||||
</Text>
|
</Text>
|
||||||
<Box gap="200" alignItems="Center">
|
<Box gap="200" alignItems="Center">
|
||||||
<Button variant="Primary" size="300" radii="400" onClick={dismiss}>
|
|
||||||
<Text size="B400">Retry</Text>
|
|
||||||
</Button>
|
|
||||||
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
|
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
|
||||||
<Text size="B400">Leave</Text>
|
<Text size="B400">Back</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -166,13 +166,13 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FullscreenIcon = () => (
|
export const FullscreenIcon = () => (
|
||||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z" />
|
<path d="M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ExitFullscreenIcon = () => (
|
export const ExitFullscreenIcon = () => (
|
||||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M9 3H7v4H3v2h6V3zm6 0v6h6V7h-4V3h-2zM3 13v2h4v4h2v-6H3zm14 4v-4h2v6h-6v-2h4z" />
|
<path d="M9 3H7v4H3v2h6V3zm6 0v6h6V7h-4V3h-2zM3 13v2h4v4h2v-6H3zm14 4v-4h2v6h-6v-2h4z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
import { Box, Button, color, Icon, Icons, Spinner, Text } from 'folds';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
|
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
|
||||||
@@ -78,10 +78,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" direction="Column" gap="200">
|
<Box grow="Yes" direction="Column" gap="200">
|
||||||
{micDenied && (
|
{micDenied && (
|
||||||
<Text
|
<Text size="T200" style={{ color: color.Critical.Main, textAlign: 'center' }}>
|
||||||
size="T200"
|
|
||||||
style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}
|
|
||||||
>
|
|
||||||
Microphone access is blocked. Enable it in your browser settings to join.
|
Microphone access is blocked. Enable it in your browser settings to join.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// Single source of truth for the avatar-decoration CDN base URL.
|
||||||
|
// scripts/syncDecorations.mjs reads this exact `DECORATION_CDN` declaration out
|
||||||
|
// of this file at runtime (by regex) instead of re-declaring it, so the two can
|
||||||
|
// never drift. If you migrate the CDN, change it here ONLY — keep the
|
||||||
|
// `export const DECORATION_CDN = '...'` shape so the sync script can still parse it.
|
||||||
export const DECORATION_CDN =
|
export const DECORATION_CDN =
|
||||||
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem, Button } from 'folds';
|
import {
|
||||||
import { useAtomValue } from 'jotai';
|
Text,
|
||||||
|
Box,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
Spinner,
|
||||||
|
IconButton,
|
||||||
|
Line,
|
||||||
|
toRem,
|
||||||
|
Button,
|
||||||
|
} from 'folds';
|
||||||
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { EventTimeline, EventType, Room, SearchOrderBy } from 'matrix-js-sdk';
|
import { EventTimeline, EventType, Room, SearchOrderBy } from 'matrix-js-sdk';
|
||||||
|
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
|
import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||||
@@ -18,8 +31,18 @@ import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../
|
|||||||
import { useRooms } from '../../state/hooks/roomList';
|
import { useRooms } from '../../state/hooks/roomList';
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
import { getStateEvent } from '../../utils/room';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
filterGroupsByMsgType,
|
||||||
|
filterGroupsByPinned,
|
||||||
|
MessageSearchParams,
|
||||||
|
MsgTypeFilter,
|
||||||
|
ResultGroup,
|
||||||
|
useMessageSearch,
|
||||||
|
} from './useMessageSearch';
|
||||||
import { useLocalMessageSearch } from './useLocalMessageSearch';
|
import { useLocalMessageSearch } from './useLocalMessageSearch';
|
||||||
|
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
|
||||||
import { SearchResultGroup } from './SearchResultGroup';
|
import { SearchResultGroup } from './SearchResultGroup';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
import { SearchFilters } from './SearchFilters';
|
import { SearchFilters } from './SearchFilters';
|
||||||
@@ -101,7 +124,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
|
|||||||
gap="200"
|
gap="200"
|
||||||
style={{
|
style={{
|
||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
background: 'var(--bg-surface-variant)',
|
background: color.SurfaceVariant.Container,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -167,6 +190,15 @@ export function MessageSearch({
|
|||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>;
|
const searchInputRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>;
|
||||||
const scrollTopAnchorRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
const scrollTopAnchorRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||||
|
|
||||||
|
// Client-side msgtype post-filter. Kept local — the Matrix search API cannot
|
||||||
|
// filter by msgtype server-side, so the server request is unaffected.
|
||||||
|
const [msgTypeFilters, setMsgTypeFilters] = useState<MsgTypeFilter[]>([]);
|
||||||
|
// Client-side "pinned only" post-filter. Narrows displayed results to events
|
||||||
|
// currently pinned in their room (`m.room.pinned_events`). Server-unaffected.
|
||||||
|
const [pinnedOnly, setPinnedOnly] = useState(false);
|
||||||
|
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
|
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
@@ -257,7 +289,45 @@ export function MessageSearch({
|
|||||||
getNextPageParam: (lastPage) => lastPage.nextToken,
|
getNextPageParam: (lastPage) => lastPage.nextToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = useMemo(() => data?.pages.flatMap((result) => result.groups) ?? [], [data]);
|
// Shared client-side post-filter (msgtype + pinned) applied to BOTH the
|
||||||
|
// server results and the local/encrypted-cache results, so the filter chips
|
||||||
|
// narrow the whole UI consistently rather than only the server section.
|
||||||
|
const applyResultFilters = useCallback(
|
||||||
|
(allGroups: ResultGroup[]): ResultGroup[] => {
|
||||||
|
const byMsgType = filterGroupsByMsgType(allGroups, msgTypeFilters);
|
||||||
|
if (!pinnedOnly) return byMsgType;
|
||||||
|
// Build a per-room pinned-event lookup. Heavy Matrix reads stay here
|
||||||
|
// (where `mx` is available); the pure helper only consumes the predicate.
|
||||||
|
const pinnedByRoom = new Map<string, Set<string>>();
|
||||||
|
const isPinned = (roomId: string, eventId: string): boolean => {
|
||||||
|
let pinned = pinnedByRoom.get(roomId);
|
||||||
|
if (!pinned) {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
const content = room
|
||||||
|
? getStateEvent(
|
||||||
|
room,
|
||||||
|
StateEvent.RoomPinnedEvents,
|
||||||
|
)?.getContent<RoomPinnedEventsEventContent>()
|
||||||
|
: undefined;
|
||||||
|
pinned = new Set(content?.pinned ?? []);
|
||||||
|
pinnedByRoom.set(roomId, pinned);
|
||||||
|
}
|
||||||
|
return pinned.has(eventId);
|
||||||
|
};
|
||||||
|
return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned);
|
||||||
|
},
|
||||||
|
[msgTypeFilters, pinnedOnly, mx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const allGroups = data?.pages.flatMap((result) => result.groups) ?? [];
|
||||||
|
return applyResultFilters(allGroups);
|
||||||
|
}, [data, applyResultFilters]);
|
||||||
|
|
||||||
|
const localGroups = useMemo(
|
||||||
|
() => (localResult ? applyResultFilters(localResult.groups) : []),
|
||||||
|
[localResult, applyResultFilters],
|
||||||
|
);
|
||||||
const highlights = useMemo(() => {
|
const highlights = useMemo(() => {
|
||||||
const mixed = data?.pages.flatMap((result) => result.highlights);
|
const mixed = data?.pages.flatMap((result) => result.highlights);
|
||||||
return Array.from(new Set(mixed));
|
return Array.from(new Set(mixed));
|
||||||
@@ -278,7 +348,29 @@ export function MessageSearch({
|
|||||||
newParams.append('term', term);
|
newParams.append('term', term);
|
||||||
return newParams;
|
return newParams;
|
||||||
});
|
});
|
||||||
|
setRecentSearches((prev) => addRecentSearch(prev, term));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRecentSearch = (term: string) => {
|
||||||
|
if (searchInputRef.current) {
|
||||||
|
searchInputRef.current.value = term;
|
||||||
|
}
|
||||||
|
handleSearch(term);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleMsgTypeFilter = useCallback((msgType: MsgTypeFilter) => {
|
||||||
|
setMsgTypeFilters((prev) =>
|
||||||
|
prev.includes(msgType) ? prev.filter((t) => t !== msgType) : [...prev, msgType],
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTogglePinnedOnly = useCallback(() => {
|
||||||
|
setPinnedOnly((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearRecentSearches = useCallback(() => {
|
||||||
|
setRecentSearches([]);
|
||||||
|
}, [setRecentSearches]);
|
||||||
const handleSearchClear = () => {
|
const handleSearchClear = () => {
|
||||||
if (searchInputRef.current) {
|
if (searchInputRef.current) {
|
||||||
searchInputRef.current.value = '';
|
searchInputRef.current.value = '';
|
||||||
@@ -407,6 +499,9 @@ export function MessageSearch({
|
|||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onReset={handleSearchClear}
|
onReset={handleSearchClear}
|
||||||
onSenderAdd={handleSenderAdd}
|
onSenderAdd={handleSenderAdd}
|
||||||
|
recentSearches={recentSearches}
|
||||||
|
onRecentSearch={handleRecentSearch}
|
||||||
|
onClearRecentSearches={handleClearRecentSearches}
|
||||||
/>
|
/>
|
||||||
<SearchFilters
|
<SearchFilters
|
||||||
defaultRoomsFilterName={defaultRoomsFilterName}
|
defaultRoomsFilterName={defaultRoomsFilterName}
|
||||||
@@ -425,6 +520,10 @@ export function MessageSearch({
|
|||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
containsUrl={msgSearchParams.containsUrl}
|
containsUrl={msgSearchParams.containsUrl}
|
||||||
onContainsUrlChange={handleContainsUrlChange}
|
onContainsUrlChange={handleContainsUrlChange}
|
||||||
|
msgTypeFilters={msgTypeFilters}
|
||||||
|
onToggleMsgTypeFilter={handleToggleMsgTypeFilter}
|
||||||
|
pinnedOnly={pinnedOnly}
|
||||||
|
onTogglePinnedOnly={handleTogglePinnedOnly}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -550,7 +649,7 @@ export function MessageSearch({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{localResult &&
|
{localResult &&
|
||||||
(senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && (
|
(senderOnlyMode ? localGroups.length > 0 : localResult.encryptedRoomsCount > 0) && (
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
@@ -565,15 +664,15 @@ export function MessageSearch({
|
|||||||
<Text size="T300" priority="300">
|
<Text size="T300" priority="300">
|
||||||
{senderOnlyMode
|
{senderOnlyMode
|
||||||
? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.`
|
? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.`
|
||||||
: localResult.groups.length > 0
|
: localGroups.length > 0
|
||||||
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
|
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
|
||||||
: `No matches in your local cache. Load messages below to search further back.`}
|
: `No matches in your local cache. Load messages below to search further back.`}
|
||||||
</Text>
|
</Text>
|
||||||
<Line size="300" variant="Surface" />
|
<Line size="300" variant="Surface" />
|
||||||
</Box>
|
</Box>
|
||||||
{localResult.groups.length > 0 && (
|
{localGroups.length > 0 && (
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
{localResult.groups.map((group) => {
|
{localGroups.map((group) => {
|
||||||
const groupRoom = mx.getRoom(group.roomId);
|
const groupRoom = mx.getRoom(group.roomId);
|
||||||
if (!groupRoom) return null;
|
if (!groupRoom) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Badge,
|
Badge,
|
||||||
RectCords,
|
RectCords,
|
||||||
|
IconSrc,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { SearchOrderBy } from 'matrix-js-sdk';
|
import { SearchOrderBy } from 'matrix-js-sdk';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
@@ -41,6 +42,13 @@ import {
|
|||||||
import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
||||||
import { VirtualTile } from '../../components/virtualizer';
|
import { VirtualTile } from '../../components/virtualizer';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { MsgTypeFilter } from './useMessageSearch';
|
||||||
|
|
||||||
|
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
|
||||||
|
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
|
||||||
|
{ msgType: 'm.file', label: 'Files', icon: Icons.File },
|
||||||
|
{ msgType: 'm.video', label: 'Video', icon: Icons.VideoCamera },
|
||||||
|
];
|
||||||
|
|
||||||
type OrderButtonProps = {
|
type OrderButtonProps = {
|
||||||
order?: string;
|
order?: string;
|
||||||
@@ -674,6 +682,10 @@ type SearchFiltersProps = {
|
|||||||
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
|
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
|
||||||
containsUrl?: boolean;
|
containsUrl?: boolean;
|
||||||
onContainsUrlChange: (value?: boolean) => void;
|
onContainsUrlChange: (value?: boolean) => void;
|
||||||
|
msgTypeFilters: MsgTypeFilter[];
|
||||||
|
onToggleMsgTypeFilter: (msgType: MsgTypeFilter) => void;
|
||||||
|
pinnedOnly: boolean;
|
||||||
|
onTogglePinnedOnly: () => void;
|
||||||
};
|
};
|
||||||
export function SearchFilters({
|
export function SearchFilters({
|
||||||
defaultRoomsFilterName,
|
defaultRoomsFilterName,
|
||||||
@@ -692,6 +704,10 @@ export function SearchFilters({
|
|||||||
onDateRangeChange,
|
onDateRangeChange,
|
||||||
containsUrl,
|
containsUrl,
|
||||||
onContainsUrlChange,
|
onContainsUrlChange,
|
||||||
|
msgTypeFilters,
|
||||||
|
onToggleMsgTypeFilter,
|
||||||
|
pinnedOnly,
|
||||||
|
onTogglePinnedOnly,
|
||||||
}: SearchFiltersProps) {
|
}: SearchFiltersProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
@@ -795,6 +811,56 @@ export function SearchFilters({
|
|||||||
>
|
>
|
||||||
<Text size="T200">Has link</Text>
|
<Text size="T200">Has link</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
|
{MSG_TYPE_FILTER_OPTIONS.map(({ msgType, label, icon }) => {
|
||||||
|
const active = msgTypeFilters.includes(msgType);
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={msgType}
|
||||||
|
variant={active ? 'Success' : 'SurfaceVariant'}
|
||||||
|
outlined={active}
|
||||||
|
radii="Pill"
|
||||||
|
aria-pressed={active}
|
||||||
|
before={<Icon size="100" src={icon} />}
|
||||||
|
after={
|
||||||
|
active ? (
|
||||||
|
<Icon
|
||||||
|
size="50"
|
||||||
|
src={Icons.Cross}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleMsgTypeFilter(msgType);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={() => onToggleMsgTypeFilter(msgType)}
|
||||||
|
>
|
||||||
|
<Text size="T200">{label}</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Chip
|
||||||
|
variant={pinnedOnly ? 'Success' : 'SurfaceVariant'}
|
||||||
|
outlined={pinnedOnly}
|
||||||
|
radii="Pill"
|
||||||
|
aria-pressed={pinnedOnly}
|
||||||
|
before={<Icon size="100" src={Icons.Pin} />}
|
||||||
|
after={
|
||||||
|
pinnedOnly ? (
|
||||||
|
<Icon
|
||||||
|
size="50"
|
||||||
|
src={Icons.Cross}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTogglePinnedOnly();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={onTogglePinnedOnly}
|
||||||
|
>
|
||||||
|
<Text size="T200">Pinned</Text>
|
||||||
|
</Chip>
|
||||||
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
|
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
|
||||||
<OrderButton order={order} onChange={onOrderChange} />
|
<OrderButton order={order} onChange={onOrderChange} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ type SearchInputProps = {
|
|||||||
onSearch: (term: string) => void;
|
onSearch: (term: string) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onSenderAdd?: (userId: string) => void;
|
onSenderAdd?: (userId: string) => void;
|
||||||
|
recentSearches?: string[];
|
||||||
|
onRecentSearch?: (term: string) => void;
|
||||||
|
onClearRecentSearches?: () => void;
|
||||||
};
|
};
|
||||||
export function SearchInput({
|
export function SearchInput({
|
||||||
active,
|
active,
|
||||||
@@ -51,6 +54,9 @@ export function SearchInput({
|
|||||||
onSearch,
|
onSearch,
|
||||||
onReset,
|
onReset,
|
||||||
onSenderAdd,
|
onSenderAdd,
|
||||||
|
recentSearches,
|
||||||
|
onRecentSearch,
|
||||||
|
onClearRecentSearches,
|
||||||
}: SearchInputProps) {
|
}: SearchInputProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
@@ -58,6 +64,8 @@ export function SearchInput({
|
|||||||
|
|
||||||
const [fromQuery, setFromQuery] = useState('');
|
const [fromQuery, setFromQuery] = useState('');
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [inputEmpty, setInputEmpty] = useState(true);
|
||||||
|
|
||||||
// Collect users from room member lists, scored for relevance.
|
// Collect users from room member lists, scored for relevance.
|
||||||
// Score: same homeserver → +1000, each shared room → +1.
|
// Score: same homeserver → +1000, each shared room → +1.
|
||||||
@@ -121,6 +129,7 @@ export function SearchInput({
|
|||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
const value = evt.target.value;
|
const value = evt.target.value;
|
||||||
|
setInputEmpty(value.trim() === '');
|
||||||
const match = FROM_TYPING_REGEX.exec(value);
|
const match = FROM_TYPING_REGEX.exec(value);
|
||||||
if (match) {
|
if (match) {
|
||||||
setFromQuery(match[1]);
|
setFromQuery(match[1]);
|
||||||
@@ -130,9 +139,24 @@ export function SearchInput({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setFocused(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Close autocomplete when input loses focus — delay so item clicks fire first
|
// Close autocomplete when input loses focus — delay so item clicks fire first
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
setTimeout(closeAutocomplete, 150);
|
setTimeout(() => {
|
||||||
|
closeAutocomplete();
|
||||||
|
setFocused(false);
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecentClick = (term: string) => {
|
||||||
|
if (searchInputRef.current) {
|
||||||
|
searchInputRef.current.value = term;
|
||||||
|
}
|
||||||
|
setInputEmpty(false);
|
||||||
|
onRecentSearch?.(term);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
@@ -181,6 +205,7 @@ export function SearchInput({
|
|||||||
placeholder="Search messages or type from:@user to filter by sender"
|
placeholder="Search messages or type from:@user to filter by sender"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
before={
|
before={
|
||||||
active && loading ? (
|
active && loading ? (
|
||||||
@@ -267,6 +292,52 @@ export function SearchInput({
|
|||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{focused &&
|
||||||
|
inputEmpty &&
|
||||||
|
suggestedUsers.length === 0 &&
|
||||||
|
recentSearches &&
|
||||||
|
recentSearches.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 999,
|
||||||
|
marginTop: config.space.S100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu variant="Surface" style={{ width: '100%' }}>
|
||||||
|
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
|
||||||
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
|
<Text size="L400">Recent searches</Text>
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
|
||||||
|
onClick={() => onClearRecentSearches?.()}
|
||||||
|
>
|
||||||
|
<Text size="T200">Clear</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
<Box gap="200" wrap="Wrap">
|
||||||
|
{recentSearches.map((term) => (
|
||||||
|
<Chip
|
||||||
|
key={term}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
before={<Icon size="50" src={Icons.RecentClock} />}
|
||||||
|
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
|
||||||
|
onClick={() => handleRecentClick(term)}
|
||||||
|
>
|
||||||
|
<Text size="T200">{term}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,51 @@ export type SearchResult = {
|
|||||||
groups: ResultGroup[];
|
groups: ResultGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Client-side msgtype post-filter. The Matrix search API cannot filter by
|
||||||
|
// msgtype server-side, so this is applied to already-returned results.
|
||||||
|
export type MsgTypeFilter = 'm.image' | 'm.file' | 'm.video';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter result groups to items whose event msgtype is in `msgTypes` (OR/union).
|
||||||
|
* Empty/absent filter returns groups unchanged. Now-empty groups are dropped.
|
||||||
|
*/
|
||||||
|
export const filterGroupsByMsgType = (
|
||||||
|
groups: ResultGroup[],
|
||||||
|
msgTypes: MsgTypeFilter[],
|
||||||
|
): ResultGroup[] => {
|
||||||
|
if (msgTypes.length === 0) return groups;
|
||||||
|
const allowed = new Set<string>(msgTypes);
|
||||||
|
return groups
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter((item) => {
|
||||||
|
const msgtype = item.event.content?.msgtype;
|
||||||
|
return typeof msgtype === 'string' && allowed.has(msgtype);
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.items.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter result groups to items whose event is currently pinned in its room.
|
||||||
|
* `isPinned(roomId, eventId)` returns whether the event is in the room's
|
||||||
|
* `m.room.pinned_events` set. When `enabled` is false, groups are returned
|
||||||
|
* unchanged. Now-empty groups are dropped.
|
||||||
|
*/
|
||||||
|
export const filterGroupsByPinned = (
|
||||||
|
groups: ResultGroup[],
|
||||||
|
enabled: boolean,
|
||||||
|
isPinned: (roomId: string, eventId: string) => boolean,
|
||||||
|
): ResultGroup[] => {
|
||||||
|
if (!enabled) return groups;
|
||||||
|
return groups
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter((item) => isPinned(group.roomId, item.event.event_id)),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.items.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||||
const groups: ResultGroup[] = [];
|
const groups: ResultGroup[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1106,7 +1106,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--tc-danger-normal)',
|
color: color.Critical.Main,
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
@@ -1119,7 +1119,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--tc-danger-normal)',
|
color: color.Critical.Main,
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
|||||||
@@ -255,6 +255,56 @@ export function About({ requestClose }: AboutProps) {
|
|||||||
paddingLeft: config.space.S400,
|
paddingLeft: config.space.S400,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<li>
|
||||||
|
<Text size="T300">
|
||||||
|
The Lotus Chat logo is a derivative work based on the original{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/cinnyapp/cinny"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Cinny
|
||||||
|
</a>{' '}
|
||||||
|
logo by{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/ajbura"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Ajay Bura
|
||||||
|
</a>{' '}
|
||||||
|
and contributors, used under the terms of{' '}
|
||||||
|
<a
|
||||||
|
href="https://creativecommons.org/licenses/by/4.0/"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
CC-BY 4.0
|
||||||
|
</a>
|
||||||
|
. The modified logo is © Lotus Guild, also under CC-BY 4.0.
|
||||||
|
</Text>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Text size="T300">
|
||||||
|
Lotus Chat is a fork of{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/cinnyapp/cinny"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Cinny
|
||||||
|
</a>{' '}
|
||||||
|
by Ajay Bura and contributors, used under the terms of{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.gnu.org/licenses/agpl-3.0.html"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
AGPL-3.0
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Text size="T300">
|
<Text size="T300">
|
||||||
The{' '}
|
The{' '}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { Method } from 'matrix-js-sdk';
|
import { Method } from 'matrix-js-sdk';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
@@ -482,9 +483,9 @@ function ProfileStatus() {
|
|||||||
opacity: statusMsg.length >= 56 ? 1 : 0.45,
|
opacity: statusMsg.length >= 56 ? 1 : 0.45,
|
||||||
color:
|
color:
|
||||||
statusMsg.length >= 64
|
statusMsg.length >= 64
|
||||||
? 'var(--tc-critical-normal)'
|
? color.Critical.Main
|
||||||
: statusMsg.length >= 56
|
: statusMsg.length >= 56
|
||||||
? 'var(--tc-warning-normal)'
|
? color.Warning.Main
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -536,7 +537,7 @@ function ProfileStatus() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
{saveState.status === AsyncStatus.Error && (
|
{saveState.status === AsyncStatus.Error && (
|
||||||
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
Failed to save status — server may be rate limiting. Try again.
|
Failed to save status — server may be rate limiting. Try again.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -544,35 +545,12 @@ function ProfileStatus() {
|
|||||||
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
Auto-clear after:
|
Auto-clear after:
|
||||||
</Text>
|
</Text>
|
||||||
<select
|
<SettingsSelect
|
||||||
value={clearAfter}
|
value={clearAfter}
|
||||||
onChange={(e) => setClearAfter(e.target.value)}
|
options={CLEAR_AFTER_OPTIONS}
|
||||||
|
onChange={setClearAfter}
|
||||||
aria-label="Auto-clear status after"
|
aria-label="Auto-clear status after"
|
||||||
style={{
|
/>
|
||||||
background: color.SurfaceVariant.Container,
|
|
||||||
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
|
||||||
borderRadius: config.radii.R300,
|
|
||||||
color: color.SurfaceVariant.OnContainer,
|
|
||||||
colorScheme: 'dark',
|
|
||||||
fontSize: '0.82rem',
|
|
||||||
padding: `${config.space.S100} ${config.space.S200}`,
|
|
||||||
cursor: 'pointer',
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{CLEAR_AFTER_OPTIONS.map((opt) => (
|
|
||||||
<option
|
|
||||||
key={opt.value}
|
|
||||||
value={opt.value}
|
|
||||||
style={{
|
|
||||||
background: color.SurfaceVariant.Container,
|
|
||||||
color: color.SurfaceVariant.OnContainer,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Box>
|
</Box>
|
||||||
{(presence?.status || statusMsg) && (
|
{(presence?.status || statusMsg) && (
|
||||||
<Button
|
<Button
|
||||||
@@ -730,7 +708,7 @@ function ProfilePronouns() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
{saveState.status === AsyncStatus.Error && (
|
{saveState.status === AsyncStatus.Error && (
|
||||||
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
Failed to save pronouns. Try again.
|
Failed to save pronouns. Try again.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -873,7 +851,7 @@ function ProfileTimezone() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
{saveState.status === AsyncStatus.Error && (
|
{saveState.status === AsyncStatus.Error && (
|
||||||
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
Failed to save timezone. Try again.
|
Failed to save timezone. Try again.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ function DecorationPreviewCell({
|
|||||||
width: CELL_SIZE,
|
width: CELL_SIZE,
|
||||||
height: CELL_SIZE,
|
height: CELL_SIZE,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
border: `2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}`,
|
border: `2px solid ${selected ? color.Primary.Main : 'transparent'}`,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: 'var(--bg-surface-variant)',
|
background: color.SurfaceVariant.Container,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
boxShadow: selected ? '0 0 0 1px var(--accent-cyan)' : 'none',
|
boxShadow: selected ? `0 0 0 1px ${color.Primary.Main}` : 'none',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
@@ -142,7 +142,7 @@ export function ProfileDecoration() {
|
|||||||
height: CELL_SIZE,
|
height: CELL_SIZE,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: 'var(--bg-surface-variant)',
|
background: color.SurfaceVariant.Container,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Button, Text } from 'folds';
|
import { Box, Button, color, config, Text } from 'folds';
|
||||||
import { DenoiseModelId } from '../../../state/settings';
|
import { DenoiseModelId } from '../../../state/settings';
|
||||||
import { DENOISE_MODELS } from '../../../utils/lotusDenoiseUtils';
|
import { DENOISE_MODELS } from '../../../utils/lotusDenoiseUtils';
|
||||||
import {
|
import {
|
||||||
@@ -49,8 +49,8 @@ function DbMeter({ label, db, threshold }: { label: string; db: number; threshol
|
|||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: '12px',
|
height: '12px',
|
||||||
background: 'var(--bg-card)',
|
background: color.Surface.Container,
|
||||||
border: '1px solid var(--border-color)',
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
@@ -62,7 +62,7 @@ function DbMeter({ label, db, threshold }: { label: string; db: number; threshol
|
|||||||
left: 0,
|
left: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: `${pct}%`,
|
width: `${pct}%`,
|
||||||
background: 'var(--accent-green)',
|
background: color.Success.Main,
|
||||||
transition: 'width 0.05s linear',
|
transition: 'width 0.05s linear',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -74,7 +74,7 @@ function DbMeter({ label, db, threshold }: { label: string; db: number; threshol
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: `${markerPct}%`,
|
left: `${markerPct}%`,
|
||||||
width: '2px',
|
width: '2px',
|
||||||
background: 'var(--accent-orange)',
|
background: color.Primary.Main,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
MessageLayout,
|
MessageLayout,
|
||||||
MessageSpacing,
|
MessageSpacing,
|
||||||
NoiseSuppressionMode,
|
NoiseSuppressionMode,
|
||||||
|
RingtoneId,
|
||||||
Settings,
|
Settings,
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
} from '../../../state/settings';
|
} from '../../../state/settings';
|
||||||
@@ -78,7 +79,9 @@ import { SequenceCardStyle } from '../styles.css';
|
|||||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||||
|
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||||
import { DenoiseTester } from './DenoiseTester';
|
import { DenoiseTester } from './DenoiseTester';
|
||||||
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||||
|
|
||||||
type ThemeSelectorProps = {
|
type ThemeSelectorProps = {
|
||||||
themeNames: Record<string, string>;
|
themeNames: Record<string, string>;
|
||||||
@@ -167,83 +170,6 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsSelectOption<T extends string> = { value: T; label: string; disabled?: boolean };
|
|
||||||
|
|
||||||
function SettingsSelect<T extends string>({
|
|
||||||
value,
|
|
||||||
options,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: T;
|
|
||||||
options: SettingsSelectOption<T>[];
|
|
||||||
onChange: (v: T) => void;
|
|
||||||
}) {
|
|
||||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
|
||||||
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
|
|
||||||
|
|
||||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
||||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (v: T) => {
|
|
||||||
onChange(v);
|
|
||||||
setMenuCords(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Secondary"
|
|
||||||
outlined
|
|
||||||
fill="Soft"
|
|
||||||
radii="300"
|
|
||||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
|
||||||
onClick={handleMenu}
|
|
||||||
>
|
|
||||||
<Text size="T300">{selectedLabel}</Text>
|
|
||||||
</Button>
|
|
||||||
<PopOut
|
|
||||||
anchor={menuCords}
|
|
||||||
offset={5}
|
|
||||||
position="Bottom"
|
|
||||||
align="End"
|
|
||||||
content={
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setMenuCords(undefined),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
isKeyForward: (evt: KeyboardEvent) =>
|
|
||||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
|
||||||
isKeyBackward: (evt: KeyboardEvent) =>
|
|
||||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu>
|
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<MenuItem
|
|
||||||
key={opt.value}
|
|
||||||
size="300"
|
|
||||||
variant={opt.value === value ? 'Primary' : 'Surface'}
|
|
||||||
radii="300"
|
|
||||||
disabled={opt.disabled}
|
|
||||||
onClick={() => !opt.disabled && handleSelect(opt.value)}
|
|
||||||
>
|
|
||||||
<Text size="T300">{opt.label}</Text>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SystemThemePreferences() {
|
function SystemThemePreferences() {
|
||||||
const themeKind = useSystemThemeKind();
|
const themeKind = useSystemThemeKind();
|
||||||
const themeNames = useThemeNames();
|
const themeNames = useThemeNames();
|
||||||
@@ -430,6 +356,7 @@ function Appearance() {
|
|||||||
settingsAtom,
|
settingsAtom,
|
||||||
'mentionHighlightColor',
|
'mentionHighlightColor',
|
||||||
);
|
);
|
||||||
|
const [customAccentColor, setCustomAccentColor] = useSetting(settingsAtom, 'customAccentColor');
|
||||||
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
|
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
|
||||||
const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting(
|
const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
@@ -682,6 +609,55 @@ function Appearance() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Custom Accent Color"
|
||||||
|
description={
|
||||||
|
lotusTerminal
|
||||||
|
? 'Only applies to non-TDS themes. Disable Lotus Terminal Mode to use a custom accent.'
|
||||||
|
: 'Recolors the app accent (buttons, active states, links, selected states). Leave empty to use the theme default.'
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
<HexColorPickerPopOut
|
||||||
|
picker={
|
||||||
|
<HexColorPicker
|
||||||
|
color={customAccentColor || color.Primary.Main}
|
||||||
|
onChange={setCustomAccentColor}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onRemove={customAccentColor ? () => setCustomAccentColor('') : undefined}
|
||||||
|
>
|
||||||
|
{(openPicker, opened) => (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={opened}
|
||||||
|
onClick={openPicker}
|
||||||
|
disabled={lotusTerminal}
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
before={
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: toRem(16),
|
||||||
|
height: toRem(16),
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
background: customAccentColor || color.Primary.Main,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
display: 'inline-block',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B300">{customAccentColor ? 'Change' : 'Pick'}</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</HexColorPickerPopOut>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1242,12 +1218,18 @@ function Calls() {
|
|||||||
'callJoinLeaveSound',
|
'callJoinLeaveSound',
|
||||||
);
|
);
|
||||||
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
|
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
|
||||||
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
||||||
setCallJoinLeaveSound(value);
|
setCallJoinLeaveSound(value);
|
||||||
if (value !== 'off') playCallJoinSound(value);
|
if (value !== 'off') playCallJoinSound(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRingtoneChange = (value: RingtoneId) => {
|
||||||
|
setRingtoneId(value);
|
||||||
|
previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
|
};
|
||||||
|
|
||||||
const pttBind = useKeyBind(setPttKey);
|
const pttBind = useKeyBind(setPttKey);
|
||||||
const deafenBind = useKeyBind(setDeafenKey);
|
const deafenBind = useKeyBind(setDeafenKey);
|
||||||
|
|
||||||
@@ -1314,8 +1296,8 @@ function Calls() {
|
|||||||
style={{
|
style={{
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
marginTop: '8px',
|
marginTop: '8px',
|
||||||
borderTop: '1px solid var(--border-color)',
|
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
background: 'var(--bg-card)',
|
background: color.Surface.Container,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Model selection ───────────────────────────────────────── */}
|
{/* ── Model selection ───────────────────────────────────────── */}
|
||||||
@@ -1339,8 +1321,8 @@ function Calls() {
|
|||||||
style={{
|
style={{
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '1px solid var(--border-color)',
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
background: 'var(--bg-input)',
|
background: color.SurfaceVariant.Container,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="T300">{selectedDenoiseModel.name}</Text>
|
<Text size="T300">{selectedDenoiseModel.name}</Text>
|
||||||
@@ -1378,7 +1360,7 @@ function Calls() {
|
|||||||
direction="Row"
|
direction="Row"
|
||||||
gap="100"
|
gap="100"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid var(--border-color)',
|
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
paddingBottom: '4px',
|
paddingBottom: '4px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1431,7 +1413,10 @@ function Calls() {
|
|||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="300"
|
gap="300"
|
||||||
style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }}
|
style={{
|
||||||
|
paddingTop: '12px',
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text size="L400">Enhancements</Text>
|
<Text size="L400">Enhancements</Text>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
@@ -1467,7 +1452,7 @@ function Calls() {
|
|||||||
step="1"
|
step="1"
|
||||||
value={callDenoiseGateThreshold}
|
value={callDenoiseGateThreshold}
|
||||||
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
|
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
|
||||||
style={{ width: '100%', accentColor: 'var(--accent-orange)' }}
|
style={{ width: '100%', accentColor: color.Primary.Main }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -1477,7 +1462,10 @@ function Calls() {
|
|||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="200"
|
gap="200"
|
||||||
style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }}
|
style={{
|
||||||
|
paddingTop: '12px',
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text size="L400">Test & calibrate</Text>
|
<Text size="L400">Test & calibrate</Text>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
@@ -1573,6 +1561,19 @@ function Calls() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Ringtone"
|
||||||
|
description="Sound played for incoming calls. Selecting an option plays a preview."
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={ringtoneId}
|
||||||
|
onChange={(v) => handleRingtoneChange(v as RingtoneId)}
|
||||||
|
options={RINGTONE_OPTIONS}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Ringtone Volume"
|
title="Ringtone Volume"
|
||||||
@@ -1587,7 +1588,7 @@ function Calls() {
|
|||||||
value={ringtoneVolume}
|
value={ringtoneVolume}
|
||||||
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
||||||
aria-label="Ringtone volume"
|
aria-label="Ringtone volume"
|
||||||
style={{ flex: 1, accentColor: 'var(--accent-orange)' }}
|
style={{ flex: 1, accentColor: color.Primary.Main }}
|
||||||
/>
|
/>
|
||||||
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
||||||
{ringtoneVolume}%
|
{ringtoneVolume}%
|
||||||
@@ -1646,7 +1647,14 @@ function SeasonalBgGrid({
|
|||||||
onChange: (v: Settings['seasonalThemeOverride']) => void;
|
onChange: (v: Settings['seasonalThemeOverride']) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box wrap="Wrap" gap="200">
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(auto-fill, minmax(${toRem(76)}, 1fr))`,
|
||||||
|
gap: config.space.S200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{SEASONAL_OPTIONS.map((opt) => {
|
{SEASONAL_OPTIONS.map((opt) => {
|
||||||
const selected = value === opt.value;
|
const selected = value === opt.value;
|
||||||
const isSpecial = opt.value === 'auto' || opt.value === 'off';
|
const isSpecial = opt.value === 'auto' || opt.value === 'off';
|
||||||
@@ -1706,7 +1714,14 @@ function ChatBgGrid() {
|
|||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box wrap="Wrap" gap="200">
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(auto-fill, minmax(${toRem(76)}, 1fr))`,
|
||||||
|
gap: config.space.S200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{BG_OPTIONS.map((opt) => (
|
{BG_OPTIONS.map((opt) => (
|
||||||
<Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}>
|
<Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
|
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
|
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
|
||||||
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
|
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
|
||||||
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||||
import { useAccountData } from '../../../hooks/useAccountData';
|
import { useAccountData } from '../../../hooks/useAccountData';
|
||||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
@@ -193,10 +194,6 @@ function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
|
|||||||
setRuleId(evt.currentTarget.value);
|
setRuleId(evt.currentTarget.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModeChange: ChangeEventHandler<HTMLSelectElement> = (evt) => {
|
|
||||||
setMode(evt.target.value as NotificationMode);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
|
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
@@ -217,24 +214,12 @@ function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<select
|
<SettingsSelect
|
||||||
value={mode}
|
value={mode}
|
||||||
onChange={handleModeChange}
|
options={ADD_MODES.map((m) => ({ value: m, label: MODE_LABELS[m] }))}
|
||||||
style={{
|
onChange={setMode}
|
||||||
background: 'transparent',
|
aria-label="Notification mode"
|
||||||
border: '1px solid currentColor',
|
/>
|
||||||
borderRadius: config.radii.R300,
|
|
||||||
padding: `${config.space.S100} ${config.space.S200}`,
|
|
||||||
color: 'inherit',
|
|
||||||
fontSize: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ADD_MODES.map((m) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{MODE_LABELS[m]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
size="400"
|
size="400"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useEffect, useRef, CSSProperties } from 'react';
|
import React, { useEffect, useRef, CSSProperties } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { color, config, Icon, IconButton, Icons } from 'folds';
|
||||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
|
||||||
// Inject the keyframe animation once
|
// Inject the keyframe animation once
|
||||||
const STYLE_ID = 'lotus-toast-keyframes';
|
const STYLE_ID = 'lotus-toast-keyframes';
|
||||||
@@ -29,16 +32,21 @@ type ToastCardProps = {
|
|||||||
|
|
||||||
function ToastCard({ toast }: ToastCardProps) {
|
function ToastCard({ toast }: ToastCardProps) {
|
||||||
const dismiss = useSetAtom(dismissToastAtom);
|
const dismiss = useSetAtom(dismissToastAtom);
|
||||||
|
// Lotus Terminal (TDS) gets its bespoke glow/accents; every other theme uses
|
||||||
|
// folds tokens so toasts render correctly on stock Cinny themes (the --lt-*
|
||||||
|
// vars only exist while Terminal mode is active).
|
||||||
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (toast.sticky) return;
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
dismiss(toast.id);
|
dismiss(toast.id);
|
||||||
}, 4000);
|
}, 4000);
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
||||||
};
|
};
|
||||||
}, [dismiss, toast.id]);
|
}, [dismiss, toast.id, toast.sticky]);
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
if (toast.onClick) {
|
if (toast.onClick) {
|
||||||
@@ -55,15 +63,29 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
dismiss(toast.id);
|
dismiss(toast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const accent = toast.sticky ? color.Primary.Main : color.Surface.OnContainer;
|
||||||
|
|
||||||
const cardStyle: CSSProperties = {
|
const cardStyle: CSSProperties = {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
background: 'var(--lt-bg-card)',
|
background: lotusTerminal ? 'var(--lt-bg-card)' : color.Surface.Container,
|
||||||
border: '1px solid var(--lt-border-color)',
|
border: `${config.borderWidth.B300} solid ${
|
||||||
borderRadius: '12px',
|
lotusTerminal
|
||||||
padding: '12px 14px',
|
? toast.sticky
|
||||||
|
? 'var(--lt-accent-cyan-border)'
|
||||||
|
: 'var(--lt-border-color)'
|
||||||
|
: toast.sticky
|
||||||
|
? color.Primary.Main
|
||||||
|
: color.Surface.ContainerLine
|
||||||
|
}`,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
padding: `${config.space.S300} ${config.space.S400}`,
|
||||||
minWidth: '280px',
|
minWidth: '280px',
|
||||||
maxWidth: '340px',
|
maxWidth: '340px',
|
||||||
boxShadow: 'var(--lt-box-glow-orange)',
|
boxShadow: lotusTerminal
|
||||||
|
? toast.sticky
|
||||||
|
? 'var(--lt-box-glow-cyan)'
|
||||||
|
: 'var(--lt-box-glow-orange)'
|
||||||
|
: `0 8px 24px ${color.Other.Shadow}`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
animation: 'lotusToastIn 0.2s ease-out both',
|
animation: 'lotusToastIn 0.2s ease-out both',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
@@ -72,8 +94,8 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
const rowStyle: CSSProperties = {
|
const rowStyle: CSSProperties = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: config.space.S200,
|
||||||
marginRight: '20px',
|
marginRight: config.space.S500,
|
||||||
};
|
};
|
||||||
|
|
||||||
const avatarStyle: CSSProperties = {
|
const avatarStyle: CSSProperties = {
|
||||||
@@ -88,19 +110,25 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
width: '24px',
|
width: '24px',
|
||||||
height: '24px',
|
height: '24px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: 'var(--lt-accent-orange-dim)',
|
background: lotusTerminal ? 'var(--lt-accent-orange-dim)' : color.Primary.Container,
|
||||||
border: '1px solid var(--lt-accent-orange-border)',
|
border: `${config.borderWidth.B300} solid ${
|
||||||
|
lotusTerminal ? 'var(--lt-accent-orange-border)' : color.Primary.ContainerLine
|
||||||
|
}`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: 'var(--lt-accent-orange)',
|
color: lotusTerminal ? 'var(--lt-accent-orange)' : color.Primary.OnContainer,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nameStyle: CSSProperties = {
|
const nameStyle: CSSProperties = {
|
||||||
color: 'var(--lt-accent-orange)',
|
color: lotusTerminal
|
||||||
|
? toast.sticky
|
||||||
|
? 'var(--lt-accent-cyan)'
|
||||||
|
: 'var(--lt-accent-orange)'
|
||||||
|
: accent,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -108,31 +136,18 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
};
|
};
|
||||||
|
|
||||||
const dismissBtnStyle: CSSProperties = {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '8px',
|
|
||||||
right: '10px',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: 'var(--lt-text-secondary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: 1,
|
|
||||||
padding: '2px 4px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
};
|
|
||||||
|
|
||||||
const bodyStyle: CSSProperties = {
|
const bodyStyle: CSSProperties = {
|
||||||
color: 'var(--lt-text-primary)',
|
color: lotusTerminal ? 'var(--lt-text-primary)' : color.Surface.OnContainer,
|
||||||
fontSize: '0.82rem',
|
fontSize: '0.82rem',
|
||||||
margin: '4px 0 2px',
|
margin: '4px 0 2px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
...(toast.sticky
|
||||||
whiteSpace: 'nowrap',
|
? { whiteSpace: 'normal', lineHeight: 1.4 }
|
||||||
|
: { textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const roomNameStyle: CSSProperties = {
|
const roomNameStyle: CSSProperties = {
|
||||||
color: 'var(--lt-text-secondary)',
|
color: lotusTerminal ? 'var(--lt-text-secondary)' : color.SurfaceVariant.OnContainer,
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
@@ -157,14 +172,19 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
}}
|
}}
|
||||||
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`}
|
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`}
|
||||||
>
|
>
|
||||||
<button
|
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
|
||||||
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
style={dismissBtnStyle}
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
aria-label="Dismiss notification"
|
aria-label="Dismiss notification"
|
||||||
>
|
>
|
||||||
×
|
<Icon size="100" src={Icons.Cross} />
|
||||||
</button>
|
</IconButton>
|
||||||
|
</span>
|
||||||
<div style={rowStyle}>
|
<div style={rowStyle}>
|
||||||
{toast.avatarUrl ? (
|
{toast.avatarUrl ? (
|
||||||
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
|
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
|
||||||
@@ -197,7 +217,7 @@ export function LotusToastContainer() {
|
|||||||
zIndex: 10001,
|
zIndex: 10001,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '8px',
|
gap: config.space.S200,
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { toastQueueAtom } from '../state/toast';
|
import { toastQueueAtom } from '../state/toast';
|
||||||
@@ -9,17 +9,25 @@ const SILENCE_RMS_THRESHOLD = 0.008;
|
|||||||
const CHECK_INTERVAL_MS = 500;
|
const CHECK_INTERVAL_MS = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monitors microphone audio while in a call. If the mic stays active but
|
* Monitors microphone audio while in a call. If the mic stays unmuted but
|
||||||
* silent for longer than the configured timeout, the mic is muted and a
|
* silent for longer than the configured timeout, the mic is muted and a toast
|
||||||
* toast is shown. Cleans up its own AudioContext and stream on unmount.
|
* is shown.
|
||||||
|
*
|
||||||
|
* The level-monitoring capture (`getUserMedia`) is opened ONLY while the mic is
|
||||||
|
* unmuted — there is nothing to auto-mute once you are already muted, so
|
||||||
|
* holding the capture would keep the OS recording indicator lit even though the
|
||||||
|
* UI shows you as muted (N95). Muting therefore releases our stream; unmuting
|
||||||
|
* re-acquires it. The AudioContext + stream are also torn down on unmount.
|
||||||
*/
|
*/
|
||||||
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
||||||
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
|
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
|
||||||
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
const { microphone } = useCallControlState(callEmbed?.control);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!callEmbed || !enabled) return;
|
// Only capture while in a call, enabled, AND unmuted (see N95 note above).
|
||||||
|
if (!callEmbed || !enabled || !microphone) return undefined;
|
||||||
|
|
||||||
let stream: MediaStream | undefined;
|
let stream: MediaStream | undefined;
|
||||||
let audioCtx: AudioContext | undefined;
|
let audioCtx: AudioContext | undefined;
|
||||||
@@ -49,12 +57,12 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
|||||||
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
|
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
|
||||||
|
|
||||||
if (rms > SILENCE_RMS_THRESHOLD) {
|
if (rms > SILENCE_RMS_THRESHOLD) {
|
||||||
// Audio detected — reset the silence timer
|
// Audio detected — reset the silence timer.
|
||||||
silenceStart = null;
|
silenceStart = null;
|
||||||
} else if (callEmbed.control.microphone) {
|
} else if (silenceStart === null) {
|
||||||
// Mic is on but silent — start or advance the timer
|
// Mic is unmuted (effect only runs while unmuted) but silent — start the timer.
|
||||||
if (silenceStart === null) silenceStart = Date.now();
|
silenceStart = Date.now();
|
||||||
else if (Date.now() - silenceStart >= timeoutMs) {
|
} else if (Date.now() - silenceStart >= timeoutMs) {
|
||||||
callEmbed.control.setMicrophone(false);
|
callEmbed.control.setMicrophone(false);
|
||||||
setToast({
|
setToast({
|
||||||
id: `afk-mute-${Date.now()}`,
|
id: `afk-mute-${Date.now()}`,
|
||||||
@@ -65,10 +73,6 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
|||||||
});
|
});
|
||||||
silenceStart = null;
|
silenceStart = null;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Mic is already muted — don't count silence
|
|
||||||
silenceStart = null;
|
|
||||||
}
|
|
||||||
}, CHECK_INTERVAL_MS);
|
}, CHECK_INTERVAL_MS);
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -79,5 +83,5 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
|||||||
stream?.getTracks().forEach((t) => t.stop());
|
stream?.getTracks().forEach((t) => t.stop());
|
||||||
audioCtx?.close().catch(() => undefined);
|
audioCtx?.close().catch(() => undefined);
|
||||||
};
|
};
|
||||||
}, [callEmbed, enabled, timeoutMinutes, setToast]);
|
}, [callEmbed, enabled, timeoutMinutes, setToast, microphone]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,50 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
import { useMutationObserver } from './useMutationObserver';
|
|
||||||
import { isUserId } from '../utils/matrix';
|
import { isUserId } from '../utils/matrix';
|
||||||
import { useCallMembers, useCallSession } from './useCall';
|
import { useCallMembers, useCallSession } from './useCall';
|
||||||
import { useCallJoined } from './useCallEmbed';
|
import { useCallJoined } from './useCallEmbed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the set of Matrix user IDs currently speaking in the Element Call
|
||||||
|
* iframe.
|
||||||
|
*
|
||||||
|
* EC renders each participant's video tile with a `[data-video-fit]` wrapper.
|
||||||
|
* When a participant is speaking, EC draws a speaking indicator via the tile's
|
||||||
|
* `::before` pseudo-element `background-image` (anything other than `none`).
|
||||||
|
* The participant's Matrix user ID is exposed on the first descendant carrying
|
||||||
|
* an `aria-label`.
|
||||||
|
*
|
||||||
|
* We watch the whole iframe document so tiles added/removed mid-call are picked
|
||||||
|
* up automatically, and on every relevant mutation we re-scan ALL `[data-video-fit]`
|
||||||
|
* tiles and rebuild the set from the full current DOM state (rather than just the
|
||||||
|
* tiles in the mutation batch).
|
||||||
|
*/
|
||||||
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||||
const [speakers, setSpeakers] = useState(new Set<string>());
|
const [speakers, setSpeakers] = useState(new Set<string>());
|
||||||
const callSession = useCallSession(callEmbed.room);
|
const callSession = useCallSession(callEmbed.room);
|
||||||
const callMembers = useCallMembers(callSession);
|
const callMembers = useCallMembers(callSession);
|
||||||
const joined = useCallJoined(callEmbed);
|
const joined = useCallJoined(callEmbed);
|
||||||
|
|
||||||
const videoContainers = useMemo(() => {
|
useEffect(() => {
|
||||||
if (callMembers && joined) return callEmbed.document?.querySelectorAll('[data-video-fit]');
|
if (!callMembers || !joined) {
|
||||||
|
setSpeakers(new Set<string>());
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [callEmbed, callMembers, joined]);
|
}
|
||||||
|
|
||||||
const mutationObserver = useMutationObserver(
|
const getDoc = (): Document | undefined =>
|
||||||
useCallback(
|
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined;
|
||||||
(mutations) => {
|
|
||||||
|
const syncState = (): void => {
|
||||||
|
const doc = getDoc();
|
||||||
|
if (!doc) {
|
||||||
|
setSpeakers(new Set<string>());
|
||||||
|
return;
|
||||||
|
}
|
||||||
const s = new Set<string>();
|
const s = new Set<string>();
|
||||||
|
// Re-scan every tile on each mutation and build the set from the full
|
||||||
mutations.forEach((mutation) => {
|
// current DOM state, not just the tiles that mutated this batch.
|
||||||
if (mutation.type !== 'attributes') return;
|
const tiles = doc.querySelectorAll<HTMLElement>('[data-video-fit]');
|
||||||
const el = mutation.target as HTMLElement;
|
tiles.forEach((el) => {
|
||||||
|
|
||||||
const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before');
|
const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before');
|
||||||
if (!style) return;
|
if (!style) return;
|
||||||
const tileBackgroundImage = style.getPropertyValue('background-image');
|
const tileBackgroundImage = style.getPropertyValue('background-image');
|
||||||
@@ -36,25 +56,61 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|||||||
s.add(speakerId);
|
s.add(speakerId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setSpeakers(s);
|
setSpeakers(s);
|
||||||
},
|
};
|
||||||
[callEmbed],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
let tileObserver: MutationObserver | undefined;
|
||||||
videoContainers?.forEach((element) => {
|
|
||||||
mutationObserver.observe(element, {
|
const attachObserver = (): void => {
|
||||||
|
const doc = getDoc();
|
||||||
|
if (!doc) return;
|
||||||
|
tileObserver?.disconnect();
|
||||||
|
// Watch the whole document for attribute changes on tiles (which carry
|
||||||
|
// the speaking indicator) and for new tiles being added/removed.
|
||||||
|
tileObserver = new MutationObserver((mutations) => {
|
||||||
|
const relevant = mutations.some(
|
||||||
|
(m) =>
|
||||||
|
m.type === 'attributes' ||
|
||||||
|
(m.type === 'childList' &&
|
||||||
|
(Array.from(m.addedNodes).some(
|
||||||
|
(n) => n instanceof Element && n.querySelector('[data-video-fit]'),
|
||||||
|
) ||
|
||||||
|
Array.from(m.removedNodes).some(
|
||||||
|
(n) => n instanceof Element && n.querySelector('[data-video-fit]'),
|
||||||
|
))),
|
||||||
|
);
|
||||||
|
if (relevant) syncState();
|
||||||
|
});
|
||||||
|
tileObserver.observe(doc.body, {
|
||||||
|
subtree: true,
|
||||||
|
childList: true,
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ['class', 'style'],
|
attributeFilter: ['class', 'style'],
|
||||||
});
|
});
|
||||||
|
syncState();
|
||||||
|
};
|
||||||
|
|
||||||
|
attachObserver();
|
||||||
|
|
||||||
|
// If iframe isn't ready yet, wait for body to be available.
|
||||||
|
let bodyWatcher: MutationObserver | undefined;
|
||||||
|
if (!getDoc()?.body) {
|
||||||
|
bodyWatcher = new MutationObserver(() => {
|
||||||
|
if (getDoc()?.body) {
|
||||||
|
bodyWatcher?.disconnect();
|
||||||
|
bodyWatcher = undefined;
|
||||||
|
attachObserver();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
const doc = getDoc();
|
||||||
|
if (doc) bodyWatcher.observe(doc, { childList: true });
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mutationObserver.disconnect();
|
tileObserver?.disconnect();
|
||||||
|
bodyWatcher?.disconnect();
|
||||||
};
|
};
|
||||||
}, [videoContainers, mutationObserver]);
|
}, [callEmbed, callMembers, joined]);
|
||||||
|
|
||||||
return speakers;
|
return speakers;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
import { useAccountDataCallback } from './useAccountDataCallback';
|
||||||
@@ -32,39 +32,64 @@ export function useReminders(): {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx));
|
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx));
|
||||||
|
|
||||||
|
// Authoritative local snapshot used to compute mutations. Reading
|
||||||
|
// mx.getAccountData() per-mutation is racy: two quick add/remove calls both
|
||||||
|
// read the same stale baseline and the second write clobbers the first
|
||||||
|
// (N113). We instead mutate from this ref, kept in sync with server echoes.
|
||||||
|
const latestRef = useRef<Reminder[]>(reminders);
|
||||||
|
// Serialize writes so overlapping setAccountData calls can't land out of
|
||||||
|
// order on the server (last-write-wins would otherwise drop data).
|
||||||
|
const writeQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||||
|
|
||||||
|
const applyServerState = useCallback((list: Reminder[]) => {
|
||||||
|
latestRef.current = list;
|
||||||
|
setReminders(list);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useAccountDataCallback(
|
useAccountDataCallback(
|
||||||
mx,
|
mx,
|
||||||
useCallback(
|
useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
if (evt.getType() === REMINDERS_KEY) {
|
if (evt.getType() === REMINDERS_KEY) {
|
||||||
setReminders(evt.getContent<RemindersContent>()?.reminders ?? []);
|
applyServerState(evt.getContent<RemindersContent>()?.reminders ?? []);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setReminders],
|
[applyServerState],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Re-read on mx change
|
// Re-read on mx change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReminders(readReminders(mx));
|
applyServerState(readReminders(mx));
|
||||||
}, [mx]);
|
}, [mx, applyServerState]);
|
||||||
|
|
||||||
const addReminder = useCallback(
|
const enqueueWrite = useCallback(
|
||||||
async (r: Reminder) => {
|
(compute: (current: Reminder[]) => Reminder[]): Promise<void> => {
|
||||||
const current = readReminders(mx);
|
const run = writeQueueRef.current.then(async () => {
|
||||||
const next = [...current, r];
|
const next = compute(latestRef.current);
|
||||||
|
latestRef.current = next;
|
||||||
|
setReminders(next);
|
||||||
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
|
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
|
||||||
|
});
|
||||||
|
// Keep the chain alive even if one write rejects, but propagate the
|
||||||
|
// rejection to this caller so it can react (e.g. retry).
|
||||||
|
writeQueueRef.current = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
},
|
},
|
||||||
[mx],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const addReminder = useCallback(
|
||||||
|
(r: Reminder) => enqueueWrite((current) => [...current, r]),
|
||||||
|
[enqueueWrite],
|
||||||
|
);
|
||||||
|
|
||||||
const removeReminder = useCallback(
|
const removeReminder = useCallback(
|
||||||
async (eventId: string, timestamp: number) => {
|
(eventId: string, timestamp: number) =>
|
||||||
const current = readReminders(mx);
|
enqueueWrite((current) =>
|
||||||
const next = current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp));
|
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
|
||||||
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
|
),
|
||||||
},
|
[enqueueWrite],
|
||||||
[mx],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const getReminders = useCallback(() => reminders, [reminders]);
|
const getReminders = useCallback(() => reminders, [reminders]);
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import { lightTheme } from 'folds';
|
|||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
||||||
import {
|
import {
|
||||||
|
bloodRedTheme,
|
||||||
butterTheme,
|
butterTheme,
|
||||||
|
classicMatrixTheme,
|
||||||
|
cyberpunkTheme,
|
||||||
darkTheme,
|
darkTheme,
|
||||||
lotusTerminalLightTheme,
|
lotusTerminalLightTheme,
|
||||||
lotusTerminalTheme,
|
lotusTerminalTheme,
|
||||||
|
midnightTheme,
|
||||||
|
oceanTheme,
|
||||||
silverTheme,
|
silverTheme,
|
||||||
} from '../../colors.css';
|
} from '../../colors.css';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
@@ -43,6 +48,31 @@ export const ButterTheme: Theme = {
|
|||||||
kind: ThemeKind.Dark,
|
kind: ThemeKind.Dark,
|
||||||
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
|
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
|
||||||
};
|
};
|
||||||
|
export const CyberpunkTheme: Theme = {
|
||||||
|
id: 'cyberpunk-theme',
|
||||||
|
kind: ThemeKind.Dark,
|
||||||
|
classNames: ['cyberpunk-theme', cyberpunkTheme, onDarkFontWeight, 'prism-dark'],
|
||||||
|
};
|
||||||
|
export const OceanTheme: Theme = {
|
||||||
|
id: 'ocean-theme',
|
||||||
|
kind: ThemeKind.Dark,
|
||||||
|
classNames: ['ocean-theme', oceanTheme, onDarkFontWeight, 'prism-dark'],
|
||||||
|
};
|
||||||
|
export const BloodRedTheme: Theme = {
|
||||||
|
id: 'blood-red-theme',
|
||||||
|
kind: ThemeKind.Dark,
|
||||||
|
classNames: ['blood-red-theme', bloodRedTheme, onDarkFontWeight, 'prism-dark'],
|
||||||
|
};
|
||||||
|
export const ClassicMatrixTheme: Theme = {
|
||||||
|
id: 'classic-matrix-theme',
|
||||||
|
kind: ThemeKind.Dark,
|
||||||
|
classNames: ['classic-matrix-theme', classicMatrixTheme, onDarkFontWeight, 'prism-dark'],
|
||||||
|
};
|
||||||
|
export const MidnightTheme: Theme = {
|
||||||
|
id: 'midnight-theme',
|
||||||
|
kind: ThemeKind.Dark,
|
||||||
|
classNames: ['midnight-theme', midnightTheme, onDarkFontWeight, 'prism-dark'],
|
||||||
|
};
|
||||||
export const LotusTerminalTheme: Theme = {
|
export const LotusTerminalTheme: Theme = {
|
||||||
id: 'lotus-terminal-theme',
|
id: 'lotus-terminal-theme',
|
||||||
kind: ThemeKind.Dark,
|
kind: ThemeKind.Dark,
|
||||||
@@ -60,7 +90,20 @@ export const LotusTerminalLightTheme: Theme = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useThemes = (): Theme[] => {
|
export const useThemes = (): Theme[] => {
|
||||||
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
|
const themes: Theme[] = useMemo(
|
||||||
|
() => [
|
||||||
|
LightTheme,
|
||||||
|
SilverTheme,
|
||||||
|
DarkTheme,
|
||||||
|
ButterTheme,
|
||||||
|
CyberpunkTheme,
|
||||||
|
OceanTheme,
|
||||||
|
BloodRedTheme,
|
||||||
|
ClassicMatrixTheme,
|
||||||
|
MidnightTheme,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return themes;
|
return themes;
|
||||||
};
|
};
|
||||||
@@ -72,6 +115,11 @@ export const useThemeNames = (): Record<string, string> =>
|
|||||||
[SilverTheme.id]: 'Silver',
|
[SilverTheme.id]: 'Silver',
|
||||||
[DarkTheme.id]: 'Dark',
|
[DarkTheme.id]: 'Dark',
|
||||||
[ButterTheme.id]: 'Butter',
|
[ButterTheme.id]: 'Butter',
|
||||||
|
[CyberpunkTheme.id]: 'Cyberpunk',
|
||||||
|
[OceanTheme.id]: 'Ocean',
|
||||||
|
[BloodRedTheme.id]: 'Blood Red',
|
||||||
|
[ClassicMatrixTheme.id]: 'Classic Matrix',
|
||||||
|
[MidnightTheme.id]: 'Midnight',
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
+40
-35
@@ -1,7 +1,16 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import * as Sentry from '@sentry/react';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
||||||
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
config,
|
||||||
|
OverlayContainerProvider,
|
||||||
|
PopOutContainerProvider,
|
||||||
|
Text,
|
||||||
|
toRem,
|
||||||
|
TooltipContainerProvider,
|
||||||
|
} from 'folds';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
@@ -17,6 +26,7 @@ import { settingsAtom } from '../state/settings';
|
|||||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||||
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||||
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
||||||
|
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
||||||
|
|
||||||
const FONT_MAP: Record<string, string> = {
|
const FONT_MAP: Record<string, string> = {
|
||||||
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
@@ -51,6 +61,17 @@ function AppearanceEffects() {
|
|||||||
}
|
}
|
||||||
}, [settings.mentionHighlightColor]);
|
}, [settings.mentionHighlightColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Custom accent color applies only to non-TDS themes. When Lotus Terminal
|
||||||
|
// (TDS) is active it has its own fixed palette, so we remove any overrides.
|
||||||
|
const accent = settings.customAccentColor;
|
||||||
|
if (accent && !settings.lotusTerminal && applyCustomAccent(accent)) {
|
||||||
|
return () => removeCustomAccent();
|
||||||
|
}
|
||||||
|
removeCustomAccent();
|
||||||
|
return undefined;
|
||||||
|
}, [settings.customAccentColor, settings.lotusTerminal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const font = FONT_MAP[settings.fontFamily ?? 'inter'] ?? FONT_MAP.inter;
|
const font = FONT_MAP[settings.fontFamily ?? 'inter'] ?? FONT_MAP.inter;
|
||||||
document.body.style.setProperty('--font-secondary', font);
|
document.body.style.setProperty('--font-secondary', font);
|
||||||
@@ -90,41 +111,25 @@ function App() {
|
|||||||
const portalContainer = document.getElementById('portalContainer') ?? undefined;
|
const portalContainer = document.getElementById('portalContainer') ?? undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sentry.ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={({ error, resetError }) => (
|
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||||
<div
|
<Box
|
||||||
style={{
|
direction="Column"
|
||||||
display: 'flex',
|
alignItems="Center"
|
||||||
flexDirection: 'column',
|
justifyContent="Center"
|
||||||
alignItems: 'center',
|
gap="400"
|
||||||
justifyContent: 'center',
|
style={{ height: '100vh', padding: config.space.S700, textAlign: 'center' }}
|
||||||
height: '100vh',
|
|
||||||
gap: '16px',
|
|
||||||
fontFamily: 'sans-serif',
|
|
||||||
padding: '24px',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<h2 style={{ margin: 0 }}>Something went wrong</h2>
|
<Text size="H2">Something went wrong</Text>
|
||||||
<p style={{ margin: 0, color: '#666', maxWidth: '400px' }}>
|
<Text size="T300" priority="300" style={{ maxWidth: toRem(400) }}>
|
||||||
{error instanceof Error ? error.message : 'An unexpected error occurred.'}
|
{error instanceof Error ? error.message : 'An unexpected error occurred.'}
|
||||||
</p>
|
</Text>
|
||||||
<button
|
<Button variant="Primary" onClick={resetErrorBoundary}>
|
||||||
type="button"
|
<Text as="span" size="B400">
|
||||||
onClick={resetError}
|
|
||||||
style={{
|
|
||||||
padding: '8px 20px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: 'none',
|
|
||||||
background: '#5865f2',
|
|
||||||
color: '#fff',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</Text>
|
||||||
</div>
|
</Button>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TooltipContainerProvider value={portalContainer}>
|
<TooltipContainerProvider value={portalContainer}>
|
||||||
@@ -159,7 +164,7 @@ function App() {
|
|||||||
</OverlayContainerProvider>
|
</OverlayContainerProvider>
|
||||||
</PopOutContainerProvider>
|
</PopOutContainerProvider>
|
||||||
</TooltipContainerProvider>
|
</TooltipContainerProvider>
|
||||||
</Sentry.ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
|
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
|
||||||
|
import { Box, Button, config, Text, toRem } from 'folds';
|
||||||
|
|
||||||
export function RouteError() {
|
export function RouteError() {
|
||||||
const error = useRouteError();
|
const error = useRouteError();
|
||||||
@@ -11,33 +12,22 @@ export function RouteError() {
|
|||||||
: 'An unexpected error occurred.';
|
: 'An unexpected error occurred.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
style={{
|
direction="Column"
|
||||||
display: 'flex',
|
alignItems="Center"
|
||||||
flexDirection: 'column',
|
justifyContent="Center"
|
||||||
alignItems: 'center',
|
gap="400"
|
||||||
justifyContent: 'center',
|
style={{ height: '100dvh', padding: config.space.S700 }}
|
||||||
height: '100dvh',
|
|
||||||
gap: '16px',
|
|
||||||
padding: '32px',
|
|
||||||
fontFamily: 'sans-serif',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>Something went wrong</h2>
|
|
||||||
<p style={{ margin: 0, opacity: 0.7, textAlign: 'center' }}>{message}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
style={{
|
|
||||||
padding: '8px 20px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<Text size="H3">Something went wrong</Text>
|
||||||
|
<Text size="T300" priority="300" style={{ textAlign: 'center', maxWidth: toRem(400) }}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
<Button variant="Primary" onClick={() => window.location.reload()}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
Reload
|
Reload
|
||||||
</button>
|
</Text>
|
||||||
</div>
|
</Button>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ function MessageNotifications() {
|
|||||||
roomId,
|
roomId,
|
||||||
eventId,
|
eventId,
|
||||||
body,
|
body,
|
||||||
|
encrypted,
|
||||||
}: {
|
}: {
|
||||||
roomName: string;
|
roomName: string;
|
||||||
roomAvatar?: string;
|
roomAvatar?: string;
|
||||||
@@ -249,6 +250,7 @@ function MessageNotifications() {
|
|||||||
roomId: string;
|
roomId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
encrypted?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const roomPath = mDirects.has(roomId)
|
const roomPath = mDirects.has(roomId)
|
||||||
? getDirectRoomPath(roomId, eventId)
|
? getDirectRoomPath(roomId, eventId)
|
||||||
@@ -267,10 +269,17 @@ function MessageNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// N109: the OS notification subsystem fetches icon/badge OUTSIDE the page,
|
||||||
|
// so the SW can't inject auth headers and authenticated-media URLs 401.
|
||||||
|
// Use the static app logo (as invite notifications already do).
|
||||||
|
// N106: never put decrypted E2EE plaintext into the OS notification (it
|
||||||
|
// persists in the notification center / lock screen / is readable by other
|
||||||
|
// apps). For encrypted rooms show only the sender; the in-page toast above
|
||||||
|
// still shows the preview while the user is actively looking at the screen.
|
||||||
const noti = new window.Notification(roomName, {
|
const noti = new window.Notification(roomName, {
|
||||||
icon: roomAvatar,
|
icon: LogoSVG,
|
||||||
badge: roomAvatar,
|
badge: LogoSVG,
|
||||||
body: body ? `${username}: ${body}`.slice(0, 120) : username,
|
body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username,
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,6 +350,7 @@ function MessageNotifications() {
|
|||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
eventId,
|
eventId,
|
||||||
body: (mEvent.getContent().body as string | undefined) ?? '',
|
body: (mEvent.getContent().body as string | undefined) ?? '',
|
||||||
|
encrypted: room.hasEncryptionStateEvent(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,16 +400,26 @@ function ReminderMonitor() {
|
|||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const firedRef = useRef<Set<string>>(new Set());
|
const firedRef = useRef<Set<string>>(new Set());
|
||||||
|
const removingRef = useRef<Set<string>>(new Set());
|
||||||
|
// Read the latest reminders / DM map via refs so the poll interval below is
|
||||||
|
// created once — not torn down and restarted (which resets its 30s countdown
|
||||||
|
// and can indefinitely defer a near-due reminder) on every reminder sync (N115).
|
||||||
|
const remindersRef = useRef(reminders);
|
||||||
|
remindersRef.current = reminders;
|
||||||
|
const mDirectsRef = useRef(mDirects);
|
||||||
|
mDirectsRef.current = mDirects;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => {
|
const check = () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
reminders.forEach((r) => {
|
remindersRef.current.forEach((r) => {
|
||||||
|
if (r.timestamp > now) return;
|
||||||
const key = `${r.eventId}-${r.timestamp}`;
|
const key = `${r.eventId}-${r.timestamp}`;
|
||||||
if (r.timestamp <= now && !firedRef.current.has(key)) {
|
// Show the toast exactly once.
|
||||||
|
if (!firedRef.current.has(key)) {
|
||||||
firedRef.current.add(key);
|
firedRef.current.add(key);
|
||||||
const room = mx.getRoom(r.roomId);
|
const room = mx.getRoom(r.roomId);
|
||||||
const hashPath = mDirects.has(r.roomId)
|
const hashPath = mDirectsRef.current.has(r.roomId)
|
||||||
? getDirectRoomPath(r.roomId, r.eventId)
|
? getDirectRoomPath(r.roomId, r.eventId)
|
||||||
: getHomeRoomPath(r.roomId, r.eventId);
|
: getHomeRoomPath(r.roomId, r.eventId);
|
||||||
setToast({
|
setToast({
|
||||||
@@ -410,7 +430,15 @@ function ReminderMonitor() {
|
|||||||
roomId: r.roomId,
|
roomId: r.roomId,
|
||||||
hashPath,
|
hashPath,
|
||||||
});
|
});
|
||||||
removeReminder(r.eventId, r.timestamp);
|
}
|
||||||
|
// Persist the removal, retrying on a later tick if it fails — without
|
||||||
|
// re-showing the toast (N114). The server echo drops it from
|
||||||
|
// `reminders` once the write lands.
|
||||||
|
if (!removingRef.current.has(key)) {
|
||||||
|
removingRef.current.add(key);
|
||||||
|
removeReminder(r.eventId, r.timestamp).catch(() => {
|
||||||
|
removingRef.current.delete(key);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -425,7 +453,7 @@ function ReminderMonitor() {
|
|||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
document.removeEventListener('visibilitychange', onVisible);
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
};
|
};
|
||||||
}, [mx, reminders, setToast, removeReminder, mDirects]);
|
}, [mx, setToast, removeReminder]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -459,11 +487,12 @@ function TauriUpdateFeature() {
|
|||||||
firedRef.current = status.version;
|
firedRef.current = status.version;
|
||||||
setToast({
|
setToast({
|
||||||
id: `tauri-update-${status.version}`,
|
id: `tauri-update-${status.version}`,
|
||||||
displayName: 'Update Available',
|
displayName: '⬆ Update Available',
|
||||||
body: `Lotus Chat ${status.version} is ready to install.`,
|
body: `Lotus Chat ${status.version} is ready. Click to install and restart.`,
|
||||||
roomName: 'System',
|
roomName: 'System',
|
||||||
roomId: '',
|
roomId: '',
|
||||||
onClick: install,
|
onClick: install,
|
||||||
|
sticky: true,
|
||||||
});
|
});
|
||||||
}, [status, setToast, install]);
|
}, [status, setToast, install]);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
|
import React, {
|
||||||
|
MouseEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
clearCacheAndReload,
|
clearCacheAndReload,
|
||||||
clearLoginData,
|
clearLoginData,
|
||||||
@@ -35,7 +42,7 @@ import { useSyncState } from '../../hooks/useSyncState';
|
|||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { SyncStatus } from './SyncStatus';
|
import { SyncStatus } from './SyncStatus';
|
||||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
import { getFallbackSession } from '../../state/sessions';
|
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
||||||
import { AutoDiscovery } from './AutoDiscovery';
|
import { AutoDiscovery } from './AutoDiscovery';
|
||||||
|
|
||||||
function ClientRootLoading() {
|
function ClientRootLoading() {
|
||||||
@@ -130,7 +137,10 @@ const useLogoutListener = (mx?: MatrixClient) => {
|
|||||||
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
||||||
mx?.stopClient();
|
mx?.stopClient();
|
||||||
await mx?.clearStores();
|
await mx?.clearStores();
|
||||||
window.localStorage.clear();
|
// Remove only the session credential keys — NOT settings, drafts, and
|
||||||
|
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
||||||
|
// window.localStorage.clear() is reserved for the explicit reset path.
|
||||||
|
removeFallbackSession();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,6 +156,11 @@ type ClientRootProps = {
|
|||||||
};
|
};
|
||||||
export function ClientRoot({ children }: ClientRootProps) {
|
export function ClientRoot({ children }: ClientRootProps) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [syncError, setSyncError] = useState(false);
|
||||||
|
// Tracks whether the initial sync has ever reached PREPARED. After that,
|
||||||
|
// transient sync errors are handled by <SyncStatus>'s reconnection banner,
|
||||||
|
// so we must NOT pop the blocking error splash for them.
|
||||||
|
const hasPreparedRef = useRef(false);
|
||||||
const { baseUrl, userId } = getFallbackSession() ?? {};
|
const { baseUrl, userId } = getFallbackSession() ?? {};
|
||||||
|
|
||||||
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
|
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
|
||||||
@@ -180,7 +195,14 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||||||
mx,
|
mx,
|
||||||
useCallback((state) => {
|
useCallback((state) => {
|
||||||
if (state === 'PREPARED') {
|
if (state === 'PREPARED') {
|
||||||
|
hasPreparedRef.current = true;
|
||||||
|
setSyncError(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
} else if (state === 'ERROR' || state === 'STOPPED') {
|
||||||
|
// Only surface the blocking error splash when the INITIAL sync fails
|
||||||
|
// (offline at startup, homeserver unreachable, non-retryable /sync
|
||||||
|
// error). After the first PREPARED, <SyncStatus> owns reconnection UX.
|
||||||
|
if (!hasPreparedRef.current) setSyncError(true);
|
||||||
}
|
}
|
||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
@@ -188,9 +210,11 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||||||
return (
|
return (
|
||||||
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
|
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
|
||||||
<SpecVersions baseUrl={baseUrl!}>
|
<SpecVersions baseUrl={baseUrl!}>
|
||||||
{mx && <SyncStatus mx={mx} />}
|
{mx && !syncError && <SyncStatus mx={mx} />}
|
||||||
{loading && <ClientRootOptions mx={mx} />}
|
{loading && <ClientRootOptions mx={mx} />}
|
||||||
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
|
{(loadState.status === AsyncStatus.Error ||
|
||||||
|
startState.status === AsyncStatus.Error ||
|
||||||
|
syncError) && (
|
||||||
<SplashScreen>
|
<SplashScreen>
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
@@ -223,6 +247,13 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||||||
{startState.status === AsyncStatus.Error && (
|
{startState.status === AsyncStatus.Error && (
|
||||||
<Text>{`Failed to start. ${startState.error.message}`}</Text>
|
<Text>{`Failed to start. ${startState.error.message}`}</Text>
|
||||||
)}
|
)}
|
||||||
|
{syncError &&
|
||||||
|
loadState.status !== AsyncStatus.Error &&
|
||||||
|
startState.status !== AsyncStatus.Error && (
|
||||||
|
<Text>
|
||||||
|
Failed to sync with your homeserver. Check your connection and try again.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{('error' in loadState ? (loadState as any).error?.message : undefined) !==
|
{('error' in loadState ? (loadState as any).error?.message : undefined) !==
|
||||||
IDB_VERSION_CONFLICT && (
|
IDB_VERSION_CONFLICT && (
|
||||||
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
|
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
|
|
||||||
private _pipMode = false;
|
private _pipMode = false;
|
||||||
|
|
||||||
private mediaStatePromiseResolver: undefined | (() => void);
|
|
||||||
|
|
||||||
private get document(): Document | undefined {
|
private get document(): Document | undefined {
|
||||||
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
||||||
}
|
}
|
||||||
@@ -183,13 +181,13 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async setMediaState(state: ElementMediaStatePayload) {
|
private async setMediaState(state: ElementMediaStatePayload) {
|
||||||
const data = await this.call.transport.send(ElementWidgetActions.DeviceMute, state);
|
// transport.send resolves once EC has ACK'd the command, which is enough to
|
||||||
return new Promise<typeof data>((resolve) => {
|
// consider the mute applied. We deliberately do NOT gate completion on a
|
||||||
if (this.mediaStatePromiseResolver) {
|
// follow-up DeviceMute state-echo: EC may elide it (e.g. when the requested
|
||||||
this.mediaStatePromiseResolver();
|
// state already matches its current state) or skip it during teardown,
|
||||||
}
|
// which would strand this promise forever and block applyState(). The echo,
|
||||||
this.mediaStatePromiseResolver = () => resolve(data);
|
// when it does arrive, is still handled authoritatively by onMediaState().
|
||||||
});
|
return this.call.transport.send(ElementWidgetActions.DeviceMute, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setSound(sound: boolean): void {
|
private setSound(sound: boolean): void {
|
||||||
@@ -233,11 +231,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
if (this.microphone && !this.sound) {
|
if (this.microphone && !this.sound) {
|
||||||
this.toggleSound();
|
this.toggleSound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.mediaStatePromiseResolver) {
|
|
||||||
this.mediaStatePromiseResolver();
|
|
||||||
this.mediaStatePromiseResolver = undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onControlMutation() {
|
private onControlMutation() {
|
||||||
@@ -356,20 +349,68 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
const doc = this.document;
|
const doc = this.document;
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
|
||||||
// Find the mute icon / aria-label element that identifies this participant
|
// EC labels participant tiles inconsistently across versions — the user's
|
||||||
const userEl = doc.querySelector<HTMLElement>(`[aria-label="${CSS.escape(userId)}"]`);
|
// matrix id may be the full aria-label, a substring of it, or carried on a
|
||||||
// Walk up to the nearest video tile container
|
// data attribute (and sometimes the visible label is the display name, not
|
||||||
const tile =
|
// the id at all). Try several strategies before giving up, then walk up to
|
||||||
userEl?.closest<HTMLElement>('[data-testid="videoTile"]') ??
|
// the enclosing video tile.
|
||||||
userEl?.closest<HTMLElement>('[data-video-fit]');
|
const findTile = (): HTMLElement | undefined => {
|
||||||
|
const escaped = CSS.escape(userId);
|
||||||
if (!this.spotlight) {
|
const el =
|
||||||
this.spotlightButton?.click();
|
doc.querySelector<HTMLElement>(`[aria-label="${escaped}"]`) ??
|
||||||
}
|
doc.querySelector<HTMLElement>(`[data-testid="videoTile"][aria-label*="${escaped}"]`) ??
|
||||||
|
doc.querySelector<HTMLElement>(`[aria-label*="${escaped}"]`) ??
|
||||||
|
doc.querySelector<HTMLElement>(`[data-member-id="${escaped}"]`) ??
|
||||||
|
doc.querySelector<HTMLElement>(`[data-id="${escaped}"]`) ??
|
||||||
|
undefined;
|
||||||
|
return (
|
||||||
|
el?.closest<HTMLElement>('[data-testid="videoTile"]') ??
|
||||||
|
el?.closest<HTMLElement>('[data-video-fit]') ??
|
||||||
|
el ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFocus = () => {
|
||||||
|
const tile = findTile();
|
||||||
if (tile) {
|
if (tile) {
|
||||||
tile.click();
|
tile.click();
|
||||||
|
} else if (import.meta.env.DEV) {
|
||||||
|
console.warn(`[CallControl] focusCameraParticipant: no tile matched ${userId}`);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.spotlight) {
|
||||||
|
// Already in spotlight — pin immediately.
|
||||||
|
applyFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching to spotlight re-renders EC's layout asynchronously; clicking the
|
||||||
|
// tile in the same tick would land in the old (grid) DOM. A fixed frame
|
||||||
|
// delay is unreliable (EC's React commit can exceed it on slow devices), so
|
||||||
|
// watch the iframe DOM for a spotlight video tile to mount, then focus —
|
||||||
|
// with a hard timeout so the click is always attempted at least once.
|
||||||
|
this.spotlightButton?.click();
|
||||||
|
|
||||||
|
const tileSelector = '[data-testid="videoTile"]';
|
||||||
|
let settled = false;
|
||||||
|
let observer: MutationObserver | undefined;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
observer?.disconnect();
|
||||||
|
applyFocus();
|
||||||
|
};
|
||||||
|
observer = new MutationObserver(() => {
|
||||||
|
if (doc.querySelector(tileSelector)) finish();
|
||||||
|
});
|
||||||
|
observer.observe(doc.body, { childList: true, subtree: true });
|
||||||
|
timer = setTimeout(finish, 600);
|
||||||
|
// A tile may already be present immediately after toggling spotlight.
|
||||||
|
if (doc.querySelector(tileSelector)) finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose() {
|
public dispose() {
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private loadError?: CallLoadErrorReason;
|
private loadError?: CallLoadErrorReason;
|
||||||
|
|
||||||
private readonly loadErrorListeners = new Set<(reason: CallLoadErrorReason) => void>();
|
private readonly loadErrorListeners = new Set<
|
||||||
|
(reason: CallLoadErrorReason | undefined) => void
|
||||||
|
>();
|
||||||
|
|
||||||
// Arrow-function class fields so dispose() passes the exact same reference to mx.off()
|
// Arrow-function class fields so dispose() passes the exact same reference to mx.off()
|
||||||
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
|
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
|
||||||
@@ -375,17 +377,44 @@ export class CallEmbed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private notifyLoadListeners(reason: CallLoadErrorReason | undefined): void {
|
||||||
|
this.loadErrorListeners.forEach((cb) => {
|
||||||
|
try {
|
||||||
|
cb(reason);
|
||||||
|
} catch {
|
||||||
|
// a misbehaving subscriber must not block the others
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks the load lifecycle as settled. Called on success (no reason) or on
|
* Marks the load lifecycle as settled.
|
||||||
* failure (reason set). Idempotent so the first signal wins.
|
*
|
||||||
|
* - Failure (reason set): the FIRST failure wins; a later success can still
|
||||||
|
* heal it (below). Once we've genuinely succeeded, later spurious failures
|
||||||
|
* are ignored.
|
||||||
|
* - Success (no reason): always clears the watchdog. Crucially, if we had
|
||||||
|
* previously settled as a failure (e.g. the 25s watchdog fired on a slow
|
||||||
|
* network but EC then finished loading), we self-heal: clear the error and
|
||||||
|
* notify subscribers with `undefined` so the recovery UI dismisses itself
|
||||||
|
* instead of stranding the user on an error screen over a live call.
|
||||||
*/
|
*/
|
||||||
private settleLoad(reason?: CallLoadErrorReason): void {
|
private settleLoad(reason?: CallLoadErrorReason): void {
|
||||||
|
if (reason) {
|
||||||
if (this.loadSettled) return;
|
if (this.loadSettled) return;
|
||||||
this.loadSettled = true;
|
this.loadSettled = true;
|
||||||
this.clearLoadWatchdog();
|
this.clearLoadWatchdog();
|
||||||
if (reason) {
|
|
||||||
this.loadError = reason;
|
this.loadError = reason;
|
||||||
this.loadErrorListeners.forEach((cb) => cb(reason));
|
this.notifyLoadListeners(reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearLoadWatchdog();
|
||||||
|
const wasFailed = this.loadError !== undefined;
|
||||||
|
this.loadSettled = true;
|
||||||
|
this.loadError = undefined;
|
||||||
|
if (wasFailed) {
|
||||||
|
this.notifyLoadListeners(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +431,7 @@ export class CallEmbed {
|
|||||||
* immediately so late subscribers still see the error.
|
* immediately so late subscribers still see the error.
|
||||||
* @returns an unsubscribe function.
|
* @returns an unsubscribe function.
|
||||||
*/
|
*/
|
||||||
public onLoadError(callback: (reason: CallLoadErrorReason) => void): () => void {
|
public onLoadError(callback: (reason: CallLoadErrorReason | undefined) => void): () => void {
|
||||||
this.loadErrorListeners.add(callback);
|
this.loadErrorListeners.add(callback);
|
||||||
if (this.loadError) callback(this.loadError);
|
if (this.loadError) callback(this.loadError);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cinny_recent_searches_v1';
|
||||||
|
const MAX_RECENT_SEARCHES = 10;
|
||||||
|
|
||||||
|
// Internal atom persists as a plain string[] (JSON-serializable).
|
||||||
|
const internalAtom = atomWithStorage<string[]>(
|
||||||
|
STORAGE_KEY,
|
||||||
|
[],
|
||||||
|
createJSONStorage(() => localStorage),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global atom: string[] of the most recent distinct, non-empty search terms.
|
||||||
|
* Most-recent first, deduped, capped at MAX_RECENT_SEARCHES.
|
||||||
|
* Backed by localStorage so recent searches survive page refreshes.
|
||||||
|
*/
|
||||||
|
export const recentSearchesAtom = atom(
|
||||||
|
(get): string[] => get(internalAtom),
|
||||||
|
(_get, set, updater: string[] | ((prev: string[]) => string[])) => {
|
||||||
|
set(internalAtom, (prev) => {
|
||||||
|
const prevList = Array.isArray(prev) ? prev : [];
|
||||||
|
const next = typeof updater === 'function' ? updater(prevList) : updater;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepend a search term: dedupes (case-sensitive), drops empties, caps at 10.
|
||||||
|
*/
|
||||||
|
export const addRecentSearch = (prev: string[], term: string): string[] => {
|
||||||
|
const trimmed = term.trim();
|
||||||
|
if (!trimmed) return prev;
|
||||||
|
const withoutDupe = prev.filter((t) => t !== trimmed);
|
||||||
|
return [trimmed, ...withoutDupe].slice(0, MAX_RECENT_SEARCHES);
|
||||||
|
};
|
||||||
@@ -20,6 +20,10 @@ export type NoiseSuppressionMode = 'off' | 'browser' | 'ml';
|
|||||||
// CDN. Its wasm is single-threaded, so no COOP/COEP cross-origin isolation is
|
// CDN. Its wasm is single-threaded, so no COOP/COEP cross-origin isolation is
|
||||||
// required (see LOTUS_DENOISE_ENGINEERING_REVIEW.md).
|
// required (see LOTUS_DENOISE_ENGINEERING_REVIEW.md).
|
||||||
export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
|
export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
|
||||||
|
// Incoming-call ringtone. 'classic' is the bundled call.ogg clip; 'chime' /
|
||||||
|
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
|
||||||
|
// 'none' is silent (visual-only incoming-call UI).
|
||||||
|
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
|
||||||
export type ChatBackground =
|
export type ChatBackground =
|
||||||
| 'none'
|
| 'none'
|
||||||
| 'blueprint'
|
| 'blueprint'
|
||||||
@@ -142,12 +146,14 @@ export interface Settings {
|
|||||||
composerToolbarButtons: ComposerToolbarSettings;
|
composerToolbarButtons: ComposerToolbarSettings;
|
||||||
|
|
||||||
mentionHighlightColor: string;
|
mentionHighlightColor: string;
|
||||||
|
customAccentColor: string;
|
||||||
fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code';
|
fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code';
|
||||||
|
|
||||||
afkAutoMute: boolean;
|
afkAutoMute: boolean;
|
||||||
afkTimeoutMinutes: number;
|
afkTimeoutMinutes: number;
|
||||||
|
|
||||||
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
|
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
|
||||||
|
ringtoneId: RingtoneId;
|
||||||
ringtoneVolume: number; // 0–100
|
ringtoneVolume: number; // 0–100
|
||||||
|
|
||||||
seasonalThemeOverride:
|
seasonalThemeOverride:
|
||||||
@@ -237,12 +243,14 @@ const defaultSettings: Settings = {
|
|||||||
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
||||||
|
|
||||||
mentionHighlightColor: '',
|
mentionHighlightColor: '',
|
||||||
|
customAccentColor: '',
|
||||||
fontFamily: 'inter',
|
fontFamily: 'inter',
|
||||||
|
|
||||||
afkAutoMute: false,
|
afkAutoMute: false,
|
||||||
afkTimeoutMinutes: 10,
|
afkTimeoutMinutes: 10,
|
||||||
|
|
||||||
callJoinLeaveSound: 'chime',
|
callJoinLeaveSound: 'chime',
|
||||||
|
ringtoneId: 'classic',
|
||||||
ringtoneVolume: 70,
|
ringtoneVolume: 70,
|
||||||
|
|
||||||
seasonalThemeOverride: 'auto',
|
seasonalThemeOverride: 'auto',
|
||||||
@@ -273,6 +281,15 @@ export const getSettings = (): Settings => {
|
|||||||
saved.callDenoiseModel === 'deepfilternet'
|
saved.callDenoiseModel === 'deepfilternet'
|
||||||
? saved.callDenoiseModel
|
? saved.callDenoiseModel
|
||||||
: defaultSettings.callDenoiseModel,
|
: defaultSettings.callDenoiseModel,
|
||||||
|
// Coerce any unknown persisted ringtone id back to the default.
|
||||||
|
ringtoneId:
|
||||||
|
saved.ringtoneId === 'classic' ||
|
||||||
|
saved.ringtoneId === 'chime' ||
|
||||||
|
saved.ringtoneId === 'soft' ||
|
||||||
|
saved.ringtoneId === 'retro' ||
|
||||||
|
saved.ringtoneId === 'none'
|
||||||
|
? saved.ringtoneId
|
||||||
|
: defaultSettings.ringtoneId,
|
||||||
composerToolbarButtons: {
|
composerToolbarButtons: {
|
||||||
...DEFAULT_COMPOSER_TOOLBAR,
|
...DEFAULT_COMPOSER_TOOLBAR,
|
||||||
...(saved.composerToolbarButtons ?? {}),
|
...(saved.composerToolbarButtons ?? {}),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ToastNotif = {
|
|||||||
roomId: string;
|
roomId: string;
|
||||||
hashPath?: string; // overrides window.location.hash navigation when set
|
hashPath?: string; // overrides window.location.hash navigation when set
|
||||||
onClick?: () => void; // custom click handler; skips hash navigation when set
|
onClick?: () => void; // custom click handler; skips hash navigation when set
|
||||||
|
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseAtom = atom<ToastNotif[]>([]);
|
const baseAtom = atom<ToastNotif[]>([]);
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { color } from 'folds';
|
||||||
|
|
||||||
|
// Custom accent color support for non-TDS themes. The folds `Primary.*` tokens
|
||||||
|
// are imported as strings like "var(--oq6d07f)"; we extract the underlying CSS
|
||||||
|
// variable name at runtime and override it on `document.body`, mirroring the
|
||||||
|
// mention-highlight pattern in pages/App.tsx. When unset (or when the Lotus
|
||||||
|
// Terminal/TDS theme is active) the overrides are removed so the theme defaults
|
||||||
|
// take over again.
|
||||||
|
|
||||||
|
export type Rgb = { r: number; g: number; b: number };
|
||||||
|
|
||||||
|
const clamp = (n: number): number => Math.max(0, Math.min(255, Math.round(n)));
|
||||||
|
|
||||||
|
export const hexToRgb = (hex: string): Rgb | undefined => {
|
||||||
|
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
|
||||||
|
if (!m) return undefined;
|
||||||
|
const h = m[1];
|
||||||
|
return {
|
||||||
|
r: parseInt(h.slice(0, 2), 16),
|
||||||
|
g: parseInt(h.slice(2, 4), 16),
|
||||||
|
b: parseInt(h.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const rgbToHex = ({ r, g, b }: Rgb): string =>
|
||||||
|
`#${[clamp(r), clamp(g), clamp(b)].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
|
||||||
|
// Lighten/darken by moving each channel a percentage toward white/black.
|
||||||
|
export const lighten = ({ r, g, b }: Rgb, amount: number): Rgb => ({
|
||||||
|
r: r + (255 - r) * amount,
|
||||||
|
g: g + (255 - g) * amount,
|
||||||
|
b: b + (255 - b) * amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const darken = ({ r, g, b }: Rgb, amount: number): Rgb => ({
|
||||||
|
r: r * (1 - amount),
|
||||||
|
g: g * (1 - amount),
|
||||||
|
b: b * (1 - amount),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rgba = ({ r, g, b }: Rgb, alpha: number): string =>
|
||||||
|
`rgba(${clamp(r)}, ${clamp(g)}, ${clamp(b)}, ${alpha})`;
|
||||||
|
|
||||||
|
// WCAG 2.1 relative luminance with gamma linearization (matches the mention
|
||||||
|
// highlight contrast logic in pages/App.tsx).
|
||||||
|
const toLinear = (c: number): number => {
|
||||||
|
const s = c / 255;
|
||||||
|
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const relativeLuminance = ({ r, g, b }: Rgb): number =>
|
||||||
|
0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
||||||
|
|
||||||
|
// Choose contrasting text color over the given base (threshold 0.179).
|
||||||
|
export const contrastingText = (rgb: Rgb): string =>
|
||||||
|
relativeLuminance(rgb) > 0.179 ? '#000' : '#fff';
|
||||||
|
|
||||||
|
// Extract the underlying CSS variable name from a folds token string such as
|
||||||
|
// "var(--oq6d07f)" -> "--oq6d07f".
|
||||||
|
export const varNameFromToken = (token: string): string | undefined =>
|
||||||
|
token.match(/var\((--[^)]+)\)/)?.[1];
|
||||||
|
|
||||||
|
// The folds Primary token family, keyed by sub-token name.
|
||||||
|
const PRIMARY_TOKENS: Record<string, string> = {
|
||||||
|
Main: color.Primary.Main,
|
||||||
|
MainHover: color.Primary.MainHover,
|
||||||
|
MainActive: color.Primary.MainActive,
|
||||||
|
MainLine: color.Primary.MainLine,
|
||||||
|
OnMain: color.Primary.OnMain,
|
||||||
|
Container: color.Primary.Container,
|
||||||
|
ContainerHover: color.Primary.ContainerHover,
|
||||||
|
ContainerActive: color.Primary.ContainerActive,
|
||||||
|
ContainerLine: color.Primary.ContainerLine,
|
||||||
|
OnContainer: color.Primary.OnContainer,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derive the 10 Primary sub-token values from a single chosen base color.
|
||||||
|
export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
|
||||||
|
const baseHex = rgbToHex(base);
|
||||||
|
// If the base is very light, darken OnContainer slightly so it stays readable
|
||||||
|
// against the (light, low-alpha) container backgrounds.
|
||||||
|
const onContainer = relativeLuminance(base) > 0.6 ? rgbToHex(darken(base, 0.25)) : baseHex;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Main: baseHex,
|
||||||
|
MainHover: rgbToHex(lighten(base, 0.08)),
|
||||||
|
MainActive: rgbToHex(darken(base, 0.08)),
|
||||||
|
MainLine: baseHex,
|
||||||
|
OnMain: contrastingText(base),
|
||||||
|
Container: rgba(base, 0.12),
|
||||||
|
ContainerHover: rgba(base, 0.16),
|
||||||
|
ContainerActive: rgba(base, 0.22),
|
||||||
|
ContainerLine: rgba(base, 0.4),
|
||||||
|
OnContainer: onContainer,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply a custom accent color by overriding the folds Primary CSS variables on
|
||||||
|
// `document.body`. Returns true when applied, false when the input is invalid.
|
||||||
|
export const applyCustomAccent = (hex: string): boolean => {
|
||||||
|
const base = hexToRgb(hex);
|
||||||
|
if (!base) return false;
|
||||||
|
const palette = derivePrimaryPalette(base);
|
||||||
|
Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => {
|
||||||
|
const varName = varNameFromToken(token);
|
||||||
|
if (varName) document.body.style.setProperty(varName, palette[key]);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove all custom accent overrides, reverting to the active theme's defaults.
|
||||||
|
export const removeCustomAccent = (): void => {
|
||||||
|
Object.values(PRIMARY_TOKENS).forEach((token) => {
|
||||||
|
const varName = varNameFromToken(token);
|
||||||
|
if (varName) document.body.style.removeProperty(varName);
|
||||||
|
});
|
||||||
|
};
|
||||||
+11
-3
@@ -169,12 +169,17 @@ const matrixErrorFromUnknown = (e: unknown): MatrixError => {
|
|||||||
// HTTP statuses that should not be retried — client errors are deterministic
|
// HTTP statuses that should not be retried — client errors are deterministic
|
||||||
// (e.g. 413 payload too large, 400 bad request, 401/403 auth) and won't succeed on retry.
|
// (e.g. 413 payload too large, 400 bad request, 401/403 auth) and won't succeed on retry.
|
||||||
const isRetryableUploadError = (e: unknown): boolean => {
|
const isRetryableUploadError = (e: unknown): boolean => {
|
||||||
|
// A user-cancelled / aborted upload must never be retried. matrix-js-sdk's
|
||||||
|
// mx.cancelUpload() rejects the upload with a DOMException named "AbortError";
|
||||||
|
// without this guard the retry loop would resurrect an upload the user just
|
||||||
|
// cancelled.
|
||||||
|
if ((e as { name?: unknown } | null | undefined)?.name === 'AbortError') return false;
|
||||||
if (e instanceof MatrixError) {
|
if (e instanceof MatrixError) {
|
||||||
const status = e.httpStatus;
|
const status = e.httpStatus;
|
||||||
// No status => network/transport failure (transient): retry.
|
// No status => network/transport failure (transient): retry.
|
||||||
if (typeof status !== 'number') return true;
|
if (typeof status !== 'number') return true;
|
||||||
// Retry on rate-limiting and server-side (5xx) errors only.
|
// Retry on request-timeout, rate-limiting and server-side (5xx) errors only.
|
||||||
return status === 429 || status >= 500;
|
return status === 408 || status === 429 || status >= 500;
|
||||||
}
|
}
|
||||||
// Non-Matrix errors are typically network/transport failures: retry.
|
// Non-Matrix errors are typically network/transport failures: retry.
|
||||||
return true;
|
return true;
|
||||||
@@ -307,6 +312,8 @@ export const addRoomIdToMDirect = async (
|
|||||||
// (it can only be a DM room for one person)
|
// (it can only be a DM room for one person)
|
||||||
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
|
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
|
||||||
const roomIds = userIdToRoomIds[targetUserId];
|
const roomIds = userIdToRoomIds[targetUserId];
|
||||||
|
// Guard against a corrupt m.direct where a value isn't an array.
|
||||||
|
if (!Array.isArray(roomIds)) return;
|
||||||
|
|
||||||
if (targetUserId !== userId) {
|
if (targetUserId !== userId) {
|
||||||
const indexOfRoomId = roomIds.indexOf(roomId);
|
const indexOfRoomId = roomIds.indexOf(roomId);
|
||||||
@@ -316,7 +323,7 @@ export const addRoomIdToMDirect = async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const roomIds = userIdToRoomIds[userId] || [];
|
const roomIds = Array.isArray(userIdToRoomIds[userId]) ? userIdToRoomIds[userId] : [];
|
||||||
if (roomIds.indexOf(roomId) === -1) {
|
if (roomIds.indexOf(roomId) === -1) {
|
||||||
roomIds.push(roomId);
|
roomIds.push(roomId);
|
||||||
}
|
}
|
||||||
@@ -334,6 +341,7 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string):
|
|||||||
|
|
||||||
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
|
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
|
||||||
const roomIds = userIdToRoomIds[targetUserId];
|
const roomIds = userIdToRoomIds[targetUserId];
|
||||||
|
if (!Array.isArray(roomIds)) return;
|
||||||
const indexOfRoomId = roomIds.indexOf(roomId);
|
const indexOfRoomId = roomIds.indexOf(roomId);
|
||||||
if (indexOfRoomId > -1) {
|
if (indexOfRoomId > -1) {
|
||||||
roomIds.splice(indexOfRoomId, 1);
|
roomIds.splice(indexOfRoomId, 1);
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import CallSound from '../../../public/sound/call.ogg';
|
||||||
|
import { RingtoneId } from '../state/settings';
|
||||||
|
|
||||||
|
export const RINGTONE_OPTIONS: { value: RingtoneId; label: string }[] = [
|
||||||
|
{ value: 'classic', label: 'Classic' },
|
||||||
|
{ value: 'chime', label: 'Chime' },
|
||||||
|
{ value: 'soft', label: 'Soft' },
|
||||||
|
{ value: 'retro', label: 'Retro' },
|
||||||
|
{ value: 'none', label: 'Silent' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isRingtoneId = (v: unknown): v is RingtoneId =>
|
||||||
|
v === 'classic' || v === 'chime' || v === 'soft' || v === 'retro' || v === 'none';
|
||||||
|
|
||||||
|
type SynthStyle = 'chime' | 'soft' | 'retro';
|
||||||
|
|
||||||
|
const clamp01 = (n: number): number => Math.max(0, Math.min(1, n));
|
||||||
|
|
||||||
|
// Shared WebAudio context for synthesized ringtones. Kept separate from the
|
||||||
|
// join/leave-sound context (callSounds.ts) to keep blast radius small.
|
||||||
|
let sharedCtx: AudioContext | undefined;
|
||||||
|
const getCtx = (): AudioContext | undefined => {
|
||||||
|
try {
|
||||||
|
if (!sharedCtx || sharedCtx.state === 'closed') sharedCtx = new AudioContext();
|
||||||
|
if (sharedCtx.state === 'suspended') sharedCtx.resume().catch(() => undefined);
|
||||||
|
return sharedCtx;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type Note = {
|
||||||
|
freq: number;
|
||||||
|
/** Offset from phrase start, in seconds */
|
||||||
|
at: number;
|
||||||
|
/** Duration in seconds */
|
||||||
|
dur: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One looping phrase per synth style + the period before it repeats.
|
||||||
|
const PHRASES: Record<
|
||||||
|
SynthStyle,
|
||||||
|
{ type: OscillatorType; gain: number; period: number; notes: Note[] }
|
||||||
|
> = {
|
||||||
|
// Two-tone "ring … ring" telephone cadence.
|
||||||
|
chime: {
|
||||||
|
type: 'sine',
|
||||||
|
gain: 0.3,
|
||||||
|
period: 3,
|
||||||
|
notes: [
|
||||||
|
{ freq: 587.33, at: 0, dur: 0.35 },
|
||||||
|
{ freq: 880, at: 0.4, dur: 0.35 },
|
||||||
|
{ freq: 587.33, at: 1.0, dur: 0.35 },
|
||||||
|
{ freq: 880, at: 1.4, dur: 0.35 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Gentle rising triangle pair.
|
||||||
|
soft: {
|
||||||
|
type: 'triangle',
|
||||||
|
gain: 0.24,
|
||||||
|
period: 3.2,
|
||||||
|
notes: [
|
||||||
|
{ freq: 523.25, at: 0, dur: 0.5 },
|
||||||
|
{ freq: 659.25, at: 0.55, dur: 0.7 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Retro arpeggio sweep.
|
||||||
|
retro: {
|
||||||
|
type: 'square',
|
||||||
|
gain: 0.12,
|
||||||
|
period: 2.4,
|
||||||
|
notes: [
|
||||||
|
{ freq: 440, at: 0, dur: 0.12 },
|
||||||
|
{ freq: 554.37, at: 0.13, dur: 0.12 },
|
||||||
|
{ freq: 659.25, at: 0.26, dur: 0.12 },
|
||||||
|
{ freq: 880, at: 0.39, dur: 0.22 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const playPhrase = (style: SynthStyle, volume: number, destination: AudioNode): void => {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
const { type, gain: peak, notes } = PHRASES[style];
|
||||||
|
const scaledPeak = peak * clamp01(volume);
|
||||||
|
if (scaledPeak <= 0) return;
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
notes.forEach(({ freq, at, dur }) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = type;
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
const start = now + at;
|
||||||
|
// Short attack + exponential decay to avoid clicks.
|
||||||
|
gain.gain.setValueAtTime(0, start);
|
||||||
|
gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(destination);
|
||||||
|
osc.start(start);
|
||||||
|
osc.stop(start + dur + 0.02);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startClassic = (volume: number, loop: boolean): (() => void) => {
|
||||||
|
let audio: HTMLAudioElement | undefined;
|
||||||
|
try {
|
||||||
|
audio = new Audio(CallSound);
|
||||||
|
audio.loop = loop;
|
||||||
|
audio.volume = clamp01(volume);
|
||||||
|
audio.play().catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
audio = undefined;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (!audio) return;
|
||||||
|
audio.pause();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
audio = undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (!ctx) return () => undefined;
|
||||||
|
// All notes route through a per-session master gain so stop() can silence
|
||||||
|
// everything instantly — including notes already scheduled slightly in the
|
||||||
|
// future — instead of letting the last phrase ring out after the user answers.
|
||||||
|
const master = ctx.createGain();
|
||||||
|
master.gain.value = 1;
|
||||||
|
master.connect(ctx.destination);
|
||||||
|
|
||||||
|
playPhrase(style, volume, master);
|
||||||
|
const id = loop
|
||||||
|
? window.setInterval(() => playPhrase(style, volume, master), PHRASES[style].period * 1000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
return () => {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
if (id) window.clearInterval(id);
|
||||||
|
try {
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
master.gain.cancelScheduledValues(now);
|
||||||
|
master.gain.setValueAtTime(master.gain.value, now);
|
||||||
|
master.gain.linearRampToValueAtTime(0, now + 0.03);
|
||||||
|
} catch {
|
||||||
|
/* context may be closed */
|
||||||
|
}
|
||||||
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
master.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start an incoming-call ringtone, looping until the returned stop fn is
|
||||||
|
* called. `volume` is 0..1. Returns a no-op stop fn for 'none'.
|
||||||
|
*
|
||||||
|
* Synthesized styles share the WebAudio autoplay limitation of the bundled
|
||||||
|
* 'classic' file: until the page has had a user gesture the browser may keep
|
||||||
|
* audio suspended, so the very first ring after a cold page load can be
|
||||||
|
* silent. This matches the pre-existing behaviour of the classic ringtone.
|
||||||
|
*/
|
||||||
|
export const startRingtone = (id: RingtoneId, volume: number): (() => void) => {
|
||||||
|
if (id === 'none') return () => undefined;
|
||||||
|
if (id === 'classic') return startClassic(volume, true);
|
||||||
|
return startSynth(id, volume, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only one preview may sound at a time; starting a new one cancels the last.
|
||||||
|
let activePreviewStop: (() => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a single, non-looping preview of a ringtone (used by Settings).
|
||||||
|
* Auto-stops the bundled 'classic' clip after a few seconds and cancels any
|
||||||
|
* previously-playing preview. Returns a stop fn for early cancellation.
|
||||||
|
*/
|
||||||
|
export const previewRingtone = (id: RingtoneId, volume: number): (() => void) => {
|
||||||
|
activePreviewStop?.();
|
||||||
|
activePreviewStop = null;
|
||||||
|
if (id === 'none') return () => undefined;
|
||||||
|
|
||||||
|
const stop = id === 'classic' ? startClassic(volume, false) : startSynth(id, volume, false);
|
||||||
|
let timer = 0;
|
||||||
|
const wrapped = () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
stop();
|
||||||
|
if (activePreviewStop === wrapped) activePreviewStop = null;
|
||||||
|
};
|
||||||
|
timer = window.setTimeout(wrapped, 4000);
|
||||||
|
activePreviewStop = wrapped;
|
||||||
|
return wrapped;
|
||||||
|
};
|
||||||
@@ -155,6 +155,11 @@ export const sanitizeCustomHtml = (customHtml: string): string =>
|
|||||||
allowProtocolRelative: false,
|
allowProtocolRelative: false,
|
||||||
allowedClasses: {
|
allowedClasses: {
|
||||||
code: ['language-*'],
|
code: ['language-*'],
|
||||||
|
// `pre` permits `class` (for `<pre class="language-*">` wrappers); without
|
||||||
|
// an allowedClasses entry, sanitize-html lets a remote sender put ARBITRARY
|
||||||
|
// class names on <pre>, activating site CSS (N100). Restrict to the same
|
||||||
|
// language-* whitelist as <code>.
|
||||||
|
pre: ['language-*'],
|
||||||
},
|
},
|
||||||
allowedStyles: {
|
allowedStyles: {
|
||||||
'*': {
|
'*': {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
|
|||||||
|
|
||||||
import { cryptoCallbacks } from './secretStorageKeys';
|
import { cryptoCallbacks } from './secretStorageKeys';
|
||||||
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
||||||
|
import { removeFallbackSession } from '../app/state/sessions';
|
||||||
import { pushSessionToSW } from '../sw-session';
|
import { pushSessionToSW } from '../sw-session';
|
||||||
|
|
||||||
type Session = {
|
type Session = {
|
||||||
@@ -75,7 +76,9 @@ export const logoutClient = async (mx: MatrixClient) => {
|
|||||||
// ignore if failed to logout
|
// ignore if failed to logout
|
||||||
}
|
}
|
||||||
await mx.clearStores();
|
await mx.clearStores();
|
||||||
window.localStorage.clear();
|
// Remove only the session credential keys, preserving user preferences and
|
||||||
|
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
||||||
|
removeFallbackSession();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,301 @@ export const butterTheme = createTheme(color, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const cyberpunkTheme = createTheme(color, {
|
||||||
|
...darkThemeData,
|
||||||
|
Background: {
|
||||||
|
Container: '#0a0015',
|
||||||
|
ContainerHover: '#130722',
|
||||||
|
ContainerActive: '#1c0f30',
|
||||||
|
ContainerLine: '#26173d',
|
||||||
|
OnContainer: '#ECE6F5',
|
||||||
|
},
|
||||||
|
|
||||||
|
Surface: {
|
||||||
|
Container: '#130722',
|
||||||
|
ContainerHover: '#1c0f30',
|
||||||
|
ContainerActive: '#26173d',
|
||||||
|
ContainerLine: '#2f1f4a',
|
||||||
|
OnContainer: '#ECE6F5',
|
||||||
|
},
|
||||||
|
|
||||||
|
SurfaceVariant: {
|
||||||
|
Container: '#1c0f30',
|
||||||
|
ContainerHover: '#26173d',
|
||||||
|
ContainerActive: '#2f1f4a',
|
||||||
|
ContainerLine: '#392858',
|
||||||
|
OnContainer: '#ECE6F5',
|
||||||
|
},
|
||||||
|
|
||||||
|
Primary: {
|
||||||
|
Main: '#bf5fff',
|
||||||
|
MainHover: '#c873ff',
|
||||||
|
MainActive: '#cd7eff',
|
||||||
|
MainLine: '#d28aff',
|
||||||
|
OnMain: '#1a0033',
|
||||||
|
Container: '#3d1a5c',
|
||||||
|
ContainerHover: '#461e69',
|
||||||
|
ContainerActive: '#502276',
|
||||||
|
ContainerLine: '#592683',
|
||||||
|
OnContainer: '#EBD6FF',
|
||||||
|
},
|
||||||
|
|
||||||
|
Secondary: {
|
||||||
|
Main: '#ff2d9b',
|
||||||
|
MainHover: '#ff47a8',
|
||||||
|
MainActive: '#ff54af',
|
||||||
|
MainLine: '#ff61b6',
|
||||||
|
OnMain: '#33001a',
|
||||||
|
Container: '#5c0033',
|
||||||
|
ContainerHover: '#69003a',
|
||||||
|
ContainerActive: '#760041',
|
||||||
|
ContainerLine: '#830048',
|
||||||
|
OnContainer: '#FFD6EB',
|
||||||
|
},
|
||||||
|
|
||||||
|
Other: {
|
||||||
|
FocusRing: 'rgba(191, 95, 255, 0.5)',
|
||||||
|
Shadow: 'rgba(0, 0, 0, 1)',
|
||||||
|
Overlay: 'rgba(10, 0, 21, 0.9)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const oceanTheme = createTheme(color, {
|
||||||
|
...darkThemeData,
|
||||||
|
Background: {
|
||||||
|
Container: '#020b18',
|
||||||
|
ContainerHover: '#051426',
|
||||||
|
ContainerActive: '#091d34',
|
||||||
|
ContainerLine: '#0e2742',
|
||||||
|
OnContainer: '#DCEAF2',
|
||||||
|
},
|
||||||
|
|
||||||
|
Surface: {
|
||||||
|
Container: '#051426',
|
||||||
|
ContainerHover: '#091d34',
|
||||||
|
ContainerActive: '#0e2742',
|
||||||
|
ContainerLine: '#143150',
|
||||||
|
OnContainer: '#DCEAF2',
|
||||||
|
},
|
||||||
|
|
||||||
|
SurfaceVariant: {
|
||||||
|
Container: '#091d34',
|
||||||
|
ContainerHover: '#0e2742',
|
||||||
|
ContainerActive: '#143150',
|
||||||
|
ContainerLine: '#1a3b5e',
|
||||||
|
OnContainer: '#DCEAF2',
|
||||||
|
},
|
||||||
|
|
||||||
|
Primary: {
|
||||||
|
Main: '#00c9b1',
|
||||||
|
MainHover: '#1ad2bd',
|
||||||
|
MainActive: '#29d7c4',
|
||||||
|
MainLine: '#38dccb',
|
||||||
|
OnMain: '#00231f',
|
||||||
|
Container: '#004c43',
|
||||||
|
ContainerHover: '#00564c',
|
||||||
|
ContainerActive: '#006155',
|
||||||
|
ContainerLine: '#006b5e',
|
||||||
|
OnContainer: '#B3F0E8',
|
||||||
|
},
|
||||||
|
|
||||||
|
Secondary: {
|
||||||
|
Main: '#0096d6',
|
||||||
|
MainHover: '#1aa3dc',
|
||||||
|
MainActive: '#29aadf',
|
||||||
|
MainLine: '#38b1e2',
|
||||||
|
OnMain: '#001a26',
|
||||||
|
Container: '#003a52',
|
||||||
|
ContainerHover: '#00425e',
|
||||||
|
ContainerActive: '#004b6b',
|
||||||
|
ContainerLine: '#005377',
|
||||||
|
OnContainer: '#B3E2F5',
|
||||||
|
},
|
||||||
|
|
||||||
|
Other: {
|
||||||
|
FocusRing: 'rgba(0, 201, 177, 0.5)',
|
||||||
|
Shadow: 'rgba(0, 0, 0, 1)',
|
||||||
|
Overlay: 'rgba(2, 11, 24, 0.9)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bloodRedTheme = createTheme(color, {
|
||||||
|
...darkThemeData,
|
||||||
|
Background: {
|
||||||
|
Container: '#0d0203',
|
||||||
|
ContainerHover: '#180608',
|
||||||
|
ContainerActive: '#240a0d',
|
||||||
|
ContainerLine: '#300e12',
|
||||||
|
OnContainer: '#F2DDDD',
|
||||||
|
},
|
||||||
|
|
||||||
|
Surface: {
|
||||||
|
Container: '#180608',
|
||||||
|
ContainerHover: '#240a0d',
|
||||||
|
ContainerActive: '#300e12',
|
||||||
|
ContainerLine: '#3c1318',
|
||||||
|
OnContainer: '#F2DDDD',
|
||||||
|
},
|
||||||
|
|
||||||
|
SurfaceVariant: {
|
||||||
|
Container: '#240a0d',
|
||||||
|
ContainerHover: '#300e12',
|
||||||
|
ContainerActive: '#3c1318',
|
||||||
|
ContainerLine: '#48181e',
|
||||||
|
OnContainer: '#F2DDDD',
|
||||||
|
},
|
||||||
|
|
||||||
|
Primary: {
|
||||||
|
Main: '#ff2233',
|
||||||
|
MainHover: '#ff3d4b',
|
||||||
|
MainActive: '#ff4a57',
|
||||||
|
MainLine: '#ff5763',
|
||||||
|
OnMain: '#330003',
|
||||||
|
Container: '#7a0010',
|
||||||
|
ContainerHover: '#8a0013',
|
||||||
|
ContainerActive: '#990015',
|
||||||
|
ContainerLine: '#a80018',
|
||||||
|
OnContainer: '#FFD1D6',
|
||||||
|
},
|
||||||
|
|
||||||
|
Secondary: {
|
||||||
|
Main: '#FFFFFF',
|
||||||
|
MainHover: '#E5E5E5',
|
||||||
|
MainActive: '#D9D9D9',
|
||||||
|
MainLine: '#CCCCCC',
|
||||||
|
OnMain: '#0d0203',
|
||||||
|
Container: '#3c1318',
|
||||||
|
ContainerHover: '#48181e',
|
||||||
|
ContainerActive: '#541d24',
|
||||||
|
ContainerLine: '#60222a',
|
||||||
|
OnContainer: '#F2DDDD',
|
||||||
|
},
|
||||||
|
|
||||||
|
Other: {
|
||||||
|
FocusRing: 'rgba(255, 34, 51, 0.5)',
|
||||||
|
Shadow: 'rgba(0, 0, 0, 1)',
|
||||||
|
Overlay: 'rgba(13, 2, 3, 0.9)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const classicMatrixTheme = createTheme(color, {
|
||||||
|
...darkThemeData,
|
||||||
|
Background: {
|
||||||
|
Container: '#000000',
|
||||||
|
ContainerHover: '#0a0f0a',
|
||||||
|
ContainerActive: '#121a12',
|
||||||
|
ContainerLine: '#1c281c',
|
||||||
|
OnContainer: '#C8E6C8',
|
||||||
|
},
|
||||||
|
|
||||||
|
Surface: {
|
||||||
|
Container: '#0a0f0a',
|
||||||
|
ContainerHover: '#121a12',
|
||||||
|
ContainerActive: '#1c281c',
|
||||||
|
ContainerLine: '#263626',
|
||||||
|
OnContainer: '#C8E6C8',
|
||||||
|
},
|
||||||
|
|
||||||
|
SurfaceVariant: {
|
||||||
|
Container: '#121a12',
|
||||||
|
ContainerHover: '#1c281c',
|
||||||
|
ContainerActive: '#263626',
|
||||||
|
ContainerLine: '#304530',
|
||||||
|
OnContainer: '#C8E6C8',
|
||||||
|
},
|
||||||
|
|
||||||
|
Primary: {
|
||||||
|
Main: '#00ff41',
|
||||||
|
MainHover: '#1aff57',
|
||||||
|
MainActive: '#29ff63',
|
||||||
|
MainLine: '#38ff6f',
|
||||||
|
OnMain: '#001a08',
|
||||||
|
Container: '#003311',
|
||||||
|
ContainerHover: '#003d14',
|
||||||
|
ContainerActive: '#004718',
|
||||||
|
ContainerLine: '#00521b',
|
||||||
|
OnContainer: '#9DFFB8',
|
||||||
|
},
|
||||||
|
|
||||||
|
Secondary: {
|
||||||
|
Main: '#C8E6C8',
|
||||||
|
MainHover: '#baddba',
|
||||||
|
MainActive: '#b0d6b0',
|
||||||
|
MainLine: '#a3cca3',
|
||||||
|
OnMain: '#000000',
|
||||||
|
Container: '#263626',
|
||||||
|
ContainerHover: '#304530',
|
||||||
|
ContainerActive: '#3a543a',
|
||||||
|
ContainerLine: '#446344',
|
||||||
|
OnContainer: '#DFF2DF',
|
||||||
|
},
|
||||||
|
|
||||||
|
Other: {
|
||||||
|
FocusRing: 'rgba(0, 255, 65, 0.5)',
|
||||||
|
Shadow: 'rgba(0, 0, 0, 1)',
|
||||||
|
Overlay: 'rgba(0, 0, 0, 0.9)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const midnightTheme = createTheme(color, {
|
||||||
|
...darkThemeData,
|
||||||
|
Background: {
|
||||||
|
Container: '#111827',
|
||||||
|
ContainerHover: '#1a2234',
|
||||||
|
ContainerActive: '#232d42',
|
||||||
|
ContainerLine: '#2c3850',
|
||||||
|
OnContainer: '#E5E9F0',
|
||||||
|
},
|
||||||
|
|
||||||
|
Surface: {
|
||||||
|
Container: '#1a2234',
|
||||||
|
ContainerHover: '#232d42',
|
||||||
|
ContainerActive: '#2c3850',
|
||||||
|
ContainerLine: '#35435e',
|
||||||
|
OnContainer: '#E5E9F0',
|
||||||
|
},
|
||||||
|
|
||||||
|
SurfaceVariant: {
|
||||||
|
Container: '#232d42',
|
||||||
|
ContainerHover: '#2c3850',
|
||||||
|
ContainerActive: '#35435e',
|
||||||
|
ContainerLine: '#3e4e6c',
|
||||||
|
OnContainer: '#E5E9F0',
|
||||||
|
},
|
||||||
|
|
||||||
|
Primary: {
|
||||||
|
Main: '#6b7ca8',
|
||||||
|
MainHover: '#7989b1',
|
||||||
|
MainActive: '#8493b8',
|
||||||
|
MainLine: '#8f9dbf',
|
||||||
|
OnMain: '#000000',
|
||||||
|
Container: '#2e3a55',
|
||||||
|
ContainerHover: '#354161',
|
||||||
|
ContainerActive: '#3c496d',
|
||||||
|
ContainerLine: '#435179',
|
||||||
|
OnContainer: '#D2DAEC',
|
||||||
|
},
|
||||||
|
|
||||||
|
Secondary: {
|
||||||
|
Main: '#E5E9F0',
|
||||||
|
MainHover: '#d4d9e3',
|
||||||
|
MainActive: '#c9cfdb',
|
||||||
|
MainLine: '#bdc4d3',
|
||||||
|
OnMain: '#111827',
|
||||||
|
Container: '#35435e',
|
||||||
|
ContainerHover: '#3e4e6c',
|
||||||
|
ContainerActive: '#47597a',
|
||||||
|
ContainerLine: '#506488',
|
||||||
|
OnContainer: '#E5E9F0',
|
||||||
|
},
|
||||||
|
|
||||||
|
Other: {
|
||||||
|
FocusRing: 'rgba(107, 124, 168, 0.5)',
|
||||||
|
Shadow: 'rgba(0, 0, 0, 1)',
|
||||||
|
Overlay: 'rgba(17, 24, 39, 0.9)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const lotusTerminalTheme = createTheme(color, {
|
export const lotusTerminalTheme = createTheme(color, {
|
||||||
Background: {
|
Background: {
|
||||||
Container: '#030508',
|
Container: '#030508',
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/* eslint-disable import/first */
|
/* eslint-disable import/first */
|
||||||
import * as Sentry from '@sentry/react';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { enableMapSet } from 'immer';
|
import { enableMapSet } from 'immer';
|
||||||
@@ -7,31 +6,6 @@ import '@fontsource-variable/inter/index.css';
|
|||||||
import 'folds/dist/style.css';
|
import 'folds/dist/style.css';
|
||||||
import { configClass, varsClass } from 'folds';
|
import { configClass, varsClass } from 'folds';
|
||||||
|
|
||||||
const sentryDsn = import.meta.env.VITE_SENTRY_DSN;
|
|
||||||
if (sentryDsn) {
|
|
||||||
Sentry.init({
|
|
||||||
dsn: sentryDsn,
|
|
||||||
environment: import.meta.env.MODE,
|
|
||||||
release: import.meta.env.VITE_APP_VERSION,
|
|
||||||
// browserTracingIntegration omitted — it injects sentry-trace/baggage headers
|
|
||||||
// into outgoing fetch calls, which breaks Synapse CORS on matrix.lotusguild.org
|
|
||||||
// No propagation targets — we don't control the Matrix server's CORS allow-list
|
|
||||||
tracePropagationTargets: [],
|
|
||||||
tracesSampleRate: 0,
|
|
||||||
// Don't send PII (IPs, usernames) — this is a private chat app
|
|
||||||
sendDefaultPii: false,
|
|
||||||
// Forward Sentry logs to the dashboard
|
|
||||||
enableLogs: true,
|
|
||||||
// Suppress benign PostmessageTransport / matrixRTC heartbeat timeouts (upstream library noise)
|
|
||||||
ignoreErrors: ['Request timed out'],
|
|
||||||
beforeSend(event) {
|
|
||||||
// Drop any event that may have leaked an access token into breadcrumbs/data
|
|
||||||
if (JSON.stringify(event).includes('access_token')) return null;
|
|
||||||
return event;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
enableMapSet();
|
enableMapSet();
|
||||||
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|||||||
+1
-16
@@ -1,6 +1,5 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
|
||||||
import { wasm } from '@rollup/plugin-wasm';
|
import { wasm } from '@rollup/plugin-wasm';
|
||||||
import inject from '@rollup/plugin-inject';
|
import inject from '@rollup/plugin-inject';
|
||||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||||
@@ -261,20 +260,6 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
copyPdfWorker(),
|
copyPdfWorker(),
|
||||||
lotusDenoise(),
|
lotusDenoise(),
|
||||||
...(process.env.SENTRY_AUTH_TOKEN
|
|
||||||
? [
|
|
||||||
sentryVitePlugin({
|
|
||||||
org: 'lotus-guild',
|
|
||||||
project: 'javascript-react',
|
|
||||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
|
||||||
sourcemaps: {
|
|
||||||
filesToDeleteAfterUpload: ['./dist/**/*.map'],
|
|
||||||
},
|
|
||||||
release: { name: process.env.VITE_APP_VERSION ?? 'lotus' },
|
|
||||||
telemetry: false,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
VitePWA({
|
VitePWA({
|
||||||
srcDir: 'src',
|
srcDir: 'src',
|
||||||
filename: 'sw.ts',
|
filename: 'sw.ts',
|
||||||
@@ -302,7 +287,7 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
target: 'esnext',
|
target: 'esnext',
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: process.env.SENTRY_AUTH_TOKEN ? 'hidden' : false,
|
sourcemap: false,
|
||||||
copyPublicDir: false,
|
copyPublicDir: false,
|
||||||
// manualChunks must be in rolldownOptions (not rollupOptions) for Vite 8 / Rolldown
|
// manualChunks must be in rolldownOptions (not rollupOptions) for Vite 8 / Rolldown
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
|
|||||||
Reference in New Issue
Block a user