Compare commits
13 Commits
c0f9867218
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| d39aef0aac | |||
| 9f533b1077 | |||
| fdaba40ba9 | |||
| caf6318a5d | |||
| 23649d85b0 | |||
| c67aed01dc | |||
| 66cc51d6d0 | |||
| 4a87588435 | |||
| c0fd372529 | |||
| 203568c967 | |||
| 0394fce929 | |||
| d2946c00ce | |||
| b7e1f89c1d |
+99
-96
@@ -23,11 +23,12 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
|||||||
|
|
||||||
### 1. No Camera Focus During Screenshare
|
### 1. No Camera Focus During Screenshare
|
||||||
|
|
||||||
- **File:** `cinny/src/app/features/call/CallControls.tsx`
|
- **File:** `cinny/src/app/plugins/call/CallControl.ts`, `cinny/src/app/features/call-status/MemberGlance.tsx`
|
||||||
- **Status:** **OPEN**
|
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with an active screenshare + a participant on camera
|
||||||
- **Issue:** Automatic screenshare spotlighting forces primary display override, preventing users from manually focusing on camera feeds.
|
- **Issue:** Automatic screenshare spotlighting forces primary display override, preventing users from manually focusing on camera feeds.
|
||||||
- **Root Cause:** Current spotlighting logic prioritizes active screenshare streams over manual participant selections, effectively ignoring or overriding user-initiated focus states.
|
- **Root Cause:** Before this feature there was no UI path to manually pick a camera to focus, so EC's auto-spotlight (which prioritizes an active screenshare) always won.
|
||||||
- **Proposed Fix:** Introduce a manual 'Focus' state that takes precedence over automatic screenshare spotlighting, implemented via a toggle/click UI on participant tiles. Update the video renderer to respect this manual override.
|
- **Fix Applied:** `CallControl.focusCameraParticipant(userId)` switches EC to spotlight mode and clicks that participant's `[data-testid="videoTile"]` inside the EC iframe — in Element Call, clicking a tile in spotlight **pins** it, so the user's explicit selection takes precedence over the auto-pinned screenshare. Exposed via a "Focus camera" item in the `MemberGlance` participant menu (avatar → menu). Falls back to a plain spotlight toggle if the tile isn't rendered (e.g. camera off).
|
||||||
|
- **Architectural note:** EC owns the grid/spotlight renderer inside its iframe; our control is DOM-level tile clicks. The pin persists until changed, so a one-shot focus is sufficient. A continuously-enforced "sticky" focus that re-pins on every EC spotlight change was deliberately **not** built — it would require fighting EC's internal state on each mutation and risks flicker.
|
||||||
|
|
||||||
### 2. Chat Background Animation Flickering
|
### 2. Chat Background Animation Flickering
|
||||||
|
|
||||||
@@ -39,20 +40,22 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
|||||||
|
|
||||||
### 3. Avatar Decorations in Element Call
|
### 3. Avatar Decorations in Element Call
|
||||||
|
|
||||||
- **File:** `cinny/src/app/components/avatar-decoration/AvatarDecoration.tsx`
|
- **File:** `cinny/src/app/features/call/CallMemberCard.tsx`
|
||||||
- **Status:** **OPEN**
|
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with a participant who has a decoration set
|
||||||
- **Issue:** Avatar decorations are failing to render within the call/room interface member lists.
|
- **Issue:** Avatar decorations are failing to render within the call/room interface member lists.
|
||||||
- **Root Cause:** Likely a mismatch between the expected `member` object structure required by the `AvatarDecoration` component and the data actually provided by the call/room UI components. Matrix event data for decorations might not be propagating correctly to these UI member objects.
|
- **Root Cause:** Member lists and the people drawer already wrapped avatars in `<AvatarDecoration userId={...}>`, but the call participant tile (`CallMemberCard`) rendered a bare `<UserAvatar>` with no decoration wrapper — so decorations were absent specifically on call tiles. (Note: avatars rendered _inside_ the Element Call iframe are EC-rendered and out of our control; this fix covers our own participant roster / prescreen.)
|
||||||
- **Proposed Fix:** Analyze the data propagation chain from Matrix events to the member object in `cinny/src/app/components/call` and `room`, ensuring that decoration-related properties are correctly mapped and passed to the `AvatarDecoration` component.
|
- **Fix Applied:** Wrapped the call-tile avatar in `<AvatarDecoration userId={userId}>` (commit `0394fce9`), matching the member-list pattern.
|
||||||
|
|
||||||
### 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 control added. Remaining: ringtone selection, suppression during active calls.
|
- **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:** Added `ringtoneVolume` setting (0–100, default 70). `IncomingCall` reads this setting and applies `audioElement.volume = ringtoneVolume / 100` before `play()`. Slider added to Settings → General → Calls section.
|
- **Fix Applied:**
|
||||||
- **Remaining:** (a) Ringtone selection (still hardcoded to `call.ogg`); (b) Suppression during active calls — not investigated.
|
- `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`.
|
||||||
|
- **(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
|
||||||
|
|
||||||
@@ -131,19 +134,19 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
|||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
| Category | Issue Description | File Path | Status |
|
||||||
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| State Sync | Fire-and-forget network call to set offline presence during `pagehide` event may not complete reliably, potentially causing UI drift in presence status. | `cinny/src/app/hooks/usePresenceUpdater.ts` | OPEN |
|
| State Sync | Fire-and-forget network call to set offline presence during `pagehide` event may not complete reliably, potentially causing UI drift in presence status. | `cinny/src/app/hooks/usePresenceUpdater.ts` | FIXED (`d2946c00`) — unload path now uses `fetch({ keepalive: true })` so the request survives page teardown (`sendBeacon` was unusable here: it can't set the auth header). |
|
||||||
| State Sync | Fire-and-forget network call `setPresence().catch(...)` suppresses errors, meaning the app may falsely assume presence update success. | `cinny/src/app/hooks/usePresenceUpdater.ts` | OPEN |
|
| State Sync | Fire-and-forget network call `setPresence().catch(...)` suppresses errors, meaning the app may falsely assume presence update success. | `cinny/src/app/hooks/usePresenceUpdater.ts` | FIXED (`d2946c00`) — errors are now surfaced via `warnPresenceFailure` (redacted logging) instead of being silently swallowed. |
|
||||||
| Memory Leak | Decrypted Media Memory Leak (Gallery & Lightbox) due to missing virtualization and blob revocation. | `cinny/src/app/features/room/MediaGallery.tsx` | PARTIALLY FIXED ⚠️ UNTESTED — Blob revocation was already correct; added `enabled` param to `useDecryptedMediaUrl` and `useNearViewport(300px)` to each `GalleryTile` to gate decryption until near-viewport, reducing burst on pagination. True virtualization (windowing) deferred — requires significant refactor. |
|
| Memory Leak | Decrypted Media Memory Leak (Gallery & Lightbox) due to missing virtualization and blob revocation. | `cinny/src/app/features/room/MediaGallery.tsx` | PARTIALLY FIXED ⚠️ UNTESTED — Blob revocation was already correct; added `enabled` param to `useDecryptedMediaUrl` and `useNearViewport(300px)` to each `GalleryTile` to gate decryption until near-viewport, reducing burst on pagination. True virtualization (windowing) deferred — requires significant refactor. |
|
||||||
| Data Persistence | Scheduled Messages are ephemeral (lost on refresh) due to fragile `localStorage` parsing. | `cinny/src/app/state/scheduledMessages.ts` | FIXED — now uses `atomWithStorage` + `createJSONStorage` (Jotai's built-in persistence with error-safe JSON parsing) |
|
| Data Persistence | Scheduled Messages are ephemeral (lost on refresh) due to fragile `localStorage` parsing. | `cinny/src/app/state/scheduledMessages.ts` | FIXED — now uses `atomWithStorage` + `createJSONStorage` (Jotai's built-in persistence with error-safe JSON parsing) |
|
||||||
| Memory Leak | Potential memory leak due to uncleaned `handleMouseMove` listener in `usePan`. | `cinny/src/app/hooks/usePan.ts` | FALSE POSITIVE — `usePan` already uses `attachedRef` to track listeners and cleans them up in an unmount `useEffect`. No change needed. |
|
| Memory Leak | Potential memory leak due to uncleaned `handleMouseMove` listener in `usePan`. | `cinny/src/app/hooks/usePan.ts` | FALSE POSITIVE — `usePan` already uses `attachedRef` to track listeners and cleans them up in an unmount `useEffect`. No change needed. |
|
||||||
| Asset Optimization | Large unoptimized media asset (213KB) found in `public/res`. | `public/res/Lotus.png` | OPEN |
|
| Asset Optimization | Large unoptimized media asset (213KB) found in `public/res`. | `public/res/Lotus.png` | OPEN |
|
||||||
| Data Persistence | Non-atomic `localStorage` updates in session management can lead to inconsistent state. | `cinny/src/app/state/sessions.ts` | OPEN |
|
| Data Persistence | Non-atomic `localStorage` updates in session management can lead to inconsistent state. | `cinny/src/app/state/sessions.ts` | OPEN |
|
||||||
| Data Persistence | Lack of cross-tab synchronization for `localStorage` updates in session management risks race conditions. | `cinny/src/app/state/sessions.ts` | OPEN |
|
| Data Persistence | Lack of cross-tab synchronization for `localStorage` updates in session management risks race conditions. | `cinny/src/app/state/sessions.ts` | OPEN |
|
||||||
| Network Resilience | `uploadContent` lacks retry logic, failing immediately upon network error. | `cinny/src/app/utils/matrix.ts` | OPEN |
|
| Network Resilience | `uploadContent` lacks retry logic, failing immediately upon network error. | `cinny/src/app/utils/matrix.ts` | FIXED (`d2946c00`) — bounded retry (`UPLOAD_MAX_RETRY_COUNT=3`) gated by `isRetryableUploadError` (transient/network/5xx/429 only, not 4xx), reusing the `rateLimitedActions` capped-exponential backoff. |
|
||||||
| Network Resilience | `rateLimitedActions` uses basic retry logic without exponential backoff, which may exacerbate 429 issues. | `cinny/src/app/utils/matrix.ts` | FIXED — fallback delay now uses capped exponential backoff (`min(1000 * 2^retryCount, 30_000)ms`) when server doesn't send `Retry-After`; server header still takes precedence via `getRetryAfterMs()`. |
|
| Network Resilience | `rateLimitedActions` uses basic retry logic without exponential backoff, which may exacerbate 429 issues. | `cinny/src/app/utils/matrix.ts` | FIXED — fallback delay now uses capped exponential backoff (`min(1000 * 2^retryCount, 30_000)ms`) when server doesn't send `Retry-After`; server header still takes precedence via `getRetryAfterMs()`. |
|
||||||
| Matrix Event Robustness | `useMatrixEventRenderer` handles unknown events gracefully by returning `null`, which may hide potentially important unrendered data. | `cinny/src/app/hooks/useMatrixEventRenderer.ts` | OPEN |
|
| Matrix Event Robustness | `useMatrixEventRenderer` handles unknown events gracefully by returning `null`, which may hide potentially important unrendered data. | `cinny/src/app/hooks/useMatrixEventRenderer.ts` | FALSE POSITIVE — returning `null` for unrendered types is the intended contract. Callers opt into rendering unknowns via the `renderStateEvent` / `renderEvent` fallback params; `null` only results when the caller deliberately supplies no fallback. No change warranted. |
|
||||||
| Data Contract | `MatrixError` instantiation with `UploadResponse` might be brittle. | `cinny/src/app/utils/matrix.ts` | OPEN |
|
| Data Contract | `MatrixError` instantiation with `UploadResponse` might be brittle. | `cinny/src/app/utils/matrix.ts` | FIXED (`d2946c00`) — replaced the brittle direct construction with `matrixErrorFromUploadResponse` / `matrixErrorFromUnknown` guards that validate shape before building a `MatrixError`. |
|
||||||
| Type Safety | `addRoomIdToMDirect` uses `as any` cast for `AccountDataEvent.Direct`, bypassing type contract validation. | `cinny/src/app/utils/matrix.ts` | OPEN |
|
| Type Safety | `addRoomIdToMDirect` uses `as any` cast for `AccountDataEvent.Direct`, bypassing type contract validation. | `cinny/src/app/utils/matrix.ts` | FIXED (`d2946c00`) — `addRoomIdToMDirect` / `removeRoomIdFromMDirect` now use `EventType.Direct` + a typed `MDirectContent`, dropping the `as any` cast. |
|
||||||
| Robustness | `rateLimitedActions` relies on `MatrixError.httpStatus` which might not exist on all error variants. | `cinny/src/app/utils/matrix.ts` | FALSE POSITIVE — `MatrixError.httpStatus` is defined as `readonly httpStatus?: number` in `matrix-js-sdk/lib/http-api/errors.d.ts`. It is optional (not on all instances) but the `?.` optional chain already guards against undefined. No change needed. |
|
| Robustness | `rateLimitedActions` relies on `MatrixError.httpStatus` which might not exist on all error variants. | `cinny/src/app/utils/matrix.ts` | FALSE POSITIVE — `MatrixError.httpStatus` is defined as `readonly httpStatus?: number` in `matrix-js-sdk/lib/http-api/errors.d.ts`. It is optional (not on all instances) but the `?.` optional chain already guards against undefined. No change needed. |
|
||||||
| Type Contract | Custom types in `cinny/src/types/matrix` mirror SDK types instead of using them, risking drift and contract mismatches. | `cinny/src/types/matrix/` | OPEN |
|
| Type Contract | Custom types in `cinny/src/types/matrix` mirror SDK types instead of using them, risking drift and contract mismatches. | `cinny/src/types/matrix/` | OPEN |
|
||||||
|
|
||||||
@@ -170,88 +173,88 @@ 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` | OPEN |
|
||||||
| 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` | OPEN |
|
||||||
| 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` | OPEN |
|
||||||
| 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` | OPEN |
|
||||||
| 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` | OPEN |
|
||||||
| 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` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
||||||
| Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
| Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
|
||||||
| 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` | OPEN |
|
||||||
| 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` | OPEN |
|
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/features/lobby/Lobby.tsx` | FALSE POSITIVE — `Lobby` already routes its render loop through the memoized `useGetRoom(allJoinedRooms)`. The two remaining `mx.getRoom()` calls are inside drag/drop event handlers (not render loops) and are O(1) SDK map lookups. No change warranted. |
|
||||||
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/components/emoji-board/EmojiBoard.tsx` | OPEN |
|
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/components/emoji-board/EmojiBoard.tsx` | FIXED (`b7e1f89c`) — pack-label `mx.getRoom()` lookups in `EmojiSidebar`/`StickerSidebar` hoisted into a `useMemo`'d `Map` built once per pack list. |
|
||||||
| Performance | Numerous event handlers (e.g., handleUserClick, handleReplyClick) lack `useCallback`, leading to unnecessary re-renders of message components. | `cinny/src/app/features/room/RoomTimeline.tsx` | OPEN |
|
| Performance | Numerous event handlers (e.g., handleUserClick, handleReplyClick) lack `useCallback`, leading to unnecessary re-renders of message components. | `cinny/src/app/features/room/RoomTimeline.tsx` | FIXED (`b7e1f89c`) — `handleJumpToLatest`/`handleJumpToUnread`/`handleMarkAsRead` wrapped in `useCallback`. |
|
||||||
| Performance | The `submit` function and file handling callbacks (e.g., handleSendUpload) are re-created on every render, causing re-renders of the editor and toolbar components. | `cinny/src/app/features/room/RoomInput.tsx` | OPEN |
|
| Performance | The `submit` function and file handling callbacks (e.g., handleSendUpload) are re-created on every render, causing re-renders of the editor and toolbar components. | `cinny/src/app/features/room/RoomInput.tsx` | FIXED (`b7e1f89c`) — `handleCancelUpload`/`handleSendUpload`/`handleShareLocation`/`handleEmoticonSelect`/`handleStickerSelect` wrapped in `useCallback`. |
|
||||||
| Accessibility | `button` for edit history lacks `aria-label` | `cinny/src/app/components/message/content/FallbackContent.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View edit history"` |
|
| Accessibility | `button` for edit history lacks `aria-label` | `cinny/src/app/components/message/content/FallbackContent.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View edit history"` |
|
||||||
| Accessibility | `button` for reaction lacks `aria-label` | `cinny/src/app/components/message/Reaction.tsx` | **FIXED ⚠️ UNTESTED** — `Reaction` component now computes `aria-label="{shortcode} reaction, N people"` internally using `getShortcodeFor`; custom (mxc://) emoji falls back to "custom emoji reaction". |
|
| Accessibility | `button` for reaction lacks `aria-label` | `cinny/src/app/components/message/Reaction.tsx` | **FIXED ⚠️ UNTESTED** — `Reaction` component now computes `aria-label="{shortcode} reaction, N people"` internally using `getShortcodeFor`; custom (mxc://) emoji falls back to "custom emoji reaction". |
|
||||||
| Accessibility | `button` for ThreadIndicator lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View thread"` |
|
| Accessibility | `button` for ThreadIndicator lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View thread"` |
|
||||||
| Accessibility | `button` for ReplyLayout lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="Jump to original message"` |
|
| Accessibility | `button` for ReplyLayout lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="Jump to original message"` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 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 |
|
||||||
| Dependencies | Unstable/experimental versions for build tools (`vite` 8.0.14, `typescript` 6.0.3, `eslint` 9.39.4) | `cinny/package.json` | OPEN |
|
| Dependencies | Unstable/experimental versions for build tools (`vite` 8.0.14, `typescript` 6.0.3, `eslint` 9.39.4) | `cinny/package.json` | OPEN |
|
||||||
| CI/CD | `package-manager-cache` set to `false` | `cinny/.github/workflows/build-pull-request.yml` | OPEN |
|
| CI/CD | `package-manager-cache` set to `false` | `cinny/.github/workflows/build-pull-request.yml` | OPEN |
|
||||||
| CI/CD | Inefficient sequential execution in deployment | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
|
| CI/CD | Inefficient sequential execution in deployment | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
|
||||||
| CI/CD | Aggressive 1-minute timeout for Netlify deploy | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
|
| CI/CD | Aggressive 1-minute timeout for Netlify deploy | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
|
||||||
| DevEx | Stale upstream bug tracker link/donations/CLA | `cinny/CONTRIBUTING.md` | OPEN |
|
| DevEx | Stale upstream bug tracker link/donations/CLA | `cinny/CONTRIBUTING.md` | OPEN |
|
||||||
| DevEx | Alignment issue between README and CONTRIBUTING | `cinny/README.md` | OPEN |
|
| DevEx | Alignment issue between README and CONTRIBUTING | `cinny/README.md` | OPEN |
|
||||||
| Testing | No evident automated testing configuration/files | `cinny/src/` | OPEN |
|
| Testing | No evident automated testing configuration/files | `cinny/src/` | OPEN |
|
||||||
| Type Safety | Extensive use of `as any` type assertions | `cinny/src/` | OPEN |
|
| Type Safety | Extensive use of `as any` type assertions | `cinny/src/` | OPEN |
|
||||||
| Security | Hardcoded public CDN URL; consider moving to environment variable | /root/code/cinny/scripts/syncDecorations.mjs | OPEN |
|
| Security | Hardcoded public CDN URL; consider moving to environment variable | /root/code/cinny/scripts/syncDecorations.mjs | OPEN |
|
||||||
| Architecture | Modifying node_modules directly is brittle; use patch-package instead | /root/code/cinny/scripts/patch-folds.mjs | OPEN |
|
| Architecture | Modifying node_modules directly is brittle; use patch-package instead | /root/code/cinny/scripts/patch-folds.mjs | OPEN |
|
||||||
| Robustness | Missing security headers (HSTS, CSP, etc.) and inefficient asset serving using rewrites instead of try_files | /root/code/cinny/contrib/nginx/cinny.domain.tld.conf | OPEN |
|
| Robustness | Missing security headers (HSTS, CSP, etc.) and inefficient asset serving using rewrites instead of try_files | /root/code/cinny/contrib/nginx/cinny.domain.tld.conf | OPEN |
|
||||||
| Robustness | Incomplete documentation/placeholder path in Caddyfile | /root/code/cinny/contrib/caddy/caddyfile | OPEN |
|
| Robustness | Incomplete documentation/placeholder path in Caddyfile | /root/code/cinny/contrib/caddy/caddyfile | OPEN |
|
||||||
| Matrix SDK | Inefficient listener management (`setMaxListeners: 150`) and incomplete SDK state transition handling. | `src/client/initMatrix.ts` | OPEN |
|
| Matrix SDK | Inefficient listener management (`setMaxListeners: 150`) and incomplete SDK state transition handling. | `src/client/initMatrix.ts` | OPEN |
|
||||||
| PWA Robustness | Service worker lacks caching strategy for application assets, resulting in no offline capability. | `cinny/src/sw.ts` | OPEN |
|
| PWA Robustness | Service worker lacks caching strategy for application assets, resulting in no offline capability. | `cinny/src/sw.ts` | OPEN |
|
||||||
| PWA Integrity | `manifest: false` in `vite.config.js` might prevent correct PWA installation if not handled externally. | `cinny/vite.config.js` | OPEN |
|
| PWA Integrity | `manifest: false` in `vite.config.js` might prevent correct PWA installation if not handled externally. | `cinny/vite.config.js` | OPEN |
|
||||||
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/plugins/call/CallEmbed.ts` | OPEN |
|
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/plugins/call/CallEmbed.ts` | VERIFIED COMPLIANT — reviewed during the logging pass (`203568c9`); the existing log path already records only `e.message`, not raw event payloads. No change needed. |
|
||||||
| PII Leakage | Potential PII exposure via console.warn (parameter imgError/videoError/thumbError object). | `cinny/src/app/features/room/msgContent.ts` | OPEN |
|
| PII Leakage | Potential PII exposure via console.warn (parameter imgError/videoError/thumbError object). | `cinny/src/app/features/room/msgContent.ts` | FIXED (`203568c9`) — media-error warnings now log only `error.name` + `error.message`, never the raw error/event object. |
|
||||||
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/features/room/RoomInput.tsx` | OPEN |
|
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/features/room/RoomInput.tsx` | VERIFIED COMPLIANT — reviewed during the logging pass (`203568c9`); the existing log path already records only `e.message`. No change needed. |
|
||||||
|
|
||||||
## 🏗️ Architectural & Resilience Audit
|
## 🏗️ 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` | OPEN |
|
| 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` | OPEN |
|
| Component Resilience | `RoomTimeline` has no `ErrorBoundary` wrapper — a single malformed event crashing the renderer takes down the entire timeline with no fallback UI. | `src/app/features/room/RoomTimeline.tsx` | 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` | OPEN |
|
| Component Resilience | `RoomInput` has no `ErrorBoundary` wrapper — a crash in the composer leaves users unable to send messages. | `src/app/features/room/RoomInput.tsx` | FALSE POSITIVE — `RoomView.tsx` (lines 151–171) already wraps `<RoomInput>` in an `ErrorBoundary` with a "Message composer encountered an error" `RoomInputPlaceholder` fallback. No change needed. |
|
||||||
| Fallback Logic | No explicit empty/error fallback for Matrix SDK data calls in `RoomTimeline`; relies purely on SDK internal error propagation, meaning silent failures show a blank timeline. | `src/app/features/room/RoomTimeline.tsx` | OPEN |
|
| Fallback Logic | No explicit empty/error fallback for Matrix SDK data calls in `RoomTimeline`; relies purely on SDK internal error propagation, meaning silent failures show a blank timeline. | `src/app/features/room/RoomTimeline.tsx` | ADDRESSED — the `RoomView` `ErrorBoundary` (above) provides the explicit render-error fallback; a thrown SDK/render error now surfaces "Timeline unavailable" rather than a blank timeline. |
|
||||||
| Dependency | Potential for complex dependency chains due to deep nesting in `src/app/features/` and `src/app/hooks/`. | `src/app/` | OPEN |
|
| Dependency | Potential for complex dependency chains due to deep nesting in `src/app/features/` and `src/app/hooks/`. | `src/app/` | OPEN |
|
||||||
| Hydration/Race Condition | The SyncState listener registered by useSyncState may miss the initial 'PREPARED' event if the client initializes synchronously from IndexedDB before the effect runs, leading to an infinite loading state. | `cinny/src/app/pages/client/ClientRoot.tsx` | OPEN |
|
| Hydration/Race Condition | The SyncState listener registered by useSyncState may miss the initial 'PREPARED' event if the client initializes synchronously from IndexedDB before the effect runs, leading to an infinite loading state. | `cinny/src/app/pages/client/ClientRoot.tsx` | OPEN |
|
||||||
| Structure | High number of small, highly coupled utility hooks (`src/app/hooks/`) may obscure dependency graphs. | `src/app/hooks/` | OPEN |
|
| Structure | High number of small, highly coupled utility hooks (`src/app/hooks/`) may obscure dependency graphs. | `src/app/hooks/` | OPEN |
|
||||||
| Dead Code | Potential for unused CSS modules or UI components in `src/app/features/`. | `src/app/` | OPEN |
|
| Dead Code | Potential for unused CSS modules or UI components in `src/app/features/`. | `src/app/` | OPEN |
|
||||||
| Security | Sensitive session data (access tokens, device ID) stored in `localStorage` is vulnerable to XSS. | `src/app/state/sessions.ts` | OPEN |
|
| Security | Sensitive session data (access tokens, device ID) stored in `localStorage` is vulnerable to XSS. | `src/app/state/sessions.ts` | OPEN |
|
||||||
| Privacy | Sensitive user status messages and expiry timestamps are persisted in `localStorage`. | `src/app/features/settings/account/Profile.tsx` | OPEN |
|
| Privacy | Sensitive user status messages and expiry timestamps are persisted in `localStorage`. | `src/app/features/settings/account/Profile.tsx` | OPEN |
|
||||||
| Privacy | Unsent composer drafts stored in `localStorage` without encryption could leak info on shared devices. | `src/app/features/room/RoomInput.tsx` | OPEN |
|
| Privacy | Unsent composer drafts stored in `localStorage` without encryption could leak info on shared devices. | `src/app/features/room/RoomInput.tsx` | OPEN |
|
||||||
| Persistence | Scheduled messages relying on fragile `localStorage` parsing are prone to data loss on session expiry or error. | `src/app/state/scheduledMessages.ts` | OPEN |
|
| Persistence | Scheduled messages relying on fragile `localStorage` parsing are prone to data loss on session expiry or error. | `src/app/state/scheduledMessages.ts` | OPEN |
|
||||||
| Bundle Bloat | Inefficient `lodash` import; risks including entire library instead of necessary utilities. | `cinny/package.json` | OPEN |
|
| Bundle Bloat | Inefficient `lodash` import; risks including entire library instead of necessary utilities. | `cinny/package.json` | OPEN |
|
||||||
| Bundle Bloat | Large `matrix-js-sdk` (RC version) dependency; high potential for tree-shaking overhead. | `cinny/package.json` | OPEN |
|
| Bundle Bloat | Large `matrix-js-sdk` (RC version) dependency; high potential for tree-shaking overhead. | `cinny/package.json` | OPEN |
|
||||||
| Build-Time Overhead | `lotusDenoise` plugin performs heavy, sequential `fs` operations during `closeBundle`, significantly slowing build times. | `cinny/vite.config.js` | OPEN |
|
| Build-Time Overhead | `lotusDenoise` plugin performs heavy, sequential `fs` operations during `closeBundle`, significantly slowing build times. | `cinny/vite.config.js` | OPEN |
|
||||||
| Build-Time Overhead | Complex manual `viteStaticCopy` configuration requiring multiple renames and path manipulations; risks redundant processing. | `cinny/vite.config.js` | OPEN |
|
| Build-Time Overhead | Complex manual `viteStaticCopy` configuration requiring multiple renames and path manipulations; risks redundant processing. | `cinny/vite.config.js` | OPEN |
|
||||||
| Architectural Debt | Redundant style variant logic in `SpacingVariant` could be simplified. | `cinny/src/app/components/message/layout/layout.css.ts` | OPEN |
|
| Architectural Debt | Redundant style variant logic in `SpacingVariant` could be simplified. | `cinny/src/app/components/message/layout/layout.css.ts` | OPEN |
|
||||||
| Overhead Analysis | Potential CSS bloat from `DropTarget` composition across multiple recipes (`SidebarItem`, `SidebarFolder`). | `cinny/src/app/components/sidebar/Sidebar.css.ts` | OPEN |
|
| Overhead Analysis | Potential CSS bloat from `DropTarget` composition across multiple recipes (`SidebarItem`, `SidebarFolder`). | `cinny/src/app/components/sidebar/Sidebar.css.ts` | OPEN |
|
||||||
|
|
||||||
## 🏗️ Git Workflow & History Audit
|
## 🏗️ Git Workflow & History Audit
|
||||||
|
|
||||||
@@ -297,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 ⚠️ UNTESTED** (`caf6318a`) — needs verification: create a poll, then view/vote on it under a **non-TDS theme** (e.g. default Cinny dark/light) and confirm borders, selected state, and progress fill are 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# 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)
|
||||||
|
|
||||||
|
This guards against a permanently-stuck "Loading…" call.
|
||||||
|
|
||||||
|
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 Retry / Leave** buttons.
|
||||||
|
- **Retry** attempts to reload the call; **Leave** exits cleanly.
|
||||||
|
- Normal joins must **not** trigger the error overlay (no false positives) — this is the important part to confirm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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 CallSound from '../../../public/sound/call.ogg';
|
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
||||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||||
@@ -103,8 +103,8 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
const canAnswer = livekitSupported && rtcSupported;
|
const canAnswer = livekitSupported && rtcSupported;
|
||||||
const { room } = info;
|
const { room } = info;
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
|
||||||
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
|
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
|
||||||
const roomName = useRoomName(room);
|
const roomName = useRoomName(room);
|
||||||
const roomAvatar = useRoomAvatar(room, dm);
|
const roomAvatar = useRoomAvatar(room, dm);
|
||||||
@@ -125,25 +125,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const playSound = useCallback(() => {
|
|
||||||
const audioElement = audioRef.current;
|
|
||||||
if (!audioElement) return;
|
|
||||||
audioElement.volume = Math.max(0, Math.min(1, ringtoneVolume / 100));
|
|
||||||
audioElement.play().catch(() => undefined);
|
|
||||||
}, [ringtoneVolume]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audioEl = audioRef.current;
|
if (info.notificationType !== 'ring') return undefined;
|
||||||
if (info.notificationType === 'ring') {
|
const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
playSound();
|
return stop;
|
||||||
}
|
}, [info.notificationType, ringtoneId, ringtoneVolume]);
|
||||||
return () => {
|
|
||||||
if (audioEl) {
|
|
||||||
audioEl.pause();
|
|
||||||
audioEl.currentTime = 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [playSound, info.notificationType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const remaining = info.senderTs + info.lifetime - Date.now();
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||||
@@ -156,112 +142,250 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
}, [info.senderTs, info.lifetime, onIgnore]);
|
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<OverlayCenter>
|
||||||
<OverlayCenter>
|
<FocusTrap
|
||||||
<FocusTrap
|
focusTrapOptions={{
|
||||||
focusTrapOptions={{
|
initialFocus: false,
|
||||||
initialFocus: false,
|
onDeactivate: () => onIgnore(),
|
||||||
onDeactivate: () => onIgnore(),
|
clickOutsideDeactivates: false,
|
||||||
clickOutsideDeactivates: false,
|
escapeDeactivates: false,
|
||||||
escapeDeactivates: false,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Dialog style={{ maxWidth: toRem(324) }}>
|
||||||
<Dialog style={{ maxWidth: toRem(324) }}>
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
<Text size="T200" align="Center">
|
||||||
<Text size="T200" align="Center">
|
{getMemberDisplayName(info.room, info.sender) ??
|
||||||
{getMemberDisplayName(info.room, info.sender) ??
|
getMxIdLocalPart(info.sender) ??
|
||||||
getMxIdLocalPart(info.sender) ??
|
info.sender}
|
||||||
info.sender}
|
</Text>
|
||||||
</Text>
|
<Box direction="Column" gap="500" alignItems="Center">
|
||||||
<Box direction="Column" gap="500" alignItems="Center">
|
<Box shrink="No">
|
||||||
<Box shrink="No">
|
<Avatar size="500" className={CallAvatarAnimation}>
|
||||||
<Avatar size="500" className={CallAvatarAnimation}>
|
<RoomAvatar
|
||||||
<RoomAvatar
|
roomId={room.roomId}
|
||||||
roomId={room.roomId}
|
src={avatarUrl}
|
||||||
src={avatarUrl}
|
alt={roomName}
|
||||||
alt={roomName}
|
renderFallback={() => (
|
||||||
renderFallback={() => (
|
<RoomIcon
|
||||||
<RoomIcon
|
roomType={room.getType()}
|
||||||
roomType={room.getType()}
|
size="400"
|
||||||
size="400"
|
joinRule={room.getJoinRule()}
|
||||||
joinRule={room.getJoinRule()}
|
filled
|
||||||
filled
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
</Avatar>
|
||||||
</Avatar>
|
|
||||||
</Box>
|
|
||||||
<Box grow="Yes" direction="Column" gap="100" alignItems="Center">
|
|
||||||
<Text size="H3" align="Center" truncate>
|
|
||||||
{roomName}
|
|
||||||
</Text>
|
|
||||||
<Text size="T300" align="Center">
|
|
||||||
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
{!livekitSupported && (
|
<Box grow="Yes" direction="Column" gap="100" alignItems="Center">
|
||||||
<Text
|
<Text size="H3" align="Center" truncate>
|
||||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
{roomName}
|
||||||
size="L400"
|
|
||||||
align="Center"
|
|
||||||
>
|
|
||||||
Your homeserver does not support calling.
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<Text size="T300" align="Center">
|
||||||
{!webRTCSupported() && (
|
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
||||||
<Text
|
|
||||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
|
||||||
size="L400"
|
|
||||||
align="Center"
|
|
||||||
>
|
|
||||||
Your browser does not support WebRTC, which is required for calling.
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
|
||||||
<Box direction="Column" gap="300">
|
|
||||||
<Button
|
|
||||||
style={{ flexGrow: 1 }}
|
|
||||||
variant="Success"
|
|
||||||
size="400"
|
|
||||||
radii="400"
|
|
||||||
onClick={() => onAnswer(room, info.intent === 'video')}
|
|
||||||
before={
|
|
||||||
<Icon
|
|
||||||
size="200"
|
|
||||||
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
|
||||||
filled
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
disabled={!canAnswer}
|
|
||||||
>
|
|
||||||
<Text as="span" size="B400">
|
|
||||||
Answer
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
style={{ flexGrow: 1 }}
|
|
||||||
variant={dm ? 'Critical' : 'Secondary'}
|
|
||||||
fill="Soft"
|
|
||||||
size="400"
|
|
||||||
radii="400"
|
|
||||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
|
||||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
|
||||||
>
|
|
||||||
<Text as="span" size="B400">
|
|
||||||
{dm ? 'Reject' : 'Ignore'}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Dialog>
|
{!livekitSupported && (
|
||||||
</FocusTrap>
|
<Text
|
||||||
</OverlayCenter>
|
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||||
</Overlay>
|
size="L400"
|
||||||
<audio ref={audioRef} loop style={{ display: 'none' }}>
|
align="Center"
|
||||||
<source src={CallSound} type="audio/ogg" />
|
>
|
||||||
</audio>
|
Your homeserver does not support calling.
|
||||||
</>
|
</Text>
|
||||||
|
)}
|
||||||
|
{!webRTCSupported() && (
|
||||||
|
<Text
|
||||||
|
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||||
|
size="L400"
|
||||||
|
align="Center"
|
||||||
|
>
|
||||||
|
Your browser does not support WebRTC, which is required for calling.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant="Success"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
onClick={() => onAnswer(room, info.intent === 'video')}
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
size="200"
|
||||||
|
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
disabled={!canAnswer}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Answer
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant={dm ? 'Critical' : 'Secondary'}
|
||||||
|
fill="Soft"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||||
|
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
{dm ? 'Reject' : 'Ignore'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
useEffect(() => {
|
||||||
|
if (info.notificationType !== 'ring') return;
|
||||||
|
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
|
}, [info.notificationType, ringtoneId, ringtoneVolume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
onIgnore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = setTimeout(onIgnore, remaining);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||||
|
|
||||||
|
const callerName =
|
||||||
|
getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="300"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: config.space.S400,
|
||||||
|
right: config.space.S400,
|
||||||
|
zIndex: 9990,
|
||||||
|
width: toRem(300),
|
||||||
|
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||||
|
padding: config.space.S300,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
boxShadow: `0 8px 32px ${color.Other.Shadow}`,
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
aria-label={`Incoming ${isVideo ? 'video' : 'voice'} call from ${roomName}`}
|
||||||
|
>
|
||||||
|
<Box gap="300" alignItems="Center">
|
||||||
|
<Box shrink="No">
|
||||||
|
<Avatar size="300" className={CallAvatarAnimation}>
|
||||||
|
<RoomAvatar
|
||||||
|
roomId={room.roomId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={roomName}
|
||||||
|
renderFallback={() => (
|
||||||
|
<RoomIcon
|
||||||
|
roomType={room.getType()}
|
||||||
|
size="200"
|
||||||
|
joinRule={room.getJoinRule()}
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0 }}>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{roomName}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300" truncate>
|
||||||
|
{isVideo ? 'Incoming video call' : 'Incoming voice call'}
|
||||||
|
{dm ? '' : ` · ${callerName}`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant="Success"
|
||||||
|
fill="Solid"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => onAnswer(room, isVideo)}
|
||||||
|
before={<Icon size="100" src={isVideo ? Icons.VideoCamera : Icons.Phone} filled />}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B300">
|
||||||
|
Answer
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant={dm ? 'Critical' : 'Secondary'}
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||||
|
before={<Icon size="100" src={Icons.Cross} filled />}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B300">
|
||||||
|
{dm ? 'Reject' : 'Ignore'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,10 +514,25 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
[startCall, navigateRoom],
|
[startCall, navigateRoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
|
if (!callInfo) return null;
|
||||||
|
// Already in this room's own call — no notification at all.
|
||||||
|
if (callEmbed?.roomId === callInfo.room.roomId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return !joined && callInfo ? (
|
// In a different call already: show the compact, non-intrusive banner
|
||||||
|
// instead of the full-screen takeover overlay.
|
||||||
|
if (joined) {
|
||||||
|
return (
|
||||||
|
<IncomingCallBanner
|
||||||
|
dm={dm}
|
||||||
|
info={callInfo}
|
||||||
|
onIgnore={handleIgnore}
|
||||||
|
onAnswer={handleAnswer}
|
||||||
|
onReject={handleReject}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<IncomingCall
|
<IncomingCall
|
||||||
dm={dm}
|
dm={dm}
|
||||||
info={callInfo}
|
info={callInfo}
|
||||||
@@ -401,7 +540,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 }) {
|
||||||
|
|||||||
@@ -178,6 +178,16 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
const icons = useEmojiGroupIcons();
|
const icons = useEmojiGroupIcons();
|
||||||
|
|
||||||
|
const packLabels = useMemo(() => {
|
||||||
|
const map = new Map<string, string | undefined>();
|
||||||
|
packs.forEach((pack) => {
|
||||||
|
let label = pack.meta.name;
|
||||||
|
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||||
|
map.set(pack.id, label);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [mx, packs]);
|
||||||
|
|
||||||
const handleScrollToGroup = (groupId: string) => {
|
const handleScrollToGroup = (groupId: string) => {
|
||||||
setActiveGroupId(groupId);
|
setActiveGroupId(groupId);
|
||||||
onScrollToGroup(groupId);
|
onScrollToGroup(groupId);
|
||||||
@@ -198,8 +208,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<SidebarDivider />
|
<SidebarDivider />
|
||||||
{packs.map((pack) => {
|
{packs.map((pack) => {
|
||||||
let label = pack.meta.name;
|
const label = packLabels.get(pack.id);
|
||||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
||||||
@@ -252,6 +261,16 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
|||||||
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
||||||
const usage = ImageUsage.Sticker;
|
const usage = ImageUsage.Sticker;
|
||||||
|
|
||||||
|
const packLabels = useMemo(() => {
|
||||||
|
const map = new Map<string, string | undefined>();
|
||||||
|
packs.forEach((pack) => {
|
||||||
|
let label = pack.meta.name;
|
||||||
|
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||||
|
map.set(pack.id, label);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [mx, packs]);
|
||||||
|
|
||||||
const handleScrollToGroup = (groupId: string) => {
|
const handleScrollToGroup = (groupId: string) => {
|
||||||
setActiveGroupId(groupId);
|
setActiveGroupId(groupId);
|
||||||
onScrollToGroup(groupId);
|
onScrollToGroup(groupId);
|
||||||
@@ -261,8 +280,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
|||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
{packs.map((pack) => {
|
{packs.map((pack) => {
|
||||||
let label = pack.meta.name;
|
const label = packLabels.get(pack.id);
|
||||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
||||||
|
|||||||
@@ -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: toRem(14),
|
||||||
width: '14px',
|
height: toRem(14),
|
||||||
height: '14px',
|
border: `${config.borderWidth.B300} solid ${
|
||||||
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--accent-cyan-border)'}`,
|
selected ? color.Primary.Main : color.Primary.ContainerLine
|
||||||
borderRadius: '3px',
|
}`,
|
||||||
background: selected ? 'var(--accent-cyan)' : 'none',
|
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
|
||||||
display: 'flex',
|
background: selected ? color.Primary.Main : 'transparent',
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
alignItems: 'center',
|
||||||
fontSize: '10px',
|
justifyContent: 'center',
|
||||||
color: '#fff',
|
color: color.Primary.OnMain,
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected ? '✓' : ''}
|
{selected && isMultiple ? (
|
||||||
</span>
|
<Text as="span" size="T200" style={{ lineHeight: 1 }}>
|
||||||
)}
|
✓
|
||||||
{!isMultiple && (
|
</Text>
|
||||||
<span
|
) : null}
|
||||||
style={{
|
</span>
|
||||||
flexShrink: 0,
|
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||||
width: '14px',
|
{text}
|
||||||
height: '14px',
|
</Text>
|
||||||
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
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
|||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
|
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||||
import { getMouseEventCords } from '../../utils/dom';
|
import { getMouseEventCords } from '../../utils/dom';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
|
|
||||||
@@ -51,14 +52,16 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box grow="Yes" gap="300" alignItems="Center">
|
<Box grow="Yes" gap="300" alignItems="Center">
|
||||||
<Avatar size="200" radii="400">
|
<AvatarDecoration userId={userId}>
|
||||||
<UserAvatar
|
<Avatar size="200" radii="400">
|
||||||
userId={userId}
|
<UserAvatar
|
||||||
src={avatarUrl}
|
userId={userId}
|
||||||
alt={name}
|
src={avatarUrl}
|
||||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
alt={name}
|
||||||
/>
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
</Avatar>
|
/>
|
||||||
|
</Avatar>
|
||||||
|
</AvatarDecoration>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="L400" truncate>
|
<Text size="L400" truncate>
|
||||||
{name}
|
{name}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import React, { RefObject, useRef } from 'react';
|
import React, { RefObject, useRef } from 'react';
|
||||||
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
|
import { useSetAtom } from 'jotai';
|
||||||
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
|
import { Badge, Box, Button, color, Header, Icon, Icons, Scroll, Text, toRem } from 'folds';
|
||||||
|
import {
|
||||||
|
useCallEmbed,
|
||||||
|
useCallJoined,
|
||||||
|
useCallEmbedPlacementSync,
|
||||||
|
useCallLoadError,
|
||||||
|
} from '../../hooks/useCallEmbed';
|
||||||
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { PrescreenControls } from './PrescreenControls';
|
import { PrescreenControls } from './PrescreenControls';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
@@ -153,6 +160,37 @@ function CallPrescreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CallLoadErrorMessage() {
|
||||||
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const dismiss = () => setCallEmbed(undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={css.CallViewContent}
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Warning} size="400" style={{ color: color.Critical.Main }} />
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="L400" align="Center">
|
||||||
|
The call failed to load. Check your connection and try again.
|
||||||
|
</Text>
|
||||||
|
<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}>
|
||||||
|
<Text size="B400">Leave</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type CallJoinedProps = {
|
type CallJoinedProps = {
|
||||||
containerRef: RefObject<HTMLDivElement>;
|
containerRef: RefObject<HTMLDivElement>;
|
||||||
joined: boolean;
|
joined: boolean;
|
||||||
@@ -175,8 +213,13 @@ export function CallView() {
|
|||||||
|
|
||||||
const callEmbed = useCallEmbed();
|
const callEmbed = useCallEmbed();
|
||||||
const callJoined = useCallJoined(callEmbed);
|
const callJoined = useCallJoined(callEmbed);
|
||||||
|
const loadError = useCallLoadError(callEmbed);
|
||||||
|
|
||||||
const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
|
const isCurrentRoom = callEmbed?.roomId === room.roomId;
|
||||||
|
const currentJoined = isCurrentRoom && callJoined;
|
||||||
|
// Show the recovery UI when this room's embed failed to load and we never
|
||||||
|
// made it into the call (a hung iframe / blank spinner otherwise).
|
||||||
|
const showLoadError = isCurrentRoom && !currentJoined && Boolean(loadError);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -184,8 +227,9 @@ export function CallView() {
|
|||||||
style={{ minWidth: toRem(280) }}
|
style={{ minWidth: toRem(280) }}
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
>
|
>
|
||||||
{!currentJoined && <CallPrescreen />}
|
{showLoadError && <CallLoadErrorMessage />}
|
||||||
<CallJoined joined={currentJoined} containerRef={callContainerRef} />
|
{!currentJoined && !showLoadError && <CallPrescreen />}
|
||||||
|
{!showLoadError && <CallJoined joined={currentJoined} containerRef={callContainerRef} />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
||||||
const [locating, setLocating] = React.useState(false);
|
const [locating, setLocating] = React.useState(false);
|
||||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||||
const handleShareLocation = () => {
|
const handleShareLocation = useCallback(() => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
setLocationError('Geolocation not supported.');
|
setLocationError('Geolocation not supported.');
|
||||||
setTimeout(() => setLocationError(null), 4000);
|
setTimeout(() => setLocationError(null), 4000);
|
||||||
@@ -252,7 +252,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
},
|
},
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
};
|
}, [mx, roomId]);
|
||||||
|
|
||||||
const handleVoiceSend = useCallback(
|
const handleVoiceSend = useCallback(
|
||||||
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
||||||
@@ -405,71 +405,77 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
[setSelectedFiles, selectedFiles],
|
[setSelectedFiles, selectedFiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCancelUpload = (uploads: Upload[]) => {
|
const handleCancelUpload = useCallback(
|
||||||
uploads.forEach((upload) => {
|
(uploads: Upload[]) => {
|
||||||
if (upload.status === UploadStatus.Loading) {
|
uploads.forEach((upload) => {
|
||||||
mx.cancelUpload(upload.promise);
|
if (upload.status === UploadStatus.Loading) {
|
||||||
}
|
mx.cancelUpload(upload.promise);
|
||||||
});
|
|
||||||
handleRemoveUpload(uploads.map((upload) => upload.file));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendUpload = async (uploads: UploadSuccess[]) => {
|
|
||||||
const contentsPromises = uploads.map(async (upload) => {
|
|
||||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
|
||||||
if (!fileItem) throw new Error('Broken upload');
|
|
||||||
|
|
||||||
// Resolve the MXC URL to use — may be overridden if compression is enabled
|
|
||||||
let mxc = upload.mxc;
|
|
||||||
|
|
||||||
if (fileItem.metadata.compressImage && isCompressible(fileItem.originalFile)) {
|
|
||||||
// Use the cached compression result if available, otherwise compute it now
|
|
||||||
let compressionResult = fileItem.metadata.compressionResult;
|
|
||||||
if (compressionResult === undefined) {
|
|
||||||
compressionResult = await compressImage(fileItem.originalFile);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
handleRemoveUpload(uploads.map((upload) => upload.file));
|
||||||
|
},
|
||||||
|
[mx, handleRemoveUpload],
|
||||||
|
);
|
||||||
|
|
||||||
if (compressionResult) {
|
const handleSendUpload = useCallback(
|
||||||
const originalFile = fileItem.originalFile as File;
|
async (uploads: UploadSuccess[]) => {
|
||||||
const compressedFile = new File([compressionResult.blob], originalFile.name, {
|
const contentsPromises = uploads.map(async (upload) => {
|
||||||
type: 'image/jpeg',
|
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||||
});
|
if (!fileItem) throw new Error('Broken upload');
|
||||||
const uploadRes = await mx.uploadContent(compressedFile, {
|
|
||||||
name: originalFile.name,
|
// Resolve the MXC URL to use — may be overridden if compression is enabled
|
||||||
type: 'image/jpeg',
|
let mxc = upload.mxc;
|
||||||
});
|
|
||||||
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
if (fileItem.metadata.compressImage && isCompressible(fileItem.originalFile)) {
|
||||||
if (compressedMxc) {
|
// Use the cached compression result if available, otherwise compute it now
|
||||||
// Delete the pre-uploaded original so only one copy lives on the server.
|
let compressionResult = fileItem.metadata.compressionResult;
|
||||||
tryDeleteMxcContent(mx, upload.mxc);
|
if (compressionResult === undefined) {
|
||||||
mxc = compressedMxc;
|
compressionResult = await compressImage(fileItem.originalFile);
|
||||||
// Build a synthetic fileItem that refers to the compressed file so
|
}
|
||||||
// getImageMsgContent picks up the correct dimensions and type.
|
|
||||||
const compressedItem = {
|
if (compressionResult) {
|
||||||
...fileItem,
|
const originalFile = fileItem.originalFile as File;
|
||||||
file: compressedFile,
|
const compressedFile = new File([compressionResult.blob], originalFile.name, {
|
||||||
originalFile: compressedFile,
|
type: 'image/jpeg',
|
||||||
};
|
});
|
||||||
return getImageMsgContent(mx, compressedItem, mxc);
|
const uploadRes = await mx.uploadContent(compressedFile, {
|
||||||
|
name: originalFile.name,
|
||||||
|
type: 'image/jpeg',
|
||||||
|
});
|
||||||
|
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
||||||
|
if (compressedMxc) {
|
||||||
|
// Delete the pre-uploaded original so only one copy lives on the server.
|
||||||
|
tryDeleteMxcContent(mx, upload.mxc);
|
||||||
|
mxc = compressedMxc;
|
||||||
|
// Build a synthetic fileItem that refers to the compressed file so
|
||||||
|
// getImageMsgContent picks up the correct dimensions and type.
|
||||||
|
const compressedItem = {
|
||||||
|
...fileItem,
|
||||||
|
file: compressedFile,
|
||||||
|
originalFile: compressedFile,
|
||||||
|
};
|
||||||
|
return getImageMsgContent(mx, compressedItem, mxc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (fileItem.file.type.startsWith('image')) {
|
if (fileItem.file.type.startsWith('image')) {
|
||||||
return getImageMsgContent(mx, fileItem, mxc);
|
return getImageMsgContent(mx, fileItem, mxc);
|
||||||
}
|
}
|
||||||
if (fileItem.file.type.startsWith('video')) {
|
if (fileItem.file.type.startsWith('video')) {
|
||||||
return getVideoMsgContent(mx, fileItem, mxc);
|
return getVideoMsgContent(mx, fileItem, mxc);
|
||||||
}
|
}
|
||||||
if (fileItem.file.type.startsWith('audio')) {
|
if (fileItem.file.type.startsWith('audio')) {
|
||||||
return getAudioMsgContent(fileItem, mxc);
|
return getAudioMsgContent(fileItem, mxc);
|
||||||
}
|
}
|
||||||
return getFileMsgContent(fileItem, mxc);
|
return getFileMsgContent(fileItem, mxc);
|
||||||
});
|
});
|
||||||
handleCancelUpload(uploads);
|
handleCancelUpload(uploads);
|
||||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||||
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
||||||
};
|
},
|
||||||
|
[mx, roomId, selectedFiles, handleCancelUpload],
|
||||||
|
);
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
uploadBoardHandlers.current?.handleSend();
|
uploadBoardHandlers.current?.handleSend();
|
||||||
@@ -675,10 +681,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
ReactEditor.focus(editor);
|
ReactEditor.focus(editor);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
const handleEmoticonSelect = useCallback(
|
||||||
editor.insertNode(createEmoticonElement(key, shortcode));
|
(key: string, shortcode: string) => {
|
||||||
moveCursor(editor);
|
editor.insertNode(createEmoticonElement(key, shortcode));
|
||||||
};
|
moveCursor(editor);
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const handleGifSelect = useCallback(
|
const handleGifSelect = useCallback(
|
||||||
async (gifUrl: string, w: number, h: number) => {
|
async (gifUrl: string, w: number, h: number) => {
|
||||||
@@ -736,21 +745,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
[mx, roomId, alive],
|
[mx, roomId, alive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
|
const handleStickerSelect = useCallback(
|
||||||
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
async (mxc: string, shortcode: string, label: string) => {
|
||||||
if (!stickerUrl) return;
|
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
||||||
|
if (!stickerUrl) return;
|
||||||
|
|
||||||
const info = await getImageInfo(
|
const info = await getImageInfo(
|
||||||
await loadImageElement(stickerUrl),
|
await loadImageElement(stickerUrl),
|
||||||
await getImageUrlBlob(stickerUrl),
|
await getImageUrlBlob(stickerUrl),
|
||||||
);
|
);
|
||||||
|
|
||||||
mx.sendEvent(roomId, EventType.Sticker, {
|
mx.sendEvent(roomId, EventType.Sticker, {
|
||||||
body: label,
|
body: label,
|
||||||
url: mxc,
|
url: mxc,
|
||||||
info,
|
info,
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[mx, roomId, useAuthentication],
|
||||||
|
);
|
||||||
|
|
||||||
if (room.getType() === 'm.server_notice') {
|
if (room.getType() === 'm.server_notice') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -906,25 +906,25 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [scrollToElement, editId]);
|
}, [scrollToElement, editId]);
|
||||||
|
|
||||||
const handleJumpToLatest = () => {
|
const handleJumpToLatest = useCallback(() => {
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
}
|
}
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
};
|
}, [eventId, navigateRoom, room]);
|
||||||
|
|
||||||
const handleJumpToUnread = () => {
|
const handleJumpToUnread = useCallback(() => {
|
||||||
if (unreadInfo?.readUptoEventId) {
|
if (unreadInfo?.readUptoEventId) {
|
||||||
setTimeline(getEmptyTimeline());
|
setTimeline(getEmptyTimeline());
|
||||||
loadEventTimeline(unreadInfo.readUptoEventId);
|
loadEventTimeline(unreadInfo.readUptoEventId);
|
||||||
}
|
}
|
||||||
};
|
}, [unreadInfo, loadEventTimeline]);
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = useCallback(() => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
};
|
}, [mx, room, hideActivity]);
|
||||||
|
|
||||||
const handleOpenReply: MouseEventHandler = useCallback(
|
const handleOpenReply: MouseEventHandler = useCallback(
|
||||||
async (evt) => {
|
async (evt) => {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const getImageMsgContent = async (
|
|||||||
): Promise<IContent> => {
|
): Promise<IContent> => {
|
||||||
const { file, originalFile, encInfo, metadata } = item;
|
const { file, originalFile, encInfo, metadata } = item;
|
||||||
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
|
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
|
||||||
if (imgError) console.warn('Failed to load image element:', imgError.message);
|
if (imgError) console.warn('Failed to load image element:', imgError.name, imgError.message);
|
||||||
|
|
||||||
const content: IContent = {
|
const content: IContent = {
|
||||||
msgtype: MsgType.Image,
|
msgtype: MsgType.Image,
|
||||||
@@ -85,7 +85,8 @@ export const getVideoMsgContent = async (
|
|||||||
const { file, originalFile, encInfo, metadata } = item;
|
const { file, originalFile, encInfo, metadata } = item;
|
||||||
|
|
||||||
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
|
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
|
||||||
if (videoError) console.warn('Failed to load video element:', videoError.message);
|
if (videoError)
|
||||||
|
console.warn('Failed to load video element:', videoError.name, videoError.message);
|
||||||
|
|
||||||
const content: IContent = {
|
const content: IContent = {
|
||||||
msgtype: MsgType.Video,
|
msgtype: MsgType.Video,
|
||||||
@@ -109,7 +110,8 @@ export const getVideoMsgContent = async (
|
|||||||
scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight),
|
scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (thumbError) console.warn('Failed to generate video thumbnail:', thumbError.message);
|
if (thumbError)
|
||||||
|
console.warn('Failed to generate video thumbnail:', thumbError.name, thumbError.message);
|
||||||
content.info = {
|
content.info = {
|
||||||
...getVideoInfo(videoEl, file),
|
...getVideoInfo(videoEl, file),
|
||||||
...thumbContent,
|
...thumbContent,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
MessageLayout,
|
MessageLayout,
|
||||||
MessageSpacing,
|
MessageSpacing,
|
||||||
NoiseSuppressionMode,
|
NoiseSuppressionMode,
|
||||||
|
RingtoneId,
|
||||||
Settings,
|
Settings,
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
} from '../../../state/settings';
|
} from '../../../state/settings';
|
||||||
@@ -78,6 +79,7 @@ import { SequenceCardStyle } from '../styles.css';
|
|||||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||||
|
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||||
import { DenoiseTester } from './DenoiseTester';
|
import { DenoiseTester } from './DenoiseTester';
|
||||||
|
|
||||||
type ThemeSelectorProps = {
|
type ThemeSelectorProps = {
|
||||||
@@ -1242,12 +1244,18 @@ function Calls() {
|
|||||||
'callJoinLeaveSound',
|
'callJoinLeaveSound',
|
||||||
);
|
);
|
||||||
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
|
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
|
||||||
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
||||||
setCallJoinLeaveSound(value);
|
setCallJoinLeaveSound(value);
|
||||||
if (value !== 'off') playCallJoinSound(value);
|
if (value !== 'off') playCallJoinSound(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRingtoneChange = (value: RingtoneId) => {
|
||||||
|
setRingtoneId(value);
|
||||||
|
previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
|
};
|
||||||
|
|
||||||
const pttBind = useKeyBind(setPttKey);
|
const pttBind = useKeyBind(setPttKey);
|
||||||
const deafenBind = useKeyBind(setDeafenKey);
|
const deafenBind = useKeyBind(setDeafenKey);
|
||||||
|
|
||||||
@@ -1573,6 +1581,19 @@ function Calls() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Ringtone"
|
||||||
|
description="Sound played for incoming calls. Selecting an option plays a preview."
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={ringtoneId}
|
||||||
|
onChange={(v) => handleRingtoneChange(v as RingtoneId)}
|
||||||
|
options={RINGTONE_OPTIONS}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Ringtone Volume"
|
title="Ringtone Volume"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MatrixClient, Room } from 'matrix-js-sdk';
|
|||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
CallEmbed,
|
CallEmbed,
|
||||||
|
CallLoadErrorReason,
|
||||||
ElementCallThemeKind,
|
ElementCallThemeKind,
|
||||||
ElementWidgetActions,
|
ElementWidgetActions,
|
||||||
useClientWidgetApiEvent,
|
useClientWidgetApiEvent,
|
||||||
@@ -156,6 +157,26 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
|
|||||||
return joined;
|
return joined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surfaces a load failure (watchdog timeout or iframe error) from the embedded
|
||||||
|
* Element Call iframe so the UI can show a recovery affordance instead of an
|
||||||
|
* indefinite "Loading..." spinner.
|
||||||
|
*/
|
||||||
|
export const useCallLoadError = (embed?: CallEmbed): CallLoadErrorReason | undefined => {
|
||||||
|
const [error, setError] = useState<CallLoadErrorReason | undefined>(() => embed?.loadFailed);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!embed) {
|
||||||
|
setError(undefined);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
setError(embed.loadFailed);
|
||||||
|
return embed.onLoadError((reason) => setError(reason));
|
||||||
|
}, [embed]);
|
||||||
|
|
||||||
|
return error;
|
||||||
|
};
|
||||||
|
|
||||||
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
|
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
|
||||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
||||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
|
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export function usePresenceUpdater() {
|
|||||||
const readStatus = () =>
|
const readStatus = () =>
|
||||||
userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
|
userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
|
||||||
|
|
||||||
|
// Log presence failures without leaking PII (user id, token, status message).
|
||||||
|
const warnPresenceFailure = (presence: string, err: unknown) => {
|
||||||
|
const reason =
|
||||||
|
err instanceof Error ? err.message : typeof err === 'string' ? err : 'unknown error';
|
||||||
|
console.warn(`Failed to set presence to "${presence}":`, reason);
|
||||||
|
};
|
||||||
|
|
||||||
const setOnline = () => {
|
const setOnline = () => {
|
||||||
const status = readStatus();
|
const status = readStatus();
|
||||||
return mx
|
return mx
|
||||||
@@ -30,7 +37,7 @@ export function usePresenceUpdater() {
|
|||||||
presence: 'online',
|
presence: 'online',
|
||||||
...(status ? { status_msg: status } : {}),
|
...(status ? { status_msg: status } : {}),
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch((err) => warnPresenceFailure('online', err));
|
||||||
};
|
};
|
||||||
const setUnavailable = (statusMsg?: string) => {
|
const setUnavailable = (statusMsg?: string) => {
|
||||||
const status = readStatus();
|
const status = readStatus();
|
||||||
@@ -39,10 +46,12 @@ export function usePresenceUpdater() {
|
|||||||
presence: 'unavailable',
|
presence: 'unavailable',
|
||||||
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
|
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch((err) => warnPresenceFailure('unavailable', err));
|
||||||
};
|
};
|
||||||
const setOffline = () =>
|
const setOffline = () =>
|
||||||
mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined);
|
mx
|
||||||
|
.setPresence({ presence: 'offline', status_msg: '' })
|
||||||
|
.catch((err) => warnPresenceFailure('offline', err));
|
||||||
|
|
||||||
// Manual presence overrides — no activity tracking needed.
|
// Manual presence overrides — no activity tracking needed.
|
||||||
if (hidePresence || presenceStatus === 'invisible') {
|
if (hidePresence || presenceStatus === 'invisible') {
|
||||||
@@ -100,6 +109,11 @@ export function usePresenceUpdater() {
|
|||||||
const baseUrl = mx.getHomeserverUrl();
|
const baseUrl = mx.getHomeserverUrl();
|
||||||
if (!userId || !token || !baseUrl) return;
|
if (!userId || !token || !baseUrl) return;
|
||||||
|
|
||||||
|
// Reliable delivery during page teardown: navigator.sendBeacon cannot set the
|
||||||
|
// Authorization header required by the authenticated Matrix presence endpoint, so
|
||||||
|
// it isn't usable here. fetch(..., { keepalive: true }) lets the request outlive the
|
||||||
|
// page and is the correct mechanism for an authed endpoint. (keepalive bodies are
|
||||||
|
// capped at 64KB, which this tiny payload is well under.)
|
||||||
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
|
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -108,7 +122,7 @@ export function usePresenceUpdater() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ presence: 'offline' }),
|
body: JSON.stringify({ presence: 'offline' }),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
}).catch(() => undefined);
|
}).catch((err) => warnPresenceFailure('offline (pagehide)', err));
|
||||||
};
|
};
|
||||||
|
|
||||||
setOnline();
|
setOnline();
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ import {
|
|||||||
import { CallControl } from './CallControl';
|
import { CallControl } from './CallControl';
|
||||||
import { CallControlState } from './CallControlState';
|
import { CallControlState } from './CallControlState';
|
||||||
|
|
||||||
|
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||||
|
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||||
|
// iframe has hung (e.g. blocked network, crashed widget, blank "Loading...")
|
||||||
|
// and surface a recoverable error instead of an indefinite spinner.
|
||||||
|
const CALL_LOAD_WATCHDOG_MS = 25_000;
|
||||||
|
|
||||||
|
export type CallLoadErrorReason = 'timeout' | 'iframe';
|
||||||
|
|
||||||
export class CallEmbed {
|
export class CallEmbed {
|
||||||
private mx: MatrixClient;
|
private mx: MatrixClient;
|
||||||
|
|
||||||
@@ -55,6 +63,15 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private themeKind: ElementCallThemeKind = 'dark';
|
private themeKind: ElementCallThemeKind = 'dark';
|
||||||
|
|
||||||
|
// Watchdog: detects an iframe that never reaches a usable state.
|
||||||
|
private loadWatchdog?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
private loadSettled = false;
|
||||||
|
|
||||||
|
private loadError?: CallLoadErrorReason;
|
||||||
|
|
||||||
|
private readonly loadErrorListeners = new Set<(reason: CallLoadErrorReason) => 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);
|
||||||
|
|
||||||
@@ -218,6 +235,19 @@ export class CallEmbed {
|
|||||||
iframe.onload = () => {
|
iframe.onload = () => {
|
||||||
this.control.startObserving();
|
this.control.startObserving();
|
||||||
};
|
};
|
||||||
|
// If the iframe document itself fails to load, fail fast.
|
||||||
|
iframe.onerror = () => {
|
||||||
|
this.settleLoad('iframe');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear the watchdog as soon as the call reaches any usable state. The
|
||||||
|
// happy path (onCallJoined) clears it too; these cover earlier signals so a
|
||||||
|
// user sitting in the lobby/prescreen isn't flagged as an error.
|
||||||
|
this.disposables.push(this.onReady(() => this.settleLoad()));
|
||||||
|
this.disposables.push(this.onCapabilitiesNotified(() => this.settleLoad()));
|
||||||
|
this.disposables.push(this.onPreparingError(() => this.settleLoad('iframe')));
|
||||||
|
|
||||||
|
this.startLoadWatchdog();
|
||||||
|
|
||||||
let initialMediaEvent = true;
|
let initialMediaEvent = true;
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
@@ -314,6 +344,8 @@ export class CallEmbed {
|
|||||||
this.disposables.forEach((disposable) => {
|
this.disposables.forEach((disposable) => {
|
||||||
disposable();
|
disposable();
|
||||||
});
|
});
|
||||||
|
this.clearLoadWatchdog();
|
||||||
|
this.loadErrorListeners.clear();
|
||||||
this.styleRetryObserver?.disconnect();
|
this.styleRetryObserver?.disconnect();
|
||||||
this.call.stop();
|
this.call.stop();
|
||||||
this.container.removeChild(this.iframe);
|
this.container.removeChild(this.iframe);
|
||||||
@@ -329,7 +361,57 @@ export class CallEmbed {
|
|||||||
this.eventsToFeed = new WeakSet<MatrixEvent>();
|
this.eventsToFeed = new WeakSet<MatrixEvent>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startLoadWatchdog(): void {
|
||||||
|
if (this.loadWatchdog !== undefined) return;
|
||||||
|
this.loadWatchdog = setTimeout(() => {
|
||||||
|
this.settleLoad('timeout');
|
||||||
|
}, CALL_LOAD_WATCHDOG_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearLoadWatchdog(): void {
|
||||||
|
if (this.loadWatchdog !== undefined) {
|
||||||
|
clearTimeout(this.loadWatchdog);
|
||||||
|
this.loadWatchdog = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the load lifecycle as settled. Called on success (no reason) or on
|
||||||
|
* failure (reason set). Idempotent so the first signal wins.
|
||||||
|
*/
|
||||||
|
private settleLoad(reason?: CallLoadErrorReason): void {
|
||||||
|
if (this.loadSettled) return;
|
||||||
|
this.loadSettled = true;
|
||||||
|
this.clearLoadWatchdog();
|
||||||
|
if (reason) {
|
||||||
|
this.loadError = reason;
|
||||||
|
this.loadErrorListeners.forEach((cb) => cb(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the call failed to load within the watchdog window or errored.
|
||||||
|
*/
|
||||||
|
public get loadFailed(): CallLoadErrorReason | undefined {
|
||||||
|
return this.loadError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to load-failure events (watchdog timeout or iframe error). If the
|
||||||
|
* load has already failed by the time of subscription, the callback fires
|
||||||
|
* immediately so late subscribers still see the error.
|
||||||
|
* @returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
public onLoadError(callback: (reason: CallLoadErrorReason) => void): () => void {
|
||||||
|
this.loadErrorListeners.add(callback);
|
||||||
|
if (this.loadError) callback(this.loadError);
|
||||||
|
return () => {
|
||||||
|
this.loadErrorListeners.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private onCallJoined(): void {
|
private onCallJoined(): void {
|
||||||
|
this.settleLoad();
|
||||||
this.joined = true;
|
this.joined = true;
|
||||||
this.applyStyles();
|
this.applyStyles();
|
||||||
this.control.startObserving();
|
this.control.startObserving();
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export type NoiseSuppressionMode = 'off' | 'browser' | 'ml';
|
|||||||
// CDN. Its wasm is single-threaded, so no COOP/COEP cross-origin isolation is
|
// CDN. Its wasm is single-threaded, so no COOP/COEP cross-origin isolation is
|
||||||
// required (see LOTUS_DENOISE_ENGINEERING_REVIEW.md).
|
// required (see LOTUS_DENOISE_ENGINEERING_REVIEW.md).
|
||||||
export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
|
export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
|
||||||
|
// Incoming-call ringtone. 'classic' is the bundled call.ogg clip; 'chime' /
|
||||||
|
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
|
||||||
|
// 'none' is silent (visual-only incoming-call UI).
|
||||||
|
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
|
||||||
export type ChatBackground =
|
export type ChatBackground =
|
||||||
| 'none'
|
| 'none'
|
||||||
| 'blueprint'
|
| 'blueprint'
|
||||||
@@ -148,6 +152,7 @@ export interface Settings {
|
|||||||
afkTimeoutMinutes: number;
|
afkTimeoutMinutes: number;
|
||||||
|
|
||||||
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
|
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
|
||||||
|
ringtoneId: RingtoneId;
|
||||||
ringtoneVolume: number; // 0–100
|
ringtoneVolume: number; // 0–100
|
||||||
|
|
||||||
seasonalThemeOverride:
|
seasonalThemeOverride:
|
||||||
@@ -243,6 +248,7 @@ const defaultSettings: Settings = {
|
|||||||
afkTimeoutMinutes: 10,
|
afkTimeoutMinutes: 10,
|
||||||
|
|
||||||
callJoinLeaveSound: 'chime',
|
callJoinLeaveSound: 'chime',
|
||||||
|
ringtoneId: 'classic',
|
||||||
ringtoneVolume: 70,
|
ringtoneVolume: 70,
|
||||||
|
|
||||||
seasonalThemeOverride: 'auto',
|
seasonalThemeOverride: 'auto',
|
||||||
@@ -273,6 +279,15 @@ export const getSettings = (): Settings => {
|
|||||||
saved.callDenoiseModel === 'deepfilternet'
|
saved.callDenoiseModel === 'deepfilternet'
|
||||||
? saved.callDenoiseModel
|
? saved.callDenoiseModel
|
||||||
: defaultSettings.callDenoiseModel,
|
: defaultSettings.callDenoiseModel,
|
||||||
|
// Coerce any unknown persisted ringtone id back to the default.
|
||||||
|
ringtoneId:
|
||||||
|
saved.ringtoneId === 'classic' ||
|
||||||
|
saved.ringtoneId === 'chime' ||
|
||||||
|
saved.ringtoneId === 'soft' ||
|
||||||
|
saved.ringtoneId === 'retro' ||
|
||||||
|
saved.ringtoneId === 'none'
|
||||||
|
? saved.ringtoneId
|
||||||
|
: defaultSettings.ringtoneId,
|
||||||
composerToolbarButtons: {
|
composerToolbarButtons: {
|
||||||
...DEFAULT_COMPOSER_TOOLBAR,
|
...DEFAULT_COMPOSER_TOOLBAR,
|
||||||
...(saved.composerToolbarButtons ?? {}),
|
...(saved.composerToolbarButtons ?? {}),
|
||||||
|
|||||||
+92
-25
@@ -5,6 +5,7 @@ import {
|
|||||||
} from 'browser-encrypt-attachment';
|
} from 'browser-encrypt-attachment';
|
||||||
import {
|
import {
|
||||||
EventTimeline,
|
EventTimeline,
|
||||||
|
EventType,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixError,
|
MatrixError,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
|
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
|
||||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
import { MDirectContent } from '../../types/matrix/accountData';
|
||||||
import { getStateEvent } from './room';
|
import { getStateEvent } from './room';
|
||||||
import { Membership, StateEvent } from '../../types/matrix/room';
|
import { Membership, StateEvent } from '../../types/matrix/room';
|
||||||
|
|
||||||
@@ -145,6 +146,42 @@ export type ContentUploadOptions = {
|
|||||||
onError: (error: MatrixError) => void;
|
onError: (error: MatrixError) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build a MatrixError defensively from an unexpected upload response.
|
||||||
|
// MatrixError's constructor expects an IErrorJson ({ errcode?, error?, ... }), not a
|
||||||
|
// raw UploadResponse, so we guard the shape and only forward string fields it understands.
|
||||||
|
const matrixErrorFromUploadResponse = (data: UploadResponse): MatrixError => {
|
||||||
|
const errorJson: { errcode?: string; error?: string } = {};
|
||||||
|
const maybe = data as Partial<{ errcode: unknown; error: unknown }>;
|
||||||
|
if (typeof maybe.errcode === 'string') errorJson.errcode = maybe.errcode;
|
||||||
|
if (typeof maybe.error === 'string') errorJson.error = maybe.error;
|
||||||
|
if (!errorJson.error) errorJson.error = 'Upload failed: missing content_uri in response';
|
||||||
|
return new MatrixError(errorJson);
|
||||||
|
};
|
||||||
|
|
||||||
|
const matrixErrorFromUnknown = (e: unknown): MatrixError => {
|
||||||
|
if (e instanceof MatrixError) return e;
|
||||||
|
const err = e as Partial<{ message: unknown; errcode: unknown }> | null | undefined;
|
||||||
|
const error = typeof err?.message === 'string' ? err.message : 'Upload failed';
|
||||||
|
const errcode = typeof err?.errcode === 'string' ? err.errcode : undefined;
|
||||||
|
return new MatrixError({ error, errcode });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const isRetryableUploadError = (e: unknown): boolean => {
|
||||||
|
if (e instanceof MatrixError) {
|
||||||
|
const status = e.httpStatus;
|
||||||
|
// No status => network/transport failure (transient): retry.
|
||||||
|
if (typeof status !== 'number') return true;
|
||||||
|
// Retry on rate-limiting and server-side (5xx) errors only.
|
||||||
|
return status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
// Non-Matrix errors are typically network/transport failures: retry.
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UPLOAD_MAX_RETRY_COUNT = 3;
|
||||||
|
|
||||||
export const uploadContent = async (
|
export const uploadContent = async (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
file: TUploadContent,
|
file: TUploadContent,
|
||||||
@@ -152,23 +189,53 @@ export const uploadContent = async (
|
|||||||
) => {
|
) => {
|
||||||
const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
|
const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
|
||||||
|
|
||||||
const uploadPromise = mx.uploadContent(file, {
|
const sleepForMs = (ms: number) =>
|
||||||
name,
|
new Promise((resolve) => {
|
||||||
type: fileType,
|
setTimeout(resolve, ms);
|
||||||
includeFilename: !hideFilename,
|
});
|
||||||
progressHandler: onProgress,
|
|
||||||
});
|
let lastError: MatrixError | undefined;
|
||||||
onPromise?.(uploadPromise);
|
|
||||||
try {
|
for (let retryCount = 0; retryCount <= UPLOAD_MAX_RETRY_COUNT; retryCount += 1) {
|
||||||
const data = await uploadPromise;
|
const uploadPromise = mx.uploadContent(file, {
|
||||||
const mxc = data.content_uri;
|
name,
|
||||||
if (mxc) onSuccess(mxc);
|
type: fileType,
|
||||||
else onError(new MatrixError(data));
|
includeFilename: !hideFilename,
|
||||||
} catch (e: any) {
|
progressHandler: onProgress,
|
||||||
const error = typeof e?.message === 'string' ? e.message : undefined;
|
});
|
||||||
const errcode = typeof e?.name === 'string' ? e.message : undefined;
|
onPromise?.(uploadPromise);
|
||||||
onError(new MatrixError({ error, errcode }));
|
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const data = await uploadPromise;
|
||||||
|
const mxc = data.content_uri;
|
||||||
|
if (mxc) {
|
||||||
|
onSuccess(mxc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Missing content_uri is not a transient failure — fail immediately.
|
||||||
|
onError(matrixErrorFromUploadResponse(data));
|
||||||
|
return;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
lastError = matrixErrorFromUnknown(e);
|
||||||
|
|
||||||
|
if (retryCount === UPLOAD_MAX_RETRY_COUNT || !isRetryableUploadError(e)) {
|
||||||
|
onError(lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect server Retry-After header; fall back to capped exponential backoff,
|
||||||
|
// mirroring rateLimitedActions (min(1000 * 2^retryCount, 30_000)ms).
|
||||||
|
const waitMS =
|
||||||
|
(e instanceof MatrixError ? e.getRetryAfterMs() : null) ??
|
||||||
|
Math.min(1000 * 2 ** retryCount, 30_000);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await sleepForMs(waitMS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unreachable in practice, but keeps onError guaranteed if the loop exits.
|
||||||
|
if (lastError) onError(lastError);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs();
|
export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs();
|
||||||
@@ -230,11 +297,11 @@ export const addRoomIdToMDirect = async (
|
|||||||
roomId: string,
|
roomId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
|
const mDirectsEvent = mx.getAccountData(EventType.Direct);
|
||||||
let userIdToRoomIds: Record<string, string[]> = {};
|
let userIdToRoomIds: MDirectContent = {};
|
||||||
|
|
||||||
if (typeof mDirectsEvent !== 'undefined')
|
if (typeof mDirectsEvent !== 'undefined')
|
||||||
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
|
userIdToRoomIds = structuredClone(mDirectsEvent.getContent<MDirectContent>());
|
||||||
|
|
||||||
// remove it from the lists of any others users
|
// remove it from the lists of any others users
|
||||||
// (it can only be a DM room for one person)
|
// (it can only be a DM room for one person)
|
||||||
@@ -255,15 +322,15 @@ export const addRoomIdToMDirect = async (
|
|||||||
}
|
}
|
||||||
userIdToRoomIds[userId] = roomIds;
|
userIdToRoomIds[userId] = roomIds;
|
||||||
|
|
||||||
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
|
await mx.setAccountData(EventType.Direct, userIdToRoomIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => {
|
export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => {
|
||||||
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
|
const mDirectsEvent = mx.getAccountData(EventType.Direct);
|
||||||
let userIdToRoomIds: Record<string, string[]> = {};
|
let userIdToRoomIds: MDirectContent = {};
|
||||||
|
|
||||||
if (typeof mDirectsEvent !== 'undefined')
|
if (typeof mDirectsEvent !== 'undefined')
|
||||||
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
|
userIdToRoomIds = structuredClone(mDirectsEvent.getContent<MDirectContent>());
|
||||||
|
|
||||||
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
|
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
|
||||||
const roomIds = userIdToRoomIds[targetUserId];
|
const roomIds = userIdToRoomIds[targetUserId];
|
||||||
@@ -273,7 +340,7 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string):
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
|
await mx.setAccountData(EventType.Direct, userIdToRoomIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mxcUrlToHttp = (
|
export const mxcUrlToHttp = (
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import CallSound from '../../../public/sound/call.ogg';
|
||||||
|
import { RingtoneId } from '../state/settings';
|
||||||
|
|
||||||
|
export const RINGTONE_OPTIONS: { value: RingtoneId; label: string }[] = [
|
||||||
|
{ value: 'classic', label: 'Classic' },
|
||||||
|
{ value: 'chime', label: 'Chime' },
|
||||||
|
{ value: 'soft', label: 'Soft' },
|
||||||
|
{ value: 'retro', label: 'Retro' },
|
||||||
|
{ value: 'none', label: 'Silent' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isRingtoneId = (v: unknown): v is RingtoneId =>
|
||||||
|
v === 'classic' || v === 'chime' || v === 'soft' || v === 'retro' || v === 'none';
|
||||||
|
|
||||||
|
type SynthStyle = 'chime' | 'soft' | 'retro';
|
||||||
|
|
||||||
|
const clamp01 = (n: number): number => Math.max(0, Math.min(1, n));
|
||||||
|
|
||||||
|
// Shared WebAudio context for synthesized ringtones. Kept separate from the
|
||||||
|
// join/leave-sound context (callSounds.ts) to keep blast radius small.
|
||||||
|
let sharedCtx: AudioContext | undefined;
|
||||||
|
const getCtx = (): AudioContext | undefined => {
|
||||||
|
try {
|
||||||
|
if (!sharedCtx || sharedCtx.state === 'closed') sharedCtx = new AudioContext();
|
||||||
|
if (sharedCtx.state === 'suspended') sharedCtx.resume().catch(() => undefined);
|
||||||
|
return sharedCtx;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type Note = {
|
||||||
|
freq: number;
|
||||||
|
/** Offset from phrase start, in seconds */
|
||||||
|
at: number;
|
||||||
|
/** Duration in seconds */
|
||||||
|
dur: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One looping phrase per synth style + the period before it repeats.
|
||||||
|
const PHRASES: Record<
|
||||||
|
SynthStyle,
|
||||||
|
{ type: OscillatorType; gain: number; period: number; notes: Note[] }
|
||||||
|
> = {
|
||||||
|
// Two-tone "ring … ring" telephone cadence.
|
||||||
|
chime: {
|
||||||
|
type: 'sine',
|
||||||
|
gain: 0.3,
|
||||||
|
period: 3,
|
||||||
|
notes: [
|
||||||
|
{ freq: 587.33, at: 0, dur: 0.35 },
|
||||||
|
{ freq: 880, at: 0.4, dur: 0.35 },
|
||||||
|
{ freq: 587.33, at: 1.0, dur: 0.35 },
|
||||||
|
{ freq: 880, at: 1.4, dur: 0.35 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Gentle rising triangle pair.
|
||||||
|
soft: {
|
||||||
|
type: 'triangle',
|
||||||
|
gain: 0.24,
|
||||||
|
period: 3.2,
|
||||||
|
notes: [
|
||||||
|
{ freq: 523.25, at: 0, dur: 0.5 },
|
||||||
|
{ freq: 659.25, at: 0.55, dur: 0.7 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Retro arpeggio sweep.
|
||||||
|
retro: {
|
||||||
|
type: 'square',
|
||||||
|
gain: 0.12,
|
||||||
|
period: 2.4,
|
||||||
|
notes: [
|
||||||
|
{ freq: 440, at: 0, dur: 0.12 },
|
||||||
|
{ freq: 554.37, at: 0.13, dur: 0.12 },
|
||||||
|
{ freq: 659.25, at: 0.26, dur: 0.12 },
|
||||||
|
{ freq: 880, at: 0.39, dur: 0.22 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const playPhrase = (style: SynthStyle, volume: number): void => {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
const { type, gain: peak, notes } = PHRASES[style];
|
||||||
|
const scaledPeak = peak * clamp01(volume);
|
||||||
|
if (scaledPeak <= 0) return;
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
notes.forEach(({ freq, at, dur }) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = type;
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
const start = now + at;
|
||||||
|
// Short attack + exponential decay to avoid clicks.
|
||||||
|
gain.gain.setValueAtTime(0, start);
|
||||||
|
gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(start);
|
||||||
|
osc.stop(start + dur + 0.02);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startClassic = (volume: number, loop: boolean): (() => void) => {
|
||||||
|
let audio: HTMLAudioElement | undefined;
|
||||||
|
try {
|
||||||
|
audio = new Audio(CallSound);
|
||||||
|
audio.loop = loop;
|
||||||
|
audio.volume = clamp01(volume);
|
||||||
|
audio.play().catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
audio = undefined;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (!audio) return;
|
||||||
|
audio.pause();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
audio = undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => {
|
||||||
|
playPhrase(style, volume);
|
||||||
|
if (!loop) return () => undefined;
|
||||||
|
const period = PHRASES[style].period * 1000;
|
||||||
|
const id = window.setInterval(() => playPhrase(style, volume), period);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start an incoming-call ringtone, looping until the returned stop fn is
|
||||||
|
* called. `volume` is 0..1. Returns a no-op stop fn for 'none'.
|
||||||
|
*
|
||||||
|
* Synthesized styles share the WebAudio autoplay limitation of the bundled
|
||||||
|
* 'classic' file: until the page has had a user gesture the browser may keep
|
||||||
|
* audio suspended, so the very first ring after a cold page load can be
|
||||||
|
* silent. This matches the pre-existing behaviour of the classic ringtone.
|
||||||
|
*/
|
||||||
|
export const startRingtone = (id: RingtoneId, volume: number): (() => void) => {
|
||||||
|
if (id === 'none') return () => undefined;
|
||||||
|
if (id === 'classic') return startClassic(volume, true);
|
||||||
|
return startSynth(id, volume, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only one preview may sound at a time; starting a new one cancels the last.
|
||||||
|
let activePreviewStop: (() => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a single, non-looping preview of a ringtone (used by Settings).
|
||||||
|
* Auto-stops the bundled 'classic' clip after a few seconds and cancels any
|
||||||
|
* previously-playing preview. Returns a stop fn for early cancellation.
|
||||||
|
*/
|
||||||
|
export const previewRingtone = (id: RingtoneId, volume: number): (() => void) => {
|
||||||
|
activePreviewStop?.();
|
||||||
|
activePreviewStop = null;
|
||||||
|
if (id === 'none') return () => undefined;
|
||||||
|
|
||||||
|
const stop = id === 'classic' ? startClassic(volume, false) : startSynth(id, volume, false);
|
||||||
|
let timer = 0;
|
||||||
|
const wrapped = () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
stop();
|
||||||
|
if (activePreviewStop === wrapped) activePreviewStop = null;
|
||||||
|
};
|
||||||
|
timer = window.setTimeout(wrapped, 4000);
|
||||||
|
activePreviewStop = wrapped;
|
||||||
|
return wrapped;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user