Compare commits
17 Commits
66cc51d6d0
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 84a2e7a93e | |||
| 950b8a8128 | |||
| af58f7a32c | |||
| 91c6f2f091 | |||
| 31cf353463 | |||
| 8912423aeb | |||
| bc85cd4984 | |||
| fc8eb70617 | |||
| 1a5896ef84 | |||
| 7b94eeaa60 | |||
| 50076962f6 | |||
| d39aef0aac | |||
| 9f533b1077 | |||
| fdaba40ba9 | |||
| caf6318a5d | |||
| 23649d85b0 | |||
| c67aed01dc |
+381
-35
@@ -49,13 +49,13 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
|||||||
### 4. DM and Group Message Calls
|
### 4. DM and Group Message Calls
|
||||||
|
|
||||||
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
|
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
|
||||||
- **Status:** **PARTIALLY FIXED ⚠️ UNTESTED** — Volume + ringtone selection added. Remaining: (b) non-intrusive notification when already in another call.
|
- **Status:** **FIXED ⚠️ UNTESTED** — needs live-call verification: (a) ring/preview per selected ringtone & volume; (b) the corner banner appearing (with a single ping, not a loop) when a second call arrives while already in a call.
|
||||||
- **Issue:** Incoming call ringtone is hardcoded, lacks volume control, and is suppressed if the user is already in an active call.
|
- **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.
|
- **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:**
|
- **Fix Applied:**
|
||||||
- `ringtoneVolume` setting (0–100, default 70); `IncomingCall` applies it to the ring. Slider in Settings → General → Calls.
|
- `ringtoneVolume` setting (0–100, default 70); applied to the ring. Slider in Settings → General → Calls.
|
||||||
- **(a) Ringtone selection** (`4a875884`): `ringtoneId` setting (`classic | chime | soft | retro | none`). New `utils/ringtones.ts` synthesizes the three styles in-browser (WebAudio, mirroring `callSounds.ts`) — no new binary assets; `classic` keeps `call.ogg`; `none` is silent/visual-only. `startRingtone()` loops until stopped; `previewRingtone()` powers the on-select preview in Settings. Persisted id is whitelisted in `getSettings`.
|
- **(a) Ringtone selection** (`4a875884`): `ringtoneId` setting (`classic | chime | soft | retro | none`). New `utils/ringtones.ts` synthesizes the three styles in-browser (WebAudio, mirroring `callSounds.ts`) — no new binary assets; `classic` keeps `call.ogg`; `none` is silent/visual-only. `startRingtone()` loops until stopped; `previewRingtone()` powers the on-select preview in Settings. Persisted id is whitelisted in `getSettings`.
|
||||||
- **Remaining:** **(b)** When the user is already in a call (`joined`), a second incoming call is currently dropped from the UI entirely (`!joined && callInfo`). Fully un-suppressing would put a disruptive full-screen takeover over the active call — the correct fix is a compact, non-intrusive in-call notification (e.g. a toast with Answer/Reject) rather than the existing `Overlay` dialog. Deferred pending that design.
|
- **(b) Active-call notification** (`c67aed01`): when already joined to a _different_ call, a compact, non-intrusive `IncomingCallBanner` (caller avatar + name + Answer/Reject, top-right) replaces the full-screen `IncomingCall` overlay and plays a **single soft ping** (one-shot ringtone) instead of the looping ring — so it never takes over the screen or talks over the active call. Full overlay still shows when in no call; being in the ringing room's own call still shows nothing.
|
||||||
|
|
||||||
### 5. Seasonal Themes and Chat Backgrounds Design
|
### 5. Seasonal Themes and Chat Backgrounds Design
|
||||||
|
|
||||||
@@ -174,27 +174,27 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
|||||||
## 🌐 Localization, Accessibility & Performance
|
## 🌐 Localization, Accessibility & Performance
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
| Category | Issue Description | File Path | Status |
|
||||||
| :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Localization | Hardcoded UI string: "Chat Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Chat Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Messages, photos, and videos." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Messages, photos, and videos." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Voice Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Voice Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Live audio and video conversations." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Live audio and video conversations." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Download" | `src/app/components/image-viewer/ImageViewer.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Download" | `src/app/components/image-viewer/ImageViewer.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Open Location" | `src/app/components/message/MsgTypeRenderers.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Open Location" | `src/app/components/message/MsgTypeRenderers.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Localization | Hardcoded UI string: "Password" | `src/app/components/uia-stages/PasswordStage.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Password" | `src/app/components/uia-stages/PasswordStage.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
||||||
| Bundle Size | Large unoptimized media asset (213KB) | `public/res/Lotus.png` | 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/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. |
|
| 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. |
|
||||||
@@ -210,7 +210,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
|||||||
## 🔧 Infrastructure, DevEx & Type Safety
|
## 🔧 Infrastructure, DevEx & Type Safety
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
| Category | Issue Description | File Path | Status |
|
||||||
| :------------- | :----------------------------------------------------------------------------------------------------------- | :--------------------------------------------------- | :----- |
|
| :------------- | :----------------------------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Dependencies | `lodash` pinned to non-existent version `4.18.1` | `cinny/package.json` | OPEN |
|
| 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 | 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 | `matrix-js-sdk` pinned to Release Candidate (`41.6.0-rc.0`) | `cinny/package.json` | OPEN |
|
||||||
@@ -236,7 +236,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
|||||||
## 🏗️ Architectural & Resilience Audit
|
## 🏗️ Architectural & Resilience Audit
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
| 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. |
|
| 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 | `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. |
|
| 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. |
|
||||||
@@ -300,11 +300,11 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
|||||||
|
|
||||||
#### N4. `PollContent` Vote Buttons — Entirely Outside the Folds Design System
|
#### N4. `PollContent` Vote Buttons — Entirely Outside the Folds Design System
|
||||||
|
|
||||||
- **File:** `src/app/components/message/content/PollContent.tsx`, lines 250–358
|
- **File:** `src/app/components/message/content/PollContent.tsx`
|
||||||
- **Status:** **OPEN**
|
- **Status:** **FIXED ✅ VERIFIED** (`caf6318a`) — confirmed renders correctly on default Cinny themes and with Lotus TDS enabled; borders, selected state, and progress fill all visible.
|
||||||
- **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.
|
- **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`, `--border-color`). Checkbox/radio indicators, percentage spans, and the poll label used raw pixel/rem font sizes (`0.68rem`, `0.78rem`, `0.88rem`) and hardcoded `rgba()`/`#fff`. None of those vars exist outside TDS mode — the component rendered unstyled (invisible borders / no selected/progress state) on every non-TDS theme.
|
||||||
- **Root Cause:** Custom implementation that bypasses folds primitives entirely.
|
- **Root Cause:** Custom implementation that bypassed folds tokens entirely.
|
||||||
- **Fix:** Rewrite using folds `Button` or `Chip` for answers; replace `--accent-cyan*` with `color.Secondary.*` folds tokens; use `Text size="T300"` for labels.
|
- **Fix Applied:** Kept the `<button>` structure (the progress-bar-behind-text affordance has no folds `Button` equivalent) but made every value theme-reactive: `color.Primary.*` for selected/indicator state, `color.SurfaceVariant.*` for the resting surface + progress fill, `config.*` for radii/spacing/border-width, and folds `<Text>` for the option label, percentage, and section label (dropping the raw rem sizes and `opacity` hacks). The duplicate checkbox/radio indicator spans were merged into one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -376,8 +376,8 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
|||||||
|
|
||||||
**N53 — PTT Badge (Lotus Terminal path): Raw `<div>` tree with `--lt-*` CSS vars instead of folds `<Chip>`**
|
**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
|
- **File:** `src/app/features/call/CallControls.tsx`
|
||||||
- **Status:** **OPEN**
|
- **Status:** **FIXED** (`50076962`) — removed the `lotusTerminal` branch entirely; the PTT badge is now the single folds `<Chip variant={pttActive ? 'Success' : 'Warning'} fill="Soft" radii="400" outlined>` path for all themes (TDS styling still flows through the CSS-variable layer over the Chip). Dropped the now-unused `lotusTerminal` read. Build-verified; visual parity to confirm only if you specifically used the terminal-mode PTT look.
|
||||||
- **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.
|
- **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>`.
|
- **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.
|
- **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.
|
||||||
@@ -440,8 +440,8 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
|||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 — **FIXED** (`50076962`): both `ChatBgGrid` and `SeasonalBgGrid` containers switched to `display: grid; grid-template-columns: repeat(auto-fill, minmax(toRem(76), 1fr))`, so swatches fill each row evenly | 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 |
|
| N82 | Join/Leave Sounds Auto-Preview | `General.tsx` | 1592–1609 | Selecting a sound in the dropdown immediately plays a preview, but no UI affordance communicates this to the user — **FIXED**: the tile description now reads "…Selecting an option plays a preview." (the same affordance was applied to the new Ringtone selector) | Settings tiles with side effects on selection (theme picker, chat background) show a live visual preview or a dedicated control explaining the side effect |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -488,3 +488,349 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
|||||||
| 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" |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 🔴 Additional Major Findings (Gemini)
|
||||||
|
|
||||||
|
**N95 — AFK Auto-Mute Keeps Hardware Mic Active While Muted**
|
||||||
|
|
||||||
|
- **File:** `src/app/hooks/useAfkAutoMute.ts`
|
||||||
|
- **Status:** **OPEN** [Gemini_Found]
|
||||||
|
- **Issue:** The `useAfkAutoMute` hook holds a persistent `MediaStream` from `getUserMedia` for the duration of the call. This causes the OS-level microphone recording indicator (e.g., green dot on macOS/iOS or camera/mic icon on Windows) to stay on even when the user mutes their microphone within the Lotus Chat UI.
|
||||||
|
- **Root Cause:** A separate parallel `MediaStream` is spawned instead of tapping into LiveKit/Element Call's managed local stream.
|
||||||
|
- **Fix:** Stop the `MediaStream` tracks (`.getTracks().forEach(t => t.stop())`) when `callEmbed.control.microphone` is false, and re-request `getUserMedia` when it turns back on. Suspending the `AudioContext` alone is **not sufficient** — it stops processing but does not clear the OS recording indicator; only stopping the tracks does. Optionally suspend the `AudioContext` alongside the track stop for CPU savings. Note: re-requesting `getUserMedia` on unmute adds a small latency and may trigger browser permission prompts on some configurations. Tapping into Element Call's local audio stream directly is architecturally cleaner but is not possible from Lotus — EC runs in a cross-origin iframe and its LiveKit `LocalAudioTrack` is inaccessible from our realm.
|
||||||
|
|
||||||
|
**N96 — Call Recovery "Retry" and "Leave" Buttons Perform Identical Actions**
|
||||||
|
|
||||||
|
- **File:** `src/app/features/call/CallView.tsx` (`CallLoadErrorMessage`)
|
||||||
|
- **Status:** **FIXED ⚠️ UNTESTED** [Gemini_Found] — needs verification: force a call load failure (offline the network right as you click join, or block the EC origin), confirm the error overlay shows a single **"Back"** button that returns to the prescreen cleanly.
|
||||||
|
- **Issue:** The `Retry` and `Leave` buttons in the call error overlay both executed the exact same `dismiss` function (`setCallEmbed(undefined)`), returning the user to the prescreen. "Retry" falsely implied it would automatically re-attempt joining the call. A true retry would require threading the previous `CallPreferences` into this component (via props or a Jotai atom) — a non-trivial change.
|
||||||
|
- **Root Cause:** Two identically-wired buttons with misleading labels; the simpler recovery path is a single honest label.
|
||||||
|
- **Fix Applied:** Removed the "Retry" button. Renamed "Leave" → "Back". One button, one clear action: returns to the prescreen where the user can manually click Join again to retry. Updated the code comment to match.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 PWA, Service Worker & Notifications Audit (Wave 2)
|
||||||
|
|
||||||
|
> Scope: `src/sw.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `vite.config.js`, `public/manifest.json`, `src/app/utils/callSounds.ts`, `src/app/hooks/useCallJoinLeaveSounds.ts`.
|
||||||
|
> Numbers N105–N109. Items already open (asset caching, `manifest: false`, `new Notification()` vs `showNotification()`) are NOT re-listed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N105 — Missing SW `notificationclick` handler: notification clicks broken when tab is closed**
|
||||||
|
- **File:** `src/sw.ts` (handler entirely absent); `src/app/pages/client/ClientNonUIFeatures.tsx`, lines 151–155 (`InviteNotifications`) and 277–284 (`MessageNotifications`)
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** All notification click handling is wired via `noti.onclick` in the main thread (`noti.onclick = () => { navigate(...); noti.close(); }`). This callback only fires while the originating tab is open and its JavaScript is running. When the browser has no open tabs for the app (or the tab is suspended/backgrounded), clicking an OS notification does nothing — there is no SW `notificationclick` handler to focus an existing window or open a new one and navigate to the correct room.
|
||||||
|
- **Root Cause:** Notifications were built entirely in the main thread without a corresponding SW `notificationclick` event listener. The SW is registered and active but has zero notification-lifecycle handlers.
|
||||||
|
- **Fix:** Add a `notificationclick` handler to `src/sw.ts` that calls `event.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => { const win = list[0]; if (win) return win.focus(); return clients.openWindow(event.notification.data?.url ?? '/'); }))`. Pass the target room URL via `data: { url: roomPath }` in the `Notification` constructor so the SW can navigate correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N106 — Decrypted E2EE message plaintext leaked to OS notification center**
|
||||||
|
- **File:** `src/app/pages/client/ClientNonUIFeatures.tsx`, line 343
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** The `MessageNotifications` component passes `mEvent.getContent().body` directly as the notification body: `body: (mEvent.getContent().body as string | undefined) ?? ''`. By the time `RoomEvent.Timeline` fires, `matrix-js-sdk` has already decrypted the event in memory. The fully decrypted plaintext is then handed to `new window.Notification()`, which stores it in the OS notification center. This plaintext is visible on the device lock screen (if notification previews are enabled), in the OS notification history, and may be read by any app with `READ_NOTIFICATIONS` permission (e.g., accessibility services, backup apps) — even when the room uses end-to-end encryption. The 120-character slice (`slice(0, 120)`) does not mitigate this.
|
||||||
|
- **Root Cause:** No distinction is made between encrypted and unencrypted rooms when constructing notification bodies. There is no check such as `mEvent.isEncrypted()` or `room.hasEncryptionStateEvent()` that would substitute a generic body.
|
||||||
|
- **Fix:** Check whether the room is encrypted before populating the body. For encrypted rooms, use a generic string (e.g., `"New encrypted message"`) as the body instead of the decrypted content. If message previews in notifications are intentionally desired by the user, gate them behind an explicit opt-in setting that warns about OS-level plaintext exposure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N107 — SW has no `push` event handler: Web Push delivery is completely broken**
|
||||||
|
- **File:** `src/sw.ts` (handler entirely absent)
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** The service worker never registers a `push` event listener. If a Matrix push gateway (e.g., Sygnal) is ever configured and sends a Web Push notification, the SW silently discards the push event — no notification is shown, no in-app routing occurs. The absence of a `push` handler means the entire background-notification path (i.e., notifications when no tab is open) is non-functional, which is one of the primary requirements for a PWA.
|
||||||
|
- **Root Cause:** The SW was written exclusively to proxy authenticated Matrix media requests. No background notification plumbing was ever added.
|
||||||
|
- **Fix:** Add a `push` event listener to `src/sw.ts` that reads the push payload (`event.data.json()`), then calls `self.registration.showNotification(title, { body, data: { url } })`. Pair with the `notificationclick` fix from N105. On the app-registration side, wire `PushManager.subscribe()` to a Matrix push gateway so the server can actually deliver pushes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N108 — No maskable icon in PWA manifest: Android adaptive icons display incorrectly**
|
||||||
|
- **File:** `public/manifest.json`, lines 12–57
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** The manifest lists nine `android-chrome-*.png` icons (36 × 36 through 512 × 512) but none include `"purpose": "maskable"`. Android 8+ adaptive icons apply a platform-defined shape mask (circle, squircle, teardrop, etc.) to PWA home-screen icons. Without a maskable-purpose icon, the OS either adds a white square background to prevent clipping or applies the mask directly to the regular icon, typically cropping the Lotus logo in a visually incorrect way.
|
||||||
|
- **Root Cause:** Icons were added from a standard Android icon set without adding a `maskable` variant. The `"purpose"` field defaults to `"any"`, which tells the OS the icon is not designed for safe-area masking.
|
||||||
|
- **Fix:** Create a variant of the Lotus icon with sufficient padding (at least 10% safe zone on all sides so the center artwork survives any clip shape) and add it as a separate manifest entry with `"purpose": "maskable"`, e.g.: `{ "src": "./res/android/android-chrome-512x512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }`. One maskable icon at 512 × 512 is sufficient; keep the existing `"any"` entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N109 — Authenticated media URLs passed to `Notification` icon/badge: OS cannot fetch them (produces 401)**
|
||||||
|
- **File:** `src/app/pages/client/ClientNonUIFeatures.tsx`, lines 333–339 and 270–273
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** When the homeserver requires authenticated media (Matrix spec v1.11+, path `/_matrix/client/v1/media/download/...`), `mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop')` returns an authenticated URL. That URL is then passed directly as `icon` and `badge` to `new window.Notification()`. The OS/browser notification subsystem fetches `icon` and `badge` URLs directly — outside the page's JavaScript context — so the service worker's `fetch` handler never fires for them (the SW only intercepts fetches with a valid `event.clientId`, which these OS-initiated fetches lack). The homeserver returns HTTP 401, and the notification shows no icon or badge.
|
||||||
|
- **Root Cause:** The SW auth-header injection is designed for page-initiated `/_matrix/client/v1/media/` fetches. It does not (and cannot) intercept fetches made by the OS notification subsystem. Room avatar URLs are passed to `Notification` without first converting them to an auth-agnostic form.
|
||||||
|
- **Fix:** Before creating a `Notification`, fetch the avatar URL in-page (via the existing authenticated fetch path where the SW can inject headers), convert the response to a Blob URL (`URL.createObjectURL(blob)`), and pass the Blob URL as `icon`/`badge`. Alternatively, skip the avatar for notifications entirely and use the static app logo (already done for invite notifications via `LogoSVG`) to avoid the authenticated-media complexity.
|
||||||
|
|
||||||
|
|
||||||
|
## 🌸 Lotus Feature Internals Audit (Wave 2)
|
||||||
|
|
||||||
|
> Deep audit of Lotus-specific hook internals, build scripts, and the avatar-decoration pipeline. All findings below are **[Claude_Found]**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N113 — `addReminder`/`removeReminder` Read-Modify-Write Race Condition**
|
||||||
|
- **File:** `src/app/hooks/useReminders.ts`, lines 52–68
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** Both `addReminder` and `removeReminder` call `readReminders(mx)` — a synchronous read from the Matrix client's local account-data cache — and then fire `setAccountData` asynchronously. If two calls overlap before either write has committed and the local cache updated (e.g. a user quickly adds two reminders, or adds one while a removal is in flight), both calls read the same stale baseline and the second write silently overwrites the first. Example: adding R1 and R2 in quick succession → both calls read `[]`, write `[R1]` and `[R2]` respectively → only R2 survives, R1 is lost.
|
||||||
|
- **Root Cause:** No optimistic locking, no serial queue, and the read source (`mx.getAccountData()`) does not reflect uncommitted in-flight writes.
|
||||||
|
- **Fix:** Use the React `reminders` state (passed as a parameter or captured in a `useRef`) as the source of truth for mutations instead of re-reading from the client cache. Alternatively, serialize writes through a promise queue so each `addReminder`/`removeReminder` awaits the previous `setAccountData` before computing the next state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N114 — `ReminderMonitor` Calls `removeReminder` Fire-and-Forget; Network Failure Silently Drops the Reminder**
|
||||||
|
- **File:** `src/app/pages/client/ClientNonUIFeatures.tsx`, lines 399, 413–414
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** Inside `ReminderMonitor.check()`, when a reminder fires the code immediately does `firedRef.current.add(key)` and then calls `removeReminder(r.eventId, r.timestamp)` without `await` and without a `.catch()` handler. If `removeReminder` fails (network error, 429 rate-limit, homeserver down), the reminder remains in account data but is permanently blocked from re-firing this session because its key is already in `firedRef`. The user's reminder is silently swallowed for the rest of the session; only a full page reload recovers it.
|
||||||
|
- **Root Cause:** The promise returned by `removeReminder` is discarded. There is no error path that rolls back `firedRef.current` or reschedules the reminder for retry.
|
||||||
|
- **Fix:** Make `check` an `async` function (or add a `.catch()` on the call), and only add to `firedRef` after `removeReminder` succeeds. On failure, omit the `firedRef` add so the reminder retries on the next poll tick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N115 — `ReminderMonitor` 30 s Poll Interval Is Reset on Every `reminders` State Change, Delaying Near-Due Reminders**
|
||||||
|
- **File:** `src/app/pages/client/ClientNonUIFeatures.tsx`, lines 394–428
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** `reminders` is listed in the `useEffect` dependency array (`}, [mx, reminders, setToast, removeReminder, mDirects]`). Every time a reminder is added, removed, or synced back from the server, React tears down the effect (clearing `setInterval`) and re-creates it, resetting the 30 s countdown from zero. A reminder due 1 s from now will not fire for up to 30 s if a reminder state change occurs 0.5 s before the due time — for instance, when the server's account-data echo arrives and updates `reminders`. In the worst case, rapid add/remove cycles can continuously defer the poll indefinitely (as long as new mutations keep arriving faster than 30 s).
|
||||||
|
- **Root Cause:** `check()` closes over `reminders`, requiring it as a dependency; but the interval itself does not need to be recreated on every reminder change — only the closure does.
|
||||||
|
- **Fix:** Store the latest `reminders` value in a `useRef` updated on each render, and read from the ref inside `check()`. Remove `reminders` from the `useEffect` dependency array. The interval is then created once per `mx`/handler change, and `check()` always sees the current snapshot via the ref.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N116 — `useCallSpeakers` Speaker Set Rebuilt From Mutation Batch Only — All Other Speaking Participants Are Dropped**
|
||||||
|
- **File:** `src/app/hooks/useCallSpeakers.ts`, lines 20–44
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** The `MutationObserver` callback builds a fresh `Set<string>` from only the tiles present in the current mutation batch, then calls `setSpeakers(s)`. If participant A has been speaking for 10 s but their tile has not mutated recently, and participant B's tile mutates for an unrelated reason (e.g. a class change), the batch contains only B's tile. Even if B is not speaking, `s` is empty and `setSpeakers(s)` replaces the entire state — A disappears from the speakers set despite still speaking. The result is a constantly-flickering or always-empty speakers indicator.
|
||||||
|
- **Root Cause:** Speaker state is derived from the delta (mutation batch) rather than the full current DOM state. Compare with `useRemoteAllMuted.syncState()` in the same file, which correctly re-scans all `[data-muted]` elements on every mutation rather than looking only at the mutated ones.
|
||||||
|
- **Fix:** Replace the per-batch iteration with a full re-scan of all observed tiles on each callback: iterate all elements in `videoContainers`, check each for the `::before` speaking indicator, and build the new `Set` from currently-speaking tiles — not just the mutated ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N117 — `useCallSpeakers` Static `querySelectorAll` NodeList Misses Video Tiles Added to EC DOM Mid-Call**
|
||||||
|
- **File:** `src/app/hooks/useCallSpeakers.ts`, lines 14–17
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** `callEmbed.document?.querySelectorAll('[data-video-fit]')` returns a static `NodeList` snapshot at the instant the `useMemo` evaluates. When a new participant joins mid-call and EC renders their video tile, that tile is not in the captured list. No `MutationObserver` is ever attached to the new tile, so the new participant can never be detected as a speaker for the remainder of the call. `callMembers` is a memo dependency and does update on join/leave, but there is a timing gap: `callMembers` may change before EC has finished rendering the new tile inside the iframe, so `querySelectorAll` at that moment still does not find the new tile.
|
||||||
|
- **Root Cause:** Observing a static snapshot of tiles does not compose with EC's dynamically-updating DOM. `useRemoteAllMuted` avoids this entirely by watching `doc.body` with `{ subtree: true, childList: true }`, which automatically picks up new tiles without re-querying.
|
||||||
|
- **Fix:** Replace the static-NodeList + per-tile-observer approach with a single body-level observer (same as `useRemoteAllMuted`), and re-scan all `[data-video-fit]` tiles on each relevant mutation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N118 — `useCallSpeakers` Relies on Three Layers of Undocumented EC Internal APIs**
|
||||||
|
- **File:** `src/app/hooks/useCallSpeakers.ts`, lines 15, 28–35
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** Speaker detection depends on three private Element Call implementation details that are not part of any stable EC API contract and can silently break on any EC version bump:
|
||||||
|
1. **`[data-video-fit]`** — selector for video tile wrapper elements (internal EC data attribute).
|
||||||
|
2. **`getComputedStyle(el, '::before').getPropertyValue('background-image') !== 'none'`** — speaking state is inferred from a `::before` pseudo-element's `background-image`. Any EC refactor of the speaking indicator (e.g. switching to a CSS class, `data-speaking` attribute, or canvas overlay) silently breaks detection with no error.
|
||||||
|
3. **`el.querySelector('[aria-label]')?.getAttribute('aria-label')`** — assumes the first child with an `aria-label` carries the Matrix user ID; EC could equally label that element with a display name or a button description.
|
||||||
|
When these internals change, `speakers` silently stays empty with no runtime error.
|
||||||
|
- **Root Cause:** There is no stable programmatic API exposed by the EC iframe for speaker state; the implementation reverse-engineers EC's internal DOM/CSS.
|
||||||
|
- **Fix:** Prefer EC's `postMessage` protocol if it exposes speaker events. At minimum, add a build-time assertion that pins the EC package version this mechanism was validated against (e.g. in `lotusDenoise` or a separate CI check), and file an upstream EC issue requesting a stable `data-speaking` attribute — which would match the pattern already used by `[data-muted]` in `useRemoteAllMuted`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N119 — `syncDecorations.mjs` Treats Network Errors the Same as 404 — CDN Outage Silently Wipes Entire Catalog**
|
||||||
|
- **File:** `scripts/syncDecorations.mjs`, lines 39–46, 56–65
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** `headCheck` catches all fetch exceptions (DNS failure, timeout, CORS error, TLS failure) and returns `{ ok: false, status: 0 }`. This is structurally identical to an HTTP 404 (`{ ok: false, status: 404 }`). The script classifies all non-ok results as "missing" and removes them from `avatarDecorations.ts`. If `drive.lotusguild.org` is temporarily unreachable when a developer runs `npm run sync:decorations`, every single decoration fails the HEAD check with `status: 0`, is marked missing, and is removed. The script writes an empty `avatarDecorations.ts`, logs "Done. Removed N entries from the catalog.", and exits 0 — permanently destroying the catalog in source control with no warning.
|
||||||
|
- **Root Cause:** The `catch` block does not distinguish transient network failures from confirmed HTTP 404 responses.
|
||||||
|
- **Fix:** Return a distinct value for network errors (e.g. `{ slug, ok: false, status: 0, networkError: true }`). Before writing the updated catalog, abort with `process.exit(1)` if any result has `networkError: true` — the CDN may be unreachable and removing all entries would be data loss. Only entries with a confirmed `status: 404` (file genuinely absent from the CDN) should be removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N120 — CDN URL Hard-Coded Separately in `syncDecorations.mjs` and `avatarDecorations.ts` — Can Drift**
|
||||||
|
- **File:** `scripts/syncDecorations.mjs`, line 24; `src/app/features/lotus/avatarDecorations.ts`, lines 1–2
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** The Nextcloud CDN base URL (including the embedded share token `bHswJ9pNKp2t26N`) is defined twice: as `const CDN` in the sync script and as `export const DECORATION_CDN` in the runtime catalog. If the CDN is migrated (new provider, new Nextcloud share, rotated token), a developer must update both files. Missing one means the sync script probes the old URL while the runtime client fetches from the new one (or vice versa), silently producing a catalog that references unreachable assets. There is no test or lint check that enforces parity.
|
||||||
|
- **Root Cause:** `syncDecorations.mjs` is a plain `.mjs` script that cannot directly `import` from a `.ts` source file at runtime, so the constant was copied instead of shared.
|
||||||
|
- **Fix:** Extract the CDN URL into a shared `.mjs` config file (e.g. `scripts/decorationConfig.mjs`) that `syncDecorations.mjs` imports directly. Have `avatarDecorations.ts` read the same value at build time (via a Vite define/import, or by making the script write the constant into `avatarDecorations.ts` rather than hardcoding it). Alternatively, add a CI step that `grep`s both files and fails if the URLs differ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N128 — `patch-folds.mjs` Emits `console.warn` Instead of `process.exit(1)` When Patch Target Is Not Found**
|
||||||
|
- **File:** `scripts/patch-folds.mjs`, lines 21–23
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** When the target string `children: src(filled)` is not found in `node_modules/folds/dist/index.js` — because folds shipped an update that renamed or restructured this code path — the script logs `Warning: folds Icon patch target not found - may need updating.` and exits with code 0. The `postinstall` npm hook considers the install successful. The production build then ships the unpatched folds, where passing a non-function as `src` to `<Icon>` causes a runtime `TypeError: src is not a function` at any call site that relies on the guard. The failure is invisible at build and install time; it manifests only when the affected UI is rendered in production.
|
||||||
|
- **Root Cause:** The mismatch branch uses `console.warn` (exit 0) rather than `process.exit(1)`, treating a broken build pre-requisite as a non-fatal advisory.
|
||||||
|
- **Fix:** Replace the `console.warn(...)` + implicit exit-0 with `console.error(...)` followed by `process.exit(1)`. This causes `npm install` (and CI) to fail loudly, forcing the developer to update the patch target string before the build can proceed. The "already applied" branch (line 15) correctly exits 0 and does not need to change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Security & Data Persistence Audit (Wave 2)
|
||||||
|
|
||||||
|
> Deep audit of five files: `src/app/state/sessions.ts`, `src/client/initMatrix.ts`, `src/app/pages/client/ClientRoot.tsx`, `src/app/state/settings.ts`, `src/app/utils/sanitize.ts`. Findings N97–N100. Items already tracked elsewhere in this file are noted as FALSE POSITIVEs below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N97 — `setFallbackSession()` stores the full Matrix access token in plaintext `localStorage` with zero mitigations**
|
||||||
|
|
||||||
|
- **File:** `src/app/state/sessions.ts`, lines 32–68
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** `setFallbackSession()` persists four credentials to plaintext `localStorage` under fixed, predictable keys with no encryption, no `httpOnly`-cookie alternative, and no `sessionStorage` (which would at least not survive a browser restart). The four keys and their threat value:
|
||||||
|
- `cinny_access_token` — the raw Matrix Bearer token; **sufficient alone** to fully impersonate the user with the homeserver: send/read messages, download E2E media, change account settings, join/leave rooms
|
||||||
|
- `cinny_device_id` — the E2E device identifier; lets an attacker narrow the cross-signing key set needed to read encrypted history
|
||||||
|
- `cinny_user_id` — the Matrix ID (`@user:server`)
|
||||||
|
- `cinny_hs_base_url` — homeserver origin
|
||||||
|
Any XSS payload executing in this origin can exfiltrate all four with four `localStorage.getItem()` calls. There is no Content-Security-Policy in the nginx/Caddy config files (existing open finding) that would limit script injection. `getFallbackSession()` (lines 49–68) also re-reads all four keys from `localStorage` on every boot — there is no in-memory cache that would allow the token to be removed from storage after the first load, so the credential window is permanent until logout.
|
||||||
|
Additionally, `setFallbackSession()` performs **four sequential, non-atomic `localStorage.setItem()` calls** (lines 38–41). If the process is killed or the browser crashes between calls 1 and 3, `cinny_access_token` will be written to storage but the session will be incomplete; `getFallbackSession()` will return `undefined` (requires all four keys), leaving a stranded, fully-valid access token in `localStorage` that is never used or cleaned up.
|
||||||
|
- **Root Cause:** The original multi-account Cinny path (now commented out) used an `atomWithLocalStorage` abstraction layer. The current single-account "fallback" path bypasses all abstraction and writes directly to raw `localStorage` with no protection.
|
||||||
|
- **Fix:** Replace the four `setItem` calls with a single atomic write: serialize all four fields as one JSON object under a single key (`cinny_session`). This eliminates the partial-write window. For the XSS-resistance problem: migrate the access token to `sessionStorage` as a minimum (does not survive browser restart, limiting the exposure window on shared devices). For stronger protection: derive a per-device encryption key via `crypto.subtle.generateKey` and store it in `IndexedDB` (which already holds E2E keys via `IndexedDBCryptoStore`); encrypt the access token before writing to `localStorage`. The OIDC token-rotation flow (short-lived access tokens, refresh-token-only persistence) is the architecturally cleanest long-term fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N98 — Normal logout (`logoutClient` / `handleLogout`) calls `window.localStorage.clear()`, permanently wiping user preferences and unsent drafts**
|
||||||
|
|
||||||
|
- **File:** `src/client/initMatrix.ts`, line 78 (`logoutClient`); `src/app/pages/client/ClientRoot.tsx`, line 133 (`handleLogout` inside `useLogoutListener`)
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** Both logout code paths call `window.localStorage.clear()`, which removes **every key** for the origin — not just the session credentials. Keys destroyed on every normal logout include:
|
||||||
|
- `settings` — theme, notification preferences, keyboard shortcuts (`pttKey`, `deafenKey`), toolbar configuration, noise-suppression mode, accessibility settings, and all other `Settings` interface fields
|
||||||
|
- `draft-msg-{roomId}` (one key per room) — unsent composer drafts for every room the user had open at logout time
|
||||||
|
- `pip-position` — saved PiP window position
|
||||||
|
- `status_msg_{userId}` / `status_expiry_{userId}` — persisted presence status message and auto-clear timestamp
|
||||||
|
- `afterLoginRedirectPath` — post-login redirect
|
||||||
|
A user who logs out and back in on the same device starts with a factory-reset app. This violates the standard expectation that app preferences persist across sessions (every comparable Matrix client and messaging app preserves preferences across logout). The `clearLoginData()` function (the explicit "wipe all data" reset path, surfaced in the UI as "Clear local data and reload") also calls `localStorage.clear()` — that usage is appropriate and expected — but `logoutClient()` / `handleLogout` should not share this behavior.
|
||||||
|
- **Root Cause:** `localStorage.clear()` was chosen as a one-line logout implementation rather than selectively removing only the four session credential keys. No distinction is made between "end the session" and "factory reset."
|
||||||
|
- **Fix:** Replace `window.localStorage.clear()` in both `logoutClient` (line 78) and `handleLogout` (line 133) with targeted removal of only the session credential keys:
|
||||||
|
```typescript
|
||||||
|
['cinny_access_token', 'cinny_device_id', 'cinny_user_id', 'cinny_hs_base_url'].forEach(k =>
|
||||||
|
window.localStorage.removeItem(k)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
Leave `settings`, draft keys, and all other preference keys intact. Reserve `window.localStorage.clear()` for the `clearLoginData()` path only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N99 — `useSyncState` callback in `ClientRoot.tsx` only handles `PREPARED`; a sync `ERROR` before first sync completion freezes the app on the loading screen with contradictory UI**
|
||||||
|
|
||||||
|
- **File:** `src/app/pages/client/ClientRoot.tsx`, lines 179–186; `src/app/hooks/useSyncState.ts`, lines 1–14
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** The `useSyncState` callback in `ClientRoot` only calls `setLoading(false)` for `state === 'PREPARED'`. The Matrix JS SDK can emit `SyncState.Error` before ever reaching `PREPARED` — for example when the device is offline at startup, the homeserver is unreachable, or the first `/sync` request returns a non-retryable server error. When this happens:
|
||||||
|
1. `loading` remains `true` (never set to `false`)
|
||||||
|
2. `<ClientRootLoading />` renders indefinitely, showing the "Heating up" spinner
|
||||||
|
3. `<SyncStatus mx={mx} />` — rendered unconditionally **above** the loading conditional at line 191 — fires its own `useSyncState` listener and shows a "Connection Lost!" red banner simultaneously
|
||||||
|
4. The user sees contradictory messages ("Connection Lost!" + "Heating up") with no recovery action visible from the loading screen. The only escape is the `ClientRootOptions` ⋮ menu (lines 192–125), which is a small icon button with Logout / Clear Cache — not discoverable without prior knowledge.
|
||||||
|
Note: This is **distinct from the existing race-condition finding** (which concerns the listener missing PREPARED because it registers too late). Here the listener registers correctly and fires, but it fires with `ERROR` instead of `PREPARED`, and the callback ignores it.
|
||||||
|
- **Root Cause:** The `useSyncState` callback is designed around a single happy-path terminal state (`PREPARED`). `SyncStatus` handles error states for the **post-PREPARED** reconnection UX, but does not replace the loading screen.
|
||||||
|
- **Fix:** Extend the `useSyncState` callback to handle `SyncState.Error` and `SyncState.Stopped` by setting a separate `syncError` state, then render a dedicated error splash (parallel to the existing `loadState`/`startState` error dialog at lines 193–238) that shows a descriptive message and a Retry button that calls `startMatrix(mx)`:
|
||||||
|
```tsx
|
||||||
|
useSyncState(mx, useCallback((state) => {
|
||||||
|
if (state === 'PREPARED') setLoading(false);
|
||||||
|
else if (state === 'ERROR' || state === 'STOPPED') setSyncError(true);
|
||||||
|
}, []));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N100 — `sanitize.ts` allows unrestricted CSS class names on `<pre>` elements; `allowedClasses` not configured for `pre`**
|
||||||
|
|
||||||
|
- **File:** `src/app/utils/sanitize.ts`, lines 69 and 156–163
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** `permittedTagToAttributes` includes `pre: ['data-md', 'class']` (line 69), permitting the `class` attribute on `<pre>` elements in Matrix `formatted_body` messages. However, `allowedClasses` (lines 156–163) restricts class names only for `code` elements (`language-*` patterns for Prism syntax highlighting). Per `sanitize-html` documentation: when `class` is listed in `allowedAttributes` for a tag but that tag has no entry in `allowedClasses`, **all class names are permitted** on that element. This allows a remote message sender to inject arbitrary class names onto `<pre>` blocks — e.g. `<pre class="some-cinny-class admin-notice">` — which could activate site-specific or folds-generated CSS rules keyed to those class names, override visual styling, or trigger `::before`/`::after` pseudo-element content defined in any loaded stylesheet. By contrast, the `code` element (which is typically the inner child of `<pre>`) is correctly restricted to `language-*` only, making the `pre` oversight inconsistent.
|
||||||
|
- **Root Cause:** When Prism syntax-highlighting class support was added for `<code>`, the `<pre>` element was given a `class` passthrough (to allow `<pre class="language-python">` wrappers) but no corresponding `allowedClasses` whitelist entry was added for it.
|
||||||
|
- **Fix:** Add `pre` to `allowedClasses` with the same `language-*` pattern already used for `code`:
|
||||||
|
```typescript
|
||||||
|
allowedClasses: {
|
||||||
|
code: ['language-*'],
|
||||||
|
pre: ['language-*'],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Wave 2 Security Audit — FALSE POSITIVES (re-examined, correctly handled)
|
||||||
|
|
||||||
|
- **`setMaxListeners(150)` in `initMatrix.ts`** — already tracked as OPEN in the Infrastructure table above. Not duplicated here.
|
||||||
|
- **`useSyncState` PREPARED race condition** — already tracked as OPEN in the Architectural Resilience table. N99 above is the distinct ERROR-before-PREPARED case, not a duplicate of the existing race-condition entry.
|
||||||
|
- **`pushSessionToSW()` called without `await` in `logoutClient()`** — `pushSessionToSW` is synchronous; `postMessage` is fire-and-forget by design and requires no `await`. FALSE POSITIVE.
|
||||||
|
- **`mx.initRustCrypto()` uncaught rejection in `initMatrix.ts` line 48** — the rejection propagates out of the async `initClient()` function and is caught by `useAsyncCallback` in `ClientRoot.tsx`, surfaced as `loadState.status === AsyncStatus.Error` with an error dialog and Retry button. FALSE POSITIVE.
|
||||||
|
- **`style` attribute on `<font>` and `<span>` in `sanitize.ts`** — `transformFontTag` and `transformSpanTag` overwrite `style` entirely: the spread `...attribs` is followed by an explicit `style:` key that replaces any attacker-supplied value with a computed-safe string derived from regex-validated `data-mx-color`/`data-mx-bg-color` only. `allowedStyles` then further validates the result. FALSE POSITIVE.
|
||||||
|
- **`href` allowing `javascript:` URLs** — `allowedSchemes: ['https', 'http', 'ftp', 'mailto', 'magnet']` plus `allowProtocolRelative: false` and `allowedSchemesAppliedToAttributes: ['href']` correctly block `javascript:`. FALSE POSITIVE.
|
||||||
|
- **`<img src="...">` without scheme checking** — `transformImgTag` converts all non-`mxc://` `src` values to `<a href="...">`, at which point the href is scheme-checked; `javascript:` and `data:` are both rejected. `mxc://` images are correctly passed through. FALSE POSITIVE.
|
||||||
|
- **`mentionHighlightColor` missing whitelist in `getSettings()`** — the value is consumed only via `document.documentElement.style.setProperty()` (CSS custom property), which cannot execute JavaScript regardless of value. FALSE POSITIVE.
|
||||||
|
- **`dangerouslySetInnerHTML` / `innerHTML` XSS chain via `data-mx-maths`** — a full codebase grep confirms zero uses of `dangerouslySetInnerHTML` or direct `innerHTML` assignment in `src/app/`. Sanitized HTML is rendered via `html-react-parser`'s `parse()`, which produces React elements via `createElement`, not raw HTML injection. FALSE POSITIVE.
|
||||||
|
- **`removeFallbackSession()` key-ordering issue** — `removeFallbackSession` is dead code in all active paths; it is only referenced in the commented-out multi-account migration block within `sessions.ts` itself. Active logout goes through `window.localStorage.clear()`. FALSE POSITIVE for the ordering concern; the broader `localStorage.clear()` behavior is tracked in N98.
|
||||||
|
- **Settings atom contains sensitive data** — the `Settings` interface stores only UI preferences (theme, notification flags, keyboard shortcuts, toolbar config). No access tokens, cryptographic keys, or private message content are stored in the `settings` localStorage key. FALSE POSITIVE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Call System & Noise Suppression Audit (Wave 2)
|
||||||
|
|
||||||
|
> Scope: `src/app/plugins/call/CallControl.ts`, `src/app/plugins/call/CallEmbed.ts`, `src/app/hooks/useCallSpeakers.ts`, `src/app/components/CallEmbedProvider.tsx`, `build/lotus-denoise.js`, `vite.config.js`.
|
||||||
|
> Numbers N122–N127. N116–N118 already document `useCallSpeakers` speaker-detection fragility; findings below cover distinct issues not captured there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N122 — `setMediaState` promise hangs permanently when EC omits a `DeviceMute` state-echo**
|
||||||
|
|
||||||
|
- **File:** `src/app/plugins/call/CallControl.ts`, lines 185–193
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** The Promise returned by `setMediaState` can never resolve if EC does not emit a `DeviceMute` `fromWidget` state-update event in response to the host's mute command. After `await this.call.transport.send(ElementWidgetActions.DeviceMute, state)` resolves (EC has ACK'd the command), the function creates an inner Promise whose resolver is stored in `this.mediaStatePromiseResolver` — a field consumed only by `onMediaState` or by the NEXT call to `setMediaState`. If EC ACKs the command but does not subsequently fire a `DeviceMute` state-report back (the most likely trigger: the requested state already matches EC's current state and EC elides the echo, or EC is shutting down before broadcasting), the inner Promise is stranded forever. `applyState()` awaits this Promise at line 118 (`await this.setMediaState({...})`); the subsequent `this.setSound(this.sound)` and `this.emitStateUpdate()` calls at lines 122–123 are never reached. Because `forceState` (which calls `applyState`) is invoked fire-and-forget from `onCallJoined`, the practical result is that the initial deafen state and the first `StateUpdate` event emission are silently skipped on every call join when EC batches or omits the echo.
|
||||||
|
- **Root Cause:** The single-slot `mediaStatePromiseResolver` architecture gates the mute operation's completion on an EC-originated event that is not guaranteed to fire for every host-initiated command.
|
||||||
|
- **Fix:** Resolve the inner Promise directly when `transport.send()` returns — EC having replied already confirms the command was received and applied. Drop the `new Promise(...)` wrapper and return `data` immediately after `await transport.send()`. Keep `onMediaState` as the authoritative state-sync path (updating `this.state` and calling `emitStateUpdate`) but remove the `mediaStatePromiseResolver` field and its invocation from that handler entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N123 — `focusCameraParticipant` tile click silently drops when EC spotlight layout isn't ready in 2 animation frames**
|
||||||
|
|
||||||
|
- **File:** `src/app/plugins/call/CallControl.ts`, lines 396–401
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** After clicking `spotlightButton` to enter spotlight mode, `focusCameraParticipant` waits exactly two `requestAnimationFrame` callbacks (~32 ms at 60 fps) before querying the EC document for the target tile. If EC's React tree has not committed new spotlight tile nodes within that window — which occurs regularly on slower devices, during animated layout transitions, or when EC is simultaneously decoding video streams — `findTile()` returns `undefined` and the focus action is silently dropped. The user sees EC switch to spotlight mode but the requested participant is never pinned. There is no retry, no surfaced error, and the only signal is a DEV-only `console.warn`.
|
||||||
|
- **Root Cause:** The double-rAF heuristic is a timing approximation, not a DOM-readiness guarantee. EC's React reconciliation and layout commit can exceed 32 ms.
|
||||||
|
- **Fix:** Replace the double-rAF with a `MutationObserver` on `this.document.body` (childList + subtree) that waits for a `[data-testid="videoTile"]` element to appear, then calls `applyFocus()` and disconnects. Add a 600 ms hard-timeout fallback that calls `applyFocus()` and disconnects regardless, so the click is always attempted at least once even when tile rendering is slow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N124 — Denoise shim `cleanup()` leaks the noise gate `AudioWorkletNode` processor thread when `USE_GATE=true`**
|
||||||
|
|
||||||
|
- **File:** `build/lotus-denoise.js`, lines 235–244 and 267–281
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** When the noise gate is active (`USE_GATE=true`), `processStream` creates a `gateNode` (`AudioWorkletNode`) and wires it as `source → gateNode → mlNode → dest`. The `cleanup()` closure inside the inner `.then()` callback calls `source.disconnect()` and `mlNode.disconnect()` but never `gateNode.disconnect()`. `gateNode` is declared with `var` inside the outer `if (USE_GATE)` block — hoisted via `var` to the enclosing `.then()` function scope — and IS accessible in the inner callback via closure, but is simply absent from `cleanup()`. The AudioWorklet processor thread for the orphaned gate node continues running on the audio rendering thread until the EC iframe is destroyed. If EC's LiveKit client calls `getUserMedia` more than once within a session (e.g., a device switch mid-call), a new orphaned gate processor accumulates on each call, each consuming audio-thread CPU indefinitely.
|
||||||
|
- **Root Cause:** `gateNode` is in closure scope but missing from the `cleanup()` body.
|
||||||
|
- **Fix:** Add to `cleanup()`:
|
||||||
|
```javascript
|
||||||
|
try { if (gateNode) gateNode.disconnect(); } catch (e) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N125 — Denoise shim `postMessage` uses wildcard `'*'` target origin**
|
||||||
|
|
||||||
|
- **File:** `build/lotus-denoise.js`, lines 294–306 and 317–320
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** Both `lotus-denoise-status` `postMessage` calls use `'*'` as the `targetOrigin` argument, broadcasting the message to any frame that currently contains the EC iframe as a child regardless of its origin. If the Lotus EC widget URL is ever embedded by a third-party page (possible since it is same-origin and publicly routable), that page receives the denoise status payload (`{ type, active, model, nativeNS, gate }`). Using `'*'` violates the MDN/W3C `postMessage` security recommendation.
|
||||||
|
- **Root Cause:** The shim has no reference to the parent origin at the point these calls are made. The `parentUrl` widget URL parameter — already present in `window.location.search` and parsed into `params` at line 27 — provides the correct target origin.
|
||||||
|
- **Fix:** Extract `parentUrl` from `params` and use it as the target origin:
|
||||||
|
```javascript
|
||||||
|
var targetOrigin = params.get('parentUrl') || window.location.origin;
|
||||||
|
window.parent.postMessage({ ... }, targetOrigin);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N126 — PiP position restored from `localStorage` without type validation, silently producing `NaN` coordinates on corrupt data**
|
||||||
|
|
||||||
|
- **File:** `src/app/components/CallEmbedProvider.tsx`, line 723
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** The saved PiP position is cast without runtime validation:
|
||||||
|
```typescript
|
||||||
|
const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : null;
|
||||||
|
```
|
||||||
|
If `localStorage['pip-position']` contains a corrupted value (from a prior bug, a different app version's format, or a developer edit), `JSON.parse` may succeed but return an object where `.left`/`.top` are `undefined`, strings, or non-finite numbers. `Math.max(0, Math.min(undefined, window.innerWidth - 280))` evaluates to `NaN`, yielding `el.style.left = 'NaN px'` — an invalid CSS value the browser silently ignores — and the PiP appears at an undefined position with no error surfaced.
|
||||||
|
- **Root Cause:** TypeScript `as` casts do not validate at runtime; the parsed value's shape is never checked.
|
||||||
|
- **Fix:** Add an explicit shape-and-finite guard:
|
||||||
|
```typescript
|
||||||
|
const raw = saved ? (() => { try { return JSON.parse(saved); } catch { return null; } })() : null;
|
||||||
|
const savedPos =
|
||||||
|
raw != null &&
|
||||||
|
typeof raw.left === 'number' && isFinite(raw.left) &&
|
||||||
|
typeof raw.top === 'number' && isFinite(raw.top)
|
||||||
|
? (raw as { left: number; top: number })
|
||||||
|
: null;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**N127 — ML noise suppression shim is never injected in `vite dev` mode; the ML feature is silently inactive during development**
|
||||||
|
|
||||||
|
- **File:** `vite.config.js`, `lotusDenoise` plugin, lines 72–193
|
||||||
|
- **Status:** **OPEN** [Claude_Found]
|
||||||
|
- **Issue:** The `lotusDenoise` plugin only defines a `closeBundle` Rollup/Vite build hook, which executes only during `vite build`. In `vite dev`, `closeBundle` is never invoked: `lotus-denoise.js` is never copied, and EC's `index.html` is never modified to include the shim `<script>` tag. EC loads its original entry from `node_modules/@element-hq/element-call-embedded/dist/` without modification. When a developer enables ML noise suppression in Settings and joins a call, the `lotusDenoise=ml` URL parameter is correctly appended to the EC widget URL, but no shim intercepts `getUserMedia` inside the iframe and the mic is never routed through the ML pipeline. No error, warning, or status indicator surfaces this discrepancy; the `lotus-denoise-status` postMessage the shim would send never arrives, leaving any status display silently blank.
|
||||||
|
- **Root Cause:** The plugin has no `configureServer` hook for the dev-server path; `viteStaticCopy` serves the original EC assets from `node_modules` without modification in dev mode.
|
||||||
|
- **Fix:** Add a `configureServer` hook to `lotusDenoise` that installs two express middlewares: one serving `build/lotus-denoise.js` at `/public/element-call/lotus-denoise.js`, and one intercepting GET requests for `/public/element-call/index.html` that reads the original from `node_modules/@element-hq/element-call-embedded/dist/index.html`, injects the `<script src="./lotus-denoise.js"></script>` tag (mirroring the production replacement regex), and returns the patched HTML. This makes dev and production consistent for ML noise suppression testing.
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# 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. Open bugs flagged by audit — reproduction needed before fix
|
||||||
|
|
||||||
|
### L1. AFK auto-mute keeps the OS microphone indicator lit (N95) — 👥 live call
|
||||||
|
|
||||||
|
**Context:** `useAfkAutoMute.ts` calls `getUserMedia({ audio: true })` independently of Element Call's managed stream. When you mute in the Lotus UI, the LiveKit mic inside EC's iframe is muted via the widget API — but the separate `MediaStream` held by the AFK hook keeps its tracks running. The OS-level recording indicator (green dot on macOS, mic icon on Windows/Linux) therefore stays lit while your mic is muted.
|
||||||
|
|
||||||
|
**To reproduce:**
|
||||||
|
|
||||||
|
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
|
||||||
|
2. Manually **mute your mic** using the call controls.
|
||||||
|
3. Check the **OS recording indicator** (macOS: green dot top-right of menu bar; Windows: mic icon in taskbar).
|
||||||
|
|
||||||
|
**Expected (current broken behavior):** the OS recording indicator stays on even though your Lotus mic shows muted.
|
||||||
|
**Expected after fix:** the indicator should clear when you mute and re-appear when you unmute.
|
||||||
|
|
||||||
|
> **Note:** This is an **open bug** — no fix has been applied yet. Reproduce and confirm the symptom first. The fix involves stopping `MediaStream` tracks on mute and re-requesting `getUserMedia` on unmute (see LOTUS_BUGS.md N95 for full details). Once fixed, re-run this check to verify the indicator clears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -543,6 +543,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 +593,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,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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,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 { startRingtone } from '../utils/ringtones';
|
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';
|
||||||
@@ -246,6 +246,154 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type IncomingCallListenerProps = {
|
type IncomingCallListenerProps = {
|
||||||
callEmbed?: CallEmbed;
|
callEmbed?: CallEmbed;
|
||||||
joined?: boolean;
|
joined?: boolean;
|
||||||
@@ -371,10 +519,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}
|
||||||
@@ -382,7 +545,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 }) {
|
||||||
|
|||||||
@@ -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,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);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -87,7 +87,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 +243,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 +262,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
|
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
)}
|
||||||
{shareConfirm && (
|
{shareConfirm && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1667,7 +1667,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';
|
||||||
@@ -1727,7 +1734,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
|
||||||
|
|||||||
@@ -32,13 +32,14 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
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) {
|
||||||
@@ -58,12 +59,14 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
const cardStyle: CSSProperties = {
|
const cardStyle: CSSProperties = {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
background: 'var(--lt-bg-card)',
|
background: 'var(--lt-bg-card)',
|
||||||
border: '1px solid var(--lt-border-color)',
|
border: toast.sticky
|
||||||
|
? '1px solid var(--lt-accent-cyan-border)'
|
||||||
|
: '1px solid var(--lt-border-color)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '12px 14px',
|
padding: '12px 14px',
|
||||||
minWidth: '280px',
|
minWidth: '280px',
|
||||||
maxWidth: '340px',
|
maxWidth: '340px',
|
||||||
boxShadow: 'var(--lt-box-glow-orange)',
|
boxShadow: toast.sticky ? 'var(--lt-box-glow-cyan)' : 'var(--lt-box-glow-orange)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
animation: 'lotusToastIn 0.2s ease-out both',
|
animation: 'lotusToastIn 0.2s ease-out both',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
@@ -100,7 +103,7 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nameStyle: CSSProperties = {
|
const nameStyle: CSSProperties = {
|
||||||
color: 'var(--lt-accent-orange)',
|
color: toast.sticky ? 'var(--lt-accent-cyan)' : 'var(--lt-accent-orange)',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -127,8 +130,9 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
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 = {
|
||||||
|
|||||||
@@ -459,11 +459,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]);
|
||||||
|
|
||||||
|
|||||||
@@ -356,20 +356,48 @@ 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. Toggle spotlight,
|
||||||
|
// then click on a later frame once the spotlight tiles have mounted.
|
||||||
|
this.spotlightButton?.click();
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(applyFocus));
|
||||||
}
|
}
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -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[]>([]);
|
||||||
|
|||||||
+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);
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const PHRASES: Record<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const playPhrase = (style: SynthStyle, volume: number): void => {
|
const playPhrase = (style: SynthStyle, volume: number, destination: AudioNode): void => {
|
||||||
const ctx = getCtx();
|
const ctx = getCtx();
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const { type, gain: peak, notes } = PHRASES[style];
|
const { type, gain: peak, notes } = PHRASES[style];
|
||||||
@@ -96,7 +96,7 @@ const playPhrase = (style: SynthStyle, volume: number): void => {
|
|||||||
gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015);
|
gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015);
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
||||||
osc.connect(gain);
|
osc.connect(gain);
|
||||||
gain.connect(ctx.destination);
|
gain.connect(destination);
|
||||||
osc.start(start);
|
osc.start(start);
|
||||||
osc.stop(start + dur + 0.02);
|
osc.stop(start + dur + 0.02);
|
||||||
});
|
});
|
||||||
@@ -121,11 +121,41 @@ const startClassic = (volume: number, loop: boolean): (() => void) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => {
|
const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => {
|
||||||
playPhrase(style, volume);
|
const ctx = getCtx();
|
||||||
if (!loop) return () => undefined;
|
if (!ctx) return () => undefined;
|
||||||
const period = PHRASES[style].period * 1000;
|
// All notes route through a per-session master gain so stop() can silence
|
||||||
const id = window.setInterval(() => playPhrase(style, volume), period);
|
// everything instantly — including notes already scheduled slightly in the
|
||||||
return () => window.clearInterval(id);
|
// 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);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user