Compare commits

..

16 Commits

Author SHA1 Message Date
jared 31cf353463 docs(testing): note EC watchdog self-heal in A7
CI / Build & Quality Checks (push) Successful in 10m26s
CI / Trigger Desktop Build (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:16:22 -04:00
jared 8912423aeb i18n: complete DeviceVerification + PasswordStage dialog translation
Review flagged that wrapping only the buttons left the dialog body copy
hardcoded (mixed-language dialogs once a non-en locale ships). Wrap the
remaining body/waiting strings ("Please accept…", "Confirm the emoji…",
"Do not Match", "Your device is verified.", etc.) and the PasswordStage
prompt, adding hooks to the sub-components that lacked one. Keys added to
en.json; all t() keys verified to resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:15:51 -04:00
jared bc85cd4984 fix(calls,matrix): address review findings from agent code review
- CallEmbed watchdog now SELF-HEALS: a genuine ready/joined signal arriving
  after the 25s timeout clears the error and notifies subscribers with
  undefined, so a slow-but-successful EC load no longer strands the user on
  the recovery screen over a live call. Listener dispatch wrapped in try/catch.
- ringtones: synth notes route through a per-session master gain; stop() ramps
  it to 0 so the ring is silenced instantly on answer instead of letting the
  last scheduled phrase ring out over call audio.
- IncomingCallBanner: ping fires exactly once per incoming call (guarded by
  refEventId) instead of re-pinging when ringtone settings change mid-banner.
- focusCameraParticipant: try multiple tile selectors (EC labels vary by
  version), defer the tile click past EC's async spotlight layout switch
  (rAF x2), and dev-warn when no tile matches so testers get signal.
- uploadContent: a cancelled upload (mx.cancelUpload -> AbortError) is no
  longer treated as retryable — previously the retry loop could resurrect an
  upload the user just cancelled. Also retry on 408.
- addRoomIdToMDirect/removeRoomIdFromMDirect: guard against a corrupt m.direct
  whose values aren't arrays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:15:51 -04:00
jared fc8eb70617 docs(bugs): mark 20 localization rows FIXED
CI / Build & Quality Checks (push) Successful in 10m20s
CI / Trigger Desktop Build (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:43:59 -04:00
jared 1a5896ef84 i18n: localize hardcoded UI strings across 10 components
Wraps the hardcoded strings flagged in LOTUS_BUGS.md (Localization rows)
in t() via react-i18next, and adds the keys to public/locales/en.json
under the existing Organisms.* namespace. de.json intentionally left to
fall back to en for now (fallbackLng: 'en') rather than fabricate
translations.

Files: CreateRoomTypeSelector, ImageViewer, MsgTypeRenderers (MLocation),
Reply (ThreadIndicator), ImageContent, DeviceVerification (5 subcomponents),
UrlPreviewCard (DiscordCard), InviteUserPrompt, UploadBoard, PasswordStage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:43:36 -04:00
jared 7b94eeaa60 docs: mark N53/N81/N82 fixed; add F3/G3 visual checks to testing guide
CI / Build & Quality Checks (push) Successful in 10m27s
CI / Trigger Desktop Build (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:29:37 -04:00
jared 50076962f6 fix(ui): collapse PTT badge to single folds Chip (N53); responsive bg pickers (N81)
N53: removed the duplicate lotusTerminal PTT-badge branch (raw <Box> with
--lt-* vars + bespoke rem/animation styling). The standard folds <Chip>
path now renders in all modes; TDS theming still flows through the CSS var
layer. Dropped the now-unused lotusTerminal read.

N81: ChatBgGrid / SeasonalBgGrid containers switched from flex-wrap with
fixed-width cells to a responsive CSS grid (repeat(auto-fill, minmax(76px,
1fr))), so swatches fill the row evenly instead of orphaning a lopsided
last row at arbitrary widths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:21:33 -04:00
jared d39aef0aac docs: add backlog (E–K) of fixed-but-unverified items to testing guide
CI / Build & Quality Checks (push) Successful in 10m27s
CI / Trigger Desktop Build (push) Successful in 19s
Sweeps every remaining "FIXED ⚠️ UNTESTED" item from LOTUS_BUGS.md and
LOTUS_TODO.md into the testing guide, grouped by environment (mobile,
theming, calls, media/perf, accessibility/screen-reader, desktop/Tauri,
features) so each category can be verified in one pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:07:39 -04:00
jared 9f533b1077 docs: fix section C numbering in LOTUS_TESTING.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:04:42 -04:00
jared fdaba40ba9 docs: mark N4 fixed; add LOTUS_TESTING.md manual test guide
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:04:21 -04:00
jared caf6318a5d fix(poll): render vote buttons with folds tokens (N4)
Poll answer buttons referenced undefined CSS vars (--accent-cyan,
--accent-cyan-dim, --accent-cyan-border, --border-color) plus hardcoded
rgba()/#fff and raw rem font sizes, so they rendered unstyled on every
non-TDS theme (invisible borders, no selected/progress state).

Replace all colors with always-defined folds tokens (Primary.* for the
selected/indicator state, SurfaceVariant.* for the resting surface +
progress fill), size/spacing/radii with config.* tokens, and the
checkbox/radio glyphs + percentage/label text with folds <Text>. The
progress-bar-behind-text affordance is preserved (folds Button has no
equivalent), now theme-reactive. Merged the duplicate checkbox/radio
indicator spans into one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:02:13 -04:00
jared 23649d85b0 docs(bugs): mark #4 (DM/group call ringtone + in-call notify) FIXED
CI / Build & Quality Checks (push) Failing after 15m32s
CI / Trigger Desktop Build (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:45:41 -04:00
jared c67aed01dc feat(calls): non-intrusive incoming-call banner while already in a call (#4b)
Previously a second incoming call was dropped from the UI entirely when the
user was already in a call (`!joined && callInfo`). Now, when joined to a
different call, a compact corner banner (caller avatar + name + Answer/Reject)
is shown instead of the full-screen IncomingCall overlay, with a single soft
ping (one-shot ringtone) rather than the looping ring so it doesn't talk over
the active call. The full overlay still shows when not in any call; being in
the ringing room's own call still shows nothing.

Built with folds primitives + TDS tokens (no hardcoded colors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:45:18 -04:00
jared 66cc51d6d0 docs(bugs): update #1 (camera focus) and #4 (ringtone) statuses
CI / Build & Quality Checks (push) Successful in 10m56s
CI / Trigger Desktop Build (push) Successful in 37s
#1 documented as implemented (focusCameraParticipant + MemberGlance
"Focus camera" menu); #4 ringtone selection landed, with the remaining
active-call non-intrusive-notification work scoped and deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:38:05 -04:00
jared 4a87588435 feat(calls): selectable incoming-call ringtone (#4)
Adds a ringtoneId setting (classic | chime | soft | retro | none) so the
incoming-call ring is no longer hardcoded to call.ogg. The three synth
styles are generated in-browser via a new utils/ringtones.ts module
(mirroring the existing callSounds.ts WebAudio pattern), so no new binary
assets are bundled; 'classic' keeps the existing call.ogg clip and 'none'
is a silent, visual-only incoming-call UI.

- ringtones.ts: startRingtone() loops until stopped; previewRingtone()
  plays a single non-looping preview and auto-cancels the prior preview.
- IncomingCall: ring driven by the setting; <audio> element removed.
- Settings > Calls: Ringtone selector with on-select preview, beside the
  existing Ringtone Volume slider.
- settings.ts: persisted value whitelisted back to a known id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:25:32 -04:00
jared c0fd372529 docs(bugs): reconcile LOTUS_BUGS statuses with wave-1 fixes
CI / Build & Quality Checks (push) Successful in 10m35s
CI / Trigger Desktop Build (push) Successful in 14s
Mark items resolved by commits b7e1f89c / d2946c00 / 0394fce9 / 203568c9
as FIXED, and record the false-positives surfaced during the audit
(useMatrixEventRenderer null contract, Lobby getRoom already memoized,
RoomTimeline/RoomInput already wrapped by RoomView's ErrorBoundary).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:35:51 -04:00
22 changed files with 1263 additions and 415 deletions
+103 -100
View File
@@ -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 (0100, 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 (0100, 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` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Messages, photos, and videos." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN | | Localization | Hardcoded UI string: "Messages, photos, and videos." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Voice Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN | | Localization | Hardcoded UI string: "Voice Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Live audio and video conversations." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN | | Localization | Hardcoded UI string: "Live audio and video conversations." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Download" | `src/app/components/image-viewer/ImageViewer.tsx` | OPEN | | Localization | Hardcoded UI string: "Download" | `src/app/components/image-viewer/ImageViewer.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Open Location" | `src/app/components/message/MsgTypeRenderers.tsx` | OPEN | | Localization | Hardcoded UI string: "Open Location" | `src/app/components/message/MsgTypeRenderers.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | OPEN | | Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | OPEN | | Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | OPEN | | Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | OPEN | | Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | OPEN | | Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | OPEN | | Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | OPEN | | Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | OPEN | | Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | OPEN | | Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | OPEN | | Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN | | Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN | | Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN | | Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Password" | `src/app/components/uia-stages/PasswordStage.tsx` | OPEN | | Localization | Hardcoded UI string: "Password" | `src/app/components/uia-stages/PasswordStage.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Bundle Size | Large unoptimized media asset (213KB) | `public/res/Lotus.png` | OPEN | | Bundle Size | Large unoptimized media asset (213KB) | `public/res/Lotus.png` | OPEN |
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/features/lobby/Lobby.tsx` | 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 113137) 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 151171) 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 250358 - **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.
--- ---
@@ -373,8 +376,8 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N53 — PTT Badge (Lotus Terminal path): Raw `<div>` tree with `--lt-*` CSS vars instead of folds `<Chip>`** **N53 — PTT Badge (Lotus Terminal path): Raw `<div>` tree with `--lt-*` CSS vars instead of folds `<Chip>`**
- **File:** `src/app/features/call/CallControls.tsx`, lines 242282 - **File:** `src/app/features/call/CallControls.tsx`
- **Status:** **OPEN** - **Status:** **FIXED** (`50076962`) — removed the `lotusTerminal` branch entirely; the PTT badge is now the single folds `<Chip variant={pttActive ? 'Success' : 'Warning'} fill="Soft" radii="400" outlined>` path for all themes (TDS styling still flows through the CSS-variable layer over the Chip). Dropped the now-unused `lotusTerminal` read. Build-verified; visual parity to confirm only if you specifically used the terminal-mode PTT look.
- **Issue:** When `lotusTerminal` is true the PTT badge renders as a bare `<Box>` with inline styles referencing `--lt-accent-green-dim`, `--lt-accent-green-border`, `--lt-accent-green` — variables absent outside TDS mode — hardcoded rem padding, `borderRadius: '99px'` (non-token), a raw monospace `fontFamily` string, non-token `letterSpacing`, and a raw `animation:` CSS string for the live-pulse dot. The live `●` dot is a raw `<span>` with inline style. - **Issue:** When `lotusTerminal` is true the PTT badge renders as a bare `<Box>` with inline styles referencing `--lt-accent-green-dim`, `--lt-accent-green-border`, `--lt-accent-green` — variables absent outside TDS mode — hardcoded rem padding, `borderRadius: '99px'` (non-token), a raw monospace `fontFamily` string, non-token `letterSpacing`, and a raw `animation:` CSS string for the live-pulse dot. The live `●` dot is a raw `<span>` with inline style.
- **Root Cause:** Two entirely separate component trees for the same badge depending on a theme boolean. The non-terminal path (lines 284301) uses the correct `<Chip variant="Success"|"Warning" fill="Soft" radii="400" outlined>`. - **Root Cause:** Two entirely separate component trees for the same badge depending on a theme boolean. The non-terminal path (lines 284301) uses the correct `<Chip variant="Success"|"Warning" fill="Soft" radii="400" outlined>`.
- **Fix:** Remove the terminal branch. The standard `<Chip>` path already exists and TDS theming can be applied via the CSS variable layer without a separate component tree. - **Fix:** Remove the terminal branch. The standard `<Chip>` path already exists and TDS theming can be applied via the CSS variable layer without a separate component tree.
@@ -437,8 +440,8 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
| N78 | HasLink Chip Active Color | `SearchFilters.tsx` | 755 | `HasLink` active state uses `variant="Primary"` (blue); all boolean scope-toggle chips in the same bar use `variant="Success"` (green) with `outlined`**FIXED**: changed to `variant={containsUrl ? 'Success' : 'SurfaceVariant'} outlined={!!containsUrl}` | `variant="Success" outlined` is the established active-state pattern for boolean toggles in the filter bar | | N78 | HasLink Chip Active Color | `SearchFilters.tsx` | 755 | `HasLink` active state uses `variant="Primary"` (blue); all boolean scope-toggle chips in the same bar use `variant="Success"` (green) with `outlined`**FIXED**: changed to `variant={containsUrl ? 'Success' : 'SurfaceVariant'} outlined={!!containsUrl}` | `variant="Success" outlined` is the established active-state pattern for boolean toggles in the filter bar |
| N79 | Server Notice Chip Radii | `RoomViewHeader.tsx` | 570 | `<Chip size="400" radii="Pill">``Pill` radii on a room-type label — **FIXED**: changed to `radii="300"` | Room/space type labels in lobby (`RoomItem.tsx:83`, `SpaceItem.tsx:63`) use `radii="300"`; `radii="Pill"` is for filter/tag chips only | | N79 | Server Notice Chip Radii | `RoomViewHeader.tsx` | 570 | `<Chip size="400" radii="Pill">``Pill` radii on a room-type label — **FIXED**: changed to `radii="300"` | Room/space type labels in lobby (`RoomItem.tsx:83`, `SpaceItem.tsx:63`) use `radii="300"`; `radii="Pill"` is for filter/tag chips only |
| N80 | Server Support Contact Layout | `About.tsx` | 172239 | Homeserver support contacts rendered as raw `<Box direction="Column">` with `<Text as="a">` pairs — custom label/link layout — **WON'T FIX (deliberate)**: a contact is `role → {matrix_id?, email?, …}` (one-to-many links per role), which doesn't map onto `SettingTile`'s single `title`/`description`/`after` slots without contortion. The current layout already uses folds `Box`/`Text`/`SequenceCard` + tokens and `Text as="a"` (a valid folds pattern); no undefined vars or raw HTML chrome. | All other `<SequenceCard>` content in `About.tsx` and `General.tsx` uses `<SettingTile title="..." description="..." after={...}>` as the content unit | | N80 | Server Support Contact Layout | `About.tsx` | 172239 | Homeserver support contacts rendered as raw `<Box direction="Column">` with `<Text as="a">` pairs — custom label/link layout — **WON'T FIX (deliberate)**: a contact is `role → {matrix_id?, email?, …}` (one-to-many links per role), which doesn't map onto `SettingTile`'s single `title`/`description`/`after` slots without contortion. The current layout already uses folds `Box`/`Text`/`SequenceCard` + tokens and `Text as="a"` (a valid folds pattern); no undefined vars or raw HTML chrome. | All other `<SequenceCard>` content in `About.tsx` and `General.tsx` uses `<SettingTile title="..." description="..." after={...}>` as the content unit |
| N81 | Background Picker Grid — No Responsive Layout | `General.tsx` | 17071742 | Fixed `width: toRem(76)` flex-wrap cells with no `minWidth` floor or CSS grid `auto-fill` — SeasonalBgGrid's 13 items produce a visually lopsided orphan last row at any viewport width | Cinny's native grids use `grid-template-columns: repeat(auto-fill, minmax(N, 1fr))` or equivalent for responsive fill | | N81 | Background Picker Grid — No Responsive Layout | `General.tsx` | 17071742 | Fixed `width: toRem(76)` flex-wrap cells with no `minWidth` floor or CSS grid `auto-fill` — SeasonalBgGrid's 13 items produce a visually lopsided orphan last row at any viewport width **FIXED** (`50076962`): both `ChatBgGrid` and `SeasonalBgGrid` containers switched to `display: grid; grid-template-columns: repeat(auto-fill, minmax(toRem(76), 1fr))`, so swatches fill each row evenly | Cinny's native grids use `grid-template-columns: repeat(auto-fill, minmax(N, 1fr))` or equivalent for responsive fill |
| N82 | Join/Leave Sounds Auto-Preview | `General.tsx` | 15921609 | Selecting a sound in the dropdown immediately plays a preview, but no UI affordance (button label, description text, or "▶ Preview" button) communicates this to the user | Settings tiles with side effects on selection (theme picker, chat background) show a live visual preview or a dedicated control explaining the side effect | | N82 | Join/Leave Sounds Auto-Preview | `General.tsx` | 15921609 | Selecting a sound in the dropdown immediately plays a preview, but no UI affordance communicates this to the user — **FIXED**: the tile description now reads "…Selecting an option plays a preview." (the same affordance was applied to the new Ringtone selector) | Settings tiles with side effects on selection (theme picker, chat background) show a live visual preview or a dedicated control explaining the side effect |
--- ---
+351
View File
@@ -0,0 +1,351 @@
# 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.
- **Self-heal:** if the error overlay appears on a slow network but EC then finishes loading anyway, the overlay should **dismiss itself** and drop you into the live call (it no longer strands you on the error screen). Worth confirming on a deliberately throttled-but-not-blocked connection.
---
## B. Polls (N4) — render correctly on non-TDS themes
This was the actual bug: poll buttons used undefined CSS variables, so on the **default (non-Lotus-Terminal) themes** they rendered with invisible borders / no selected state.
### B1. Poll renders on a default theme
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 AD 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 AD are; do them as you have the right device handy.
## E. Mobile / responsive (needs a real phone, or devtools device emulation)
### E1. Composer toolbar touch targets (#7)
On a phone, open a room and the composer toolbar. Tap each button (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
**Expected:** every button is comfortably tappable (≥44×44px), no mis-taps hitting the wrong icon.
### E2. Room Settings — no horizontal overflow (#8)
On a narrow phone screen, open **Room Settings**.
**Expected:** the settings nav panel fills the full width; **no** horizontal scrollbar / sideways scrolling anywhere in the panel.
### E3. Modals go fullscreen on mobile (#9)
On a phone, open several dialogs: Leave Room, Create Room, Create Space, Invite User, Report (room/user/message), Edit History, Forward Message, Remind Me, Schedule Message, Device Verification, Poll Creator.
**Expected:** each opens **fullscreen** (no floating box, no rounded corners / max-width margins). On desktop the same modals should still be the normal centered boxes.
### E4. Composer not hidden by the keyboard (#10) — iOS Safari especially
On a phone (priority: **iOS Safari**), tap into the composer so the on-screen keyboard appears.
**Expected:** the composer input stays **visible above** the keyboard; the layout shrinks rather than the composer sliding under the keyboard.
### E5. Mobile "Saved Messages" access (Mobile Bookmarks)
On a phone, **inside a room**, open the room header **··· More Options** menu.
**Expected:** a **"Saved Messages"** item is present; tapping it opens the bookmarks panel. (This was the only in-room access point missing on mobile.)
---
## F. Visual / theming
### F1. Animated chat background — no flicker (#2)
Settings → set an **animated** chat background (e.g. anim-rain / anim-aurora / anim-stars). Watch the message text and composer while it animates.
**Expected:** smooth animation, **no flickering / shimmering** on message text or the composer, especially after scrolling. Note your GPU/browser if you see artifacts.
### F2. Background vs. Seasonal theme are mutually exclusive (#6)
In Settings → Appearance:
1. Pick a **chat background** → confirm any **seasonal theme** auto-switches off.
2. Pick a **seasonal theme** → confirm the **chat background** auto-clears to none.
3. (Edge) If you have old data with both set, after reload only one should visibly apply (no double-overlay clutter).
### F3. Background / seasonal picker grid layout (N81)
In Settings → Appearance, look at the **Chat Background** and **Seasonal Theme** swatch grids; resize the window narrow→wide.
**Expected:** swatches reflow to fill each row evenly (responsive grid), with no lopsided/orphaned last row at any width.
---
## G. Calls — additional unverified (👥 2 people)
### G1. PiP mute badges point at the right person (#12)
In a call with at least one other person, pop out the **Picture-in-Picture** mini window.
- **You** mute your own mic → a **"You"/muted badge appears bottom-left** (your status).
- A **remote** participant (or all of them) mutes → an **"All muted"** badge appears **top-right** (clearly about other people).
**Expected:** the bottom-left badge is **never** triggered by someone else muting — that was the original bug (it looked like your own mic was muted when it wasn't).
### G2. Full-screen camera broadcasts
1. In a **camera-only** call (no screenshare), confirm the **Fullscreen** button is available (previously only showed during screenshare).
2. Use **MemberGlance → Focus camera** to full-screen/spotlight a specific person's camera. (Overlaps **A5**; if you've done A5 you can skip.)
### G3. PTT badge renders on all themes (N53)
Enable **Push-to-talk** (Settings → Calls) and join a call. Hold the PTT key.
**Expected:** the floating PTT badge above the controls shows "PTT — Hold KEY" when idle and "● Live" (green) while held — on **both** a default theme and Lotus Terminal (it's now a single folds Chip; the old terminal-only variant was removed).
---
## H. Media / performance (needs a room with many images)
### H1. Lazy image decryption (P5-5 / MediaGallery)
Open a room / media gallery with **many images** (ideally encrypted). Scroll down through them.
**Expected:** images decrypt/load as they **approach the viewport**, not all at once on open; scrolling stays smooth and memory doesn't balloon. Off-screen images shouldn't all decode up front.
### H2. Thumbnail framing (P5-6)
Look at **tall portrait** images in the timeline and in the media gallery.
**Expected:** thumbnails are framed **center-top** (so faces/subjects at the top aren't cropped out); no awkward stretching. Opening the full-size viewer still shows the **whole** image (contain, not cropped).
---
## I. Accessibility (needs a screen reader: VoiceOver / NVDA / TalkBack)
With a screen reader on, navigate message hover-actions and content and confirm each control **announces a meaningful label** (not "button" / blank):
- [ ] **Reaction** buttons announce the emoji + count (e.g. "thumbsup reaction, 3 people").
- [ ] **Edit history** button announces "View edit history".
- [ ] **Thread indicator** announces "View thread".
- [ ] **Reply** (jump to original) announces "Jump to original message".
---
## J. Desktop / Tauri build only
### J1. Proactive update notifications (P5-40)
In the **desktop (Tauri)** build, with an update available, launch the app (and/or leave it running ~12h).
**Expected:** an in-app toast/badge alerts you that an update is available, without manually checking Settings. (Needs an actual newer release to point at.)
### J2. DTLN noise suppression sanity
In Settings → Calls, enable **ML noise suppression** with the **DTLN** model, then join a call.
**Expected:** your mic audio still flows (no silence/robotic dropouts) and background noise is reduced. Confirmed working earlier but flagged for a final real-call check; verify on **both** web and desktop.
---
## K. Features — end-to-end unverified
### K1. Remind Me Later
On a message, **··· → Remind Me**, pick a short preset (the 20-min one, or wait one out).
**Expected:** when due, a Lotus toast fires linking to that message; the reminder then clears itself. Survives a reload while pending (stored in account data).
### K2. Advanced search filters (P4-9)
In message search: use the **sender picker** (instead of typing `from:@user`), the **date-range** quick presets (Today / Last week / Last month / Last year), and the **Has link** toggle.
**Expected:** each narrows results correctly and reflects in the search.
### K3. Notification content + click target (P5-20 partial)
Trigger a desktop/browser notification for a new message.
**Expected:** it shows the **real message body** (`username: message`, not "New inbox notification from…"); **clicking it** brings the window to front and navigates **directly to that message** (not just the inbox).
---
## Priority if you're short on time
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
2. **B1B3** (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.
+51
View File
@@ -2,6 +2,57 @@
"Organisms": { "Organisms": {
"RoomCommon": { "RoomCommon": {
"changed_room_name": " changed room name" "changed_room_name": " changed room name"
},
"CreateRoom": {
"chat_room": "Chat Room",
"chat_room_desc": "Messages, photos, and videos.",
"voice_room": "Voice Room",
"voice_room_desc": "Live audio and video conversations."
},
"ImageViewer": {
"download": "Download"
},
"Message": {
"open_location": "Open Location",
"thread": "Thread"
},
"ImageContent": {
"view": "View",
"spoiler": "Spoiler",
"retry": "Retry"
},
"DeviceVerification": {
"close": "Close",
"accept": "Accept",
"they_match": "They Match",
"okay": "Okay",
"do_not_match": "Do not Match",
"please_accept": "Please accept the request from other device.",
"waiting_accept": "Waiting for request to be accepted...",
"click_accept": "Click accept to start the verification process.",
"request_accepted": "Verification request has been accepted.",
"waiting_response": "Waiting for the response from other device...",
"starting_emoji": "Starting verification using emoji comparison...",
"confirm_emoji": "Confirm the emoji below are displayed on both devices, in the same order:",
"device_verified": "Your device is verified.",
"verification_canceled": "Verification has been canceled."
},
"UrlPreview": {
"join_server": "Join Server"
},
"InviteUser": {
"invite": "Invite"
},
"UploadBoard": {
"files": "Files",
"send": "Send",
"upload_failed": "Upload Failed"
},
"PasswordStage": {
"account_password": "Account Password",
"password": "Password",
"invalid_password": "Invalid Password!",
"authenticate_prompt": "To perform this action you need to authenticate yourself by entering you account password."
} }
} }
} }
+268 -124
View File
@@ -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,255 @@ 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 — and we
// ping exactly once per incoming call, not again if the user happens to tweak
// ringtone settings while the banner is showing.
const pingedRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (info.notificationType !== 'ring') return;
if (pingedRef.current === info.refEventId) return;
pingedRef.current = info.refEventId;
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
}, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume]);
useEffect(() => {
const remaining = info.senderTs + info.lifetime - Date.now();
if (remaining <= 0) {
onIgnore();
return;
}
const id = setTimeout(onIgnore, remaining);
return () => clearTimeout(id);
}, [info.senderTs, info.lifetime, onIgnore]);
const callerName =
getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender;
return (
<Box
direction="Column"
gap="300"
style={{
position: 'fixed',
top: config.space.S400,
right: config.space.S400,
zIndex: 9990,
width: toRem(300),
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
padding: config.space.S300,
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R400,
boxShadow: `0 8px 32px ${color.Other.Shadow}`,
}}
role="alert"
aria-label={`Incoming ${isVideo ? 'video' : 'voice'} call from ${roomName}`}
>
<Box gap="300" alignItems="Center">
<Box shrink="No">
<Avatar size="300" className={CallAvatarAnimation}>
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<RoomIcon
roomType={room.getType()}
size="200"
joinRule={room.getJoinRule()}
filled
/>
)}
/>
</Avatar>
</Box>
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{roomName}
</Text>
<Text size="T200" priority="300" truncate>
{isVideo ? 'Incoming video call' : 'Incoming voice call'}
{dm ? '' : ` · ${callerName}`}
</Text>
</Box>
</Box>
<Box gap="200">
<Button
style={{ flexGrow: 1 }}
variant="Success"
fill="Solid"
size="300"
radii="300"
onClick={() => onAnswer(room, isVideo)}
before={<Icon size="100" src={isVideo ? Icons.VideoCamera : Icons.Phone} filled />}
>
<Text as="span" size="B300">
Answer
</Text>
</Button>
<Button
style={{ flexGrow: 1 }}
variant={dm ? 'Critical' : 'Secondary'}
fill="Soft"
size="300"
radii="300"
outlined
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
before={<Icon size="100" src={Icons.Cross} filled />}
>
<Text as="span" size="B300">
{dm ? 'Reject' : 'Ignore'}
</Text>
</Button>
</Box>
</Box>
); );
} }
@@ -390,10 +519,25 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
[startCall, navigateRoom], [startCall, navigateRoom],
); );
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) { if (!callInfo) return null;
// Already in this room's own call — no notification at all.
if (callEmbed?.roomId === callInfo.room.roomId) {
return null; return null;
} }
return !joined && callInfo ? ( // In a different call already: show the compact, non-intrusive banner
// instead of the full-screen takeover overlay.
if (joined) {
return (
<IncomingCallBanner
dm={dm}
info={callInfo}
onIgnore={handleIgnore}
onAnswer={handleAnswer}
onReject={handleReject}
/>
);
}
return (
<IncomingCall <IncomingCall
dm={dm} dm={dm}
info={callInfo} info={callInfo}
@@ -401,7 +545,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
onAnswer={handleAnswer} onAnswer={handleAnswer}
onReject={handleReject} onReject={handleReject}
/> />
) : null; );
} }
function CallUtils({ embed }: { embed: CallEmbed }) { function CallUtils({ embed }: { embed: CallEmbed }) {
+26 -16
View File
@@ -5,6 +5,7 @@ import {
Verifier, Verifier,
} from 'matrix-js-sdk/lib/crypto-api'; } from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useState } from 'react'; import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { VerificationMethod } from 'matrix-js-sdk/lib/types'; import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import { import {
Box, Box,
@@ -51,21 +52,23 @@ function WaitingMessage({ message }: WaitingMessageProps) {
type VerificationUnexpectedProps = { message: string; onClose: () => void }; type VerificationUnexpectedProps = { message: string; onClose: () => void };
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) { function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
const { t } = useTranslation();
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<Text>{message}</Text> <Text>{message}</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}> <Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text> <Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
</Button> </Button>
</Box> </Box>
); );
} }
function VerificationWaitAccept() { function VerificationWaitAccept() {
const { t } = useTranslation();
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<Text>Please accept the request from other device.</Text> <Text>{t('Organisms.DeviceVerification.please_accept')}</Text>
<WaitingMessage message="Waiting for request to be accepted..." /> <WaitingMessage message={t('Organisms.DeviceVerification.waiting_accept')} />
</Box> </Box>
); );
} }
@@ -74,12 +77,13 @@ type VerificationAcceptProps = {
onAccept: () => Promise<void>; onAccept: () => Promise<void>;
}; };
function VerificationAccept({ onAccept }: VerificationAcceptProps) { function VerificationAccept({ onAccept }: VerificationAcceptProps) {
const { t } = useTranslation();
const [acceptState, accept] = useAsyncCallback(onAccept); const [acceptState, accept] = useAsyncCallback(onAccept);
const accepting = acceptState.status === AsyncStatus.Loading; const accepting = acceptState.status === AsyncStatus.Loading;
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<Text>Click accept to start the verification process.</Text> <Text>{t('Organisms.DeviceVerification.click_accept')}</Text>
<Button <Button
variant="Primary" variant="Primary"
fill="Solid" fill="Solid"
@@ -87,17 +91,18 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />} before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
disabled={accepting} disabled={accepting}
> >
<Text size="B400">Accept</Text> <Text size="B400">{t('Organisms.DeviceVerification.accept')}</Text>
</Button> </Button>
</Box> </Box>
); );
} }
function VerificationWaitStart() { function VerificationWaitStart() {
const { t } = useTranslation();
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<Text>Verification request has been accepted.</Text> <Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
<WaitingMessage message="Waiting for the response from other device..." /> <WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
</Box> </Box>
); );
} }
@@ -106,18 +111,20 @@ type VerificationStartProps = {
onStart: () => Promise<void>; onStart: () => Promise<void>;
}; };
function AutoVerificationStart({ onStart }: VerificationStartProps) { function AutoVerificationStart({ onStart }: VerificationStartProps) {
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
onStart(); onStart();
}, [onStart]); }, [onStart]);
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<WaitingMessage message="Starting verification using emoji comparison..." /> <WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
</Box> </Box>
); );
} }
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
const { t } = useTranslation();
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData])); const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
const confirming = const confirming =
@@ -125,7 +132,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text> <Text>{t('Organisms.DeviceVerification.confirm_emoji')}</Text>
<Box <Box
className={ContainerColor({ variant: 'SurfaceVariant' })} className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{ style={{
@@ -157,7 +164,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
disabled={confirming} disabled={confirming}
before={confirming && <Spinner size="100" variant="Primary" />} before={confirming && <Spinner size="100" variant="Primary" />}
> >
<Text size="B400">They Match</Text> <Text size="B400">{t('Organisms.DeviceVerification.they_match')}</Text>
</Button> </Button>
<Button <Button
variant="Primary" variant="Primary"
@@ -165,7 +172,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
onClick={() => sasData.mismatch()} onClick={() => sasData.mismatch()}
disabled={confirming} disabled={confirming}
> >
<Text size="B400">Do not Match</Text> <Text size="B400">{t('Organisms.DeviceVerification.do_not_match')}</Text>
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -177,6 +184,7 @@ type SasVerificationProps = {
onCancel: () => void; onCancel: () => void;
}; };
function SasVerification({ verifier, onCancel }: SasVerificationProps) { function SasVerification({ verifier, onCancel }: SasVerificationProps) {
const { t } = useTranslation();
const [sasData, setSasData] = useState<ShowSasCallbacks>(); const [sasData, setSasData] = useState<ShowSasCallbacks>();
useVerifierShowSas(verifier, setSasData); useVerifierShowSas(verifier, setSasData);
@@ -192,7 +200,7 @@ function SasVerification({ verifier, onCancel }: SasVerificationProps) {
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<WaitingMessage message="Starting verification using emoji comparison..." /> <WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
</Box> </Box>
); );
} }
@@ -201,13 +209,14 @@ type VerificationDoneProps = {
onExit: () => void; onExit: () => void;
}; };
function VerificationDone({ onExit }: VerificationDoneProps) { function VerificationDone({ onExit }: VerificationDoneProps) {
const { t } = useTranslation();
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<div> <div>
<Text>Your device is verified.</Text> <Text>{t('Organisms.DeviceVerification.device_verified')}</Text>
</div> </div>
<Button variant="Primary" fill="Solid" onClick={onExit}> <Button variant="Primary" fill="Solid" onClick={onExit}>
<Text size="B400">Okay</Text> <Text size="B400">{t('Organisms.DeviceVerification.okay')}</Text>
</Button> </Button>
</Box> </Box>
); );
@@ -217,11 +226,12 @@ type VerificationCanceledProps = {
onClose: () => void; onClose: () => void;
}; };
function VerificationCanceled({ onClose }: VerificationCanceledProps) { function VerificationCanceled({ onClose }: VerificationCanceledProps) {
const { t } = useTranslation();
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<Text>Verification has been canceled.</Text> <Text>{t('Organisms.DeviceVerification.verification_canceled')}</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}> <Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text> <Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
</Button> </Button>
</Box> </Box>
); );
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds'; import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
import { SequenceCard } from '../sequence-card'; import { SequenceCard } from '../sequence-card';
import { SettingTile } from '../setting-tile'; import { SettingTile } from '../setting-tile';
@@ -17,6 +18,7 @@ export function CreateRoomTypeSelector({
disabled, disabled,
getIcon, getIcon,
}: CreateRoomTypeSelectorProps) { }: CreateRoomTypeSelectorProps) {
const { t } = useTranslation();
return ( return (
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<SequenceCard <SequenceCard
@@ -36,10 +38,10 @@ export function CreateRoomTypeSelector({
> >
<Box gap="200" alignItems="Baseline"> <Box gap="200" alignItems="Baseline">
<Text size="H6" style={{ flexShrink: 0 }}> <Text size="H6" style={{ flexShrink: 0 }}>
Chat Room {t('Organisms.CreateRoom.chat_room')}
</Text> </Text>
<Text size="T300" priority="300" truncate> <Text size="T300" priority="300" truncate>
- Messages, photos, and videos. - {t('Organisms.CreateRoom.chat_room_desc')}
</Text> </Text>
</Box> </Box>
</SettingTile> </SettingTile>
@@ -61,10 +63,10 @@ export function CreateRoomTypeSelector({
> >
<Box gap="200" alignItems="Baseline"> <Box gap="200" alignItems="Baseline">
<Text size="H6" style={{ flexShrink: 0 }}> <Text size="H6" style={{ flexShrink: 0 }}>
Voice Room {t('Organisms.CreateRoom.voice_room')}
</Text> </Text>
<Text size="T300" priority="300" truncate> <Text size="T300" priority="300" truncate>
- Live audio and video conversations. - {t('Organisms.CreateRoom.voice_room_desc')}
</Text> </Text>
<BetaNoticeBadge /> <BetaNoticeBadge />
</Box> </Box>
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import classNames from 'classnames'; import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
@@ -15,6 +16,7 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>( export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => { ({ className, alt, src, requestClose, ...props }, ref) => {
const { t } = useTranslation();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1); const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
@@ -69,7 +71,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
radii="300" radii="300"
before={<Icon size="50" src={Icons.Download} />} before={<Icon size="50" src={Icons.Download} />}
> >
<Text size="B300">Download</Text> <Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
</Chip> </Chip>
</Box> </Box>
</Header> </Header>
@@ -7,6 +7,7 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
Overlay, Overlay,
OverlayBackdrop, OverlayBackdrop,
@@ -66,6 +67,7 @@ type InviteUserProps = {
requestClose: () => void; requestClose: () => void;
}; };
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const modalStyle = useModalStyle(560); const modalStyle = useModalStyle(560);
const alive = useAlive(); const alive = useAlive();
@@ -194,7 +196,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
> >
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4" truncate> <Text size="H4" truncate>
Invite {t('Organisms.InviteUser.invite')}
</Text> </Text>
</Box> </Box>
<Box shrink="No" gap="100" alignItems="Center"> <Box shrink="No" gap="100" alignItems="Center">
@@ -351,7 +353,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
disabled={!validUserId || inviting} disabled={!validUserId || inviting}
before={inviting && <Spinner size="200" variant="Primary" fill="Solid" />} before={inviting && <Spinner size="200" variant="Primary" fill="Solid" />}
> >
<Text size="B400">Invite</Text> <Text size="B400">{t('Organisms.InviteUser.invite')}</Text>
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -1,4 +1,5 @@
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react'; import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds'; import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
import { IContent } from 'matrix-js-sdk'; import { IContent } from 'matrix-js-sdk';
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex'; import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
@@ -507,6 +508,7 @@ type MLocationProps = {
content: IContent; content: IContent;
}; };
export function MLocation({ content }: MLocationProps) { export function MLocation({ content }: MLocationProps) {
const { t } = useTranslation();
const geoUri = content.geo_uri; const geoUri = content.geo_uri;
if (typeof geoUri !== 'string') return <BrokenContent />; if (typeof geoUri !== 'string') return <BrokenContent />;
const location = parseGeoUri(geoUri); const location = parseGeoUri(geoUri);
@@ -549,7 +551,7 @@ export function MLocation({ content }: MLocationProps) {
radii="300" radii="300"
before={<Icon src={Icons.External} size="50" />} before={<Icon src={Icons.External} size="50" />}
> >
<Text size="B300">Open Location</Text> <Text size="B300">{t('Organisms.Message.open_location')}</Text>
</Button> </Button>
</Box> </Box>
); );
+17 -13
View File
@@ -1,6 +1,7 @@
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds'; import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, Room } from 'matrix-js-sdk'; import { EventTimelineSet, Room } from 'matrix-js-sdk';
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react'; import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames'; import classNames from 'classnames';
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room'; import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
@@ -37,19 +38,22 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
), ),
); );
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => ( export const ThreadIndicator = as<'div'>(({ ...props }, ref) => {
<Box const { t } = useTranslation();
shrink="No" return (
className={css.ThreadIndicator} <Box
alignItems="Center" shrink="No"
gap="100" className={css.ThreadIndicator}
{...props} alignItems="Center"
ref={ref} gap="100"
> {...props}
<Icon size="50" src={Icons.Thread} /> ref={ref}
<Text size="L400">Thread</Text> >
</Box> <Icon size="50" src={Icons.Thread} />
)); <Text size="L400">{t('Organisms.Message.thread')}</Text>
</Box>
);
});
type ReplyProps = { type ReplyProps = {
room: Room; room: Room;
@@ -1,4 +1,5 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
Badge, Badge,
Box, Box,
@@ -81,6 +82,7 @@ export const ImageContent = as<'div', ImageContentProps>(
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const { t } = useTranslation();
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false); const [viewer, setViewer] = useState(false);
@@ -168,7 +170,7 @@ export const ImageContent = as<'div', ImageContentProps>(
onClick={loadSrc} onClick={loadSrc}
before={<Icon size="Inherit" src={Icons.Photo} filled />} before={<Icon size="Inherit" src={Icons.Photo} filled />}
> >
<Text size="B300">View</Text> <Text size="B300">{t('Organisms.ImageContent.view')}</Text>
</Button> </Button>
</Box> </Box>
)} )}
@@ -212,7 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
} }
}} }}
> >
<Text size="B300">Spoiler</Text> <Text size="B300">{t('Organisms.ImageContent.spoiler')}</Text>
</Chip> </Chip>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -247,7 +249,7 @@ export const ImageContent = as<'div', ImageContentProps>(
onClick={handleRetry} onClick={handleRetry}
before={<Icon size="Inherit" src={Icons.Warning} filled />} before={<Icon size="Inherit" src={Icons.Warning} filled />}
> >
<Text size="B300">Retry</Text> <Text size="B300">{t('Organisms.ImageContent.retry')}</Text>
</Button> </Button>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text } from 'folds'; import { Box, color, config, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { RoomEvent } from 'matrix-js-sdk'; import { RoomEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -175,7 +175,7 @@ export function PollContent({
if (!poll) { if (!poll) {
return ( return (
<Text style={{ opacity: 0.6 }}> <Text priority="300">
<i>Poll (unreadable format)</i> <i>Poll (unreadable format)</i>
</Text> </Text>
); );
@@ -244,21 +244,20 @@ export function PollContent({
gap="200" gap="200"
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }} style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
> >
<Box <Text
alignItems="Center" as="div"
gap="100" size="T200"
priority="300"
data-poll-content-label data-poll-content-label
style={{ style={{
fontSize: '0.68rem',
fontWeight: 700, fontWeight: 700,
letterSpacing: '0.12em', letterSpacing: '0.12em',
textTransform: 'uppercase', textTransform: 'uppercase',
opacity: 0.55, marginBottom: config.space.S100,
marginBottom: '2px',
}} }}
> >
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`} {`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
</Box> </Text>
<Text size="T400" style={{ fontWeight: 600 }}> <Text size="T400" style={{ fontWeight: 600 }}>
{questionText} {questionText}
</Text> </Text>
@@ -280,18 +279,19 @@ export function PollContent({
data-selected={selected} data-selected={selected}
onClick={canVote ? () => handleVote(id) : undefined} onClick={canVote ? () => handleVote(id) : undefined}
style={{ style={{
padding: '7px 12px', padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: '8px', borderRadius: config.radii.R300,
background: selected ? 'var(--accent-cyan-dim)' : 'rgba(255,255,255,0.04)', background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`, border: `${config.borderWidth.B300} solid ${
fontSize: '0.88rem', selected ? color.Primary.Main : color.SurfaceVariant.ContainerLine
}`,
lineHeight: 1.4, lineHeight: 1.4,
textAlign: 'left', textAlign: 'left',
cursor: canVote ? 'pointer' : 'default', cursor: canVote ? 'pointer' : 'default',
color: 'inherit', color: 'inherit',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '4px', gap: config.space.S100,
width: '100%', width: '100%',
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
@@ -306,58 +306,59 @@ export function PollContent({
inset: 0, inset: 0,
right: 'auto', right: 'auto',
width: `${pct}%`, width: `${pct}%`,
background: selected ? 'var(--accent-cyan-dim)' : 'rgba(255,255,255,0.03)', background: selected
? color.Primary.ContainerActive
: color.SurfaceVariant.ContainerActive,
pointerEvents: 'none', pointerEvents: 'none',
transition: 'width 0.3s ease', transition: 'width 0.3s ease',
}} }}
/> />
)} )}
<span <span
style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }} style={{
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
position: 'relative',
}}
> >
{isMultiple && ( <span
<span style={{
style={{ flexShrink: 0,
flexShrink: 0, width: 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
@@ -1,5 +1,6 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds'; import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { FormEventHandler } from 'react'; import React, { FormEventHandler } from 'react';
import { useTranslation } from 'react-i18next';
import { AuthType } from 'matrix-js-sdk'; import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types'; import { StageComponentProps } from './types';
import { ErrorCode } from '../../cs-errorcode'; import { ErrorCode } from '../../cs-errorcode';
@@ -13,6 +14,7 @@ export function PasswordStage({
}: StageComponentProps & { }: StageComponentProps & {
userId: string; userId: string;
}) { }) {
const { t } = useTranslation();
const { errorCode, error, session } = stageData; const { errorCode, error, session } = stageData;
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => { const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@@ -44,7 +46,7 @@ export function PasswordStage({
> >
<Box grow="Yes"> <Box grow="Yes">
<Text as="h2" size="H4"> <Text as="h2" size="H4">
Account Password {t('Organisms.PasswordStage.account_password')}
</Text> </Text>
</Box> </Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel"> <IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
@@ -59,12 +61,9 @@ export function PasswordStage({
gap="400" gap="400"
> >
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<Text size="T200"> <Text size="T200">{t('Organisms.PasswordStage.authenticate_prompt')}</Text>
To perform this action you need to authenticate yourself by entering you account
password.
</Text>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Password</Text> <Text size="L400">{t('Organisms.PasswordStage.password')}</Text>
<PasswordInput size="400" name="passwordInput" outlined autoFocus required /> <PasswordInput size="400" name="passwordInput" outlined autoFocus required />
{errorCode && ( {errorCode && (
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}> <Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
@@ -72,7 +71,7 @@ export function PasswordStage({
<Text size="T200"> <Text size="T200">
<b> <b>
{errorCode === ErrorCode.M_FORBIDDEN {errorCode === ErrorCode.M_FORBIDDEN
? 'Invalid Password!' ? t('Organisms.PasswordStage.invalid_password')
: `${errorCode}: ${error}`} : `${errorCode}: ${error}`}
</b> </b>
</Text> </Text>
@@ -1,4 +1,5 @@
import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react'; import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds'; import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
@@ -43,6 +44,7 @@ export function UploadBoardHeader({
onSend, onSend,
imperativeHandlerRef, imperativeHandlerRef,
}: UploadBoardHeaderProps) { }: UploadBoardHeaderProps) {
const { t } = useTranslation();
const sendingRef = useRef(false); const sendingRef = useRef(false);
const uploads = useAtomValue(uploadFamilyObserverAtom); const uploads = useAtomValue(uploadFamilyObserverAtom);
@@ -88,7 +90,7 @@ export function UploadBoardHeader({
gap="100" gap="100"
> >
<Icon src={open ? Icons.ChevronTop : Icons.ChevronRight} size="50" /> <Icon src={open ? Icons.ChevronTop : Icons.ChevronRight} size="50" />
<Text size="H6">Files</Text> <Text size="H6">{t('Organisms.UploadBoard.files')}</Text>
</Box> </Box>
<Box className={css.UploadBoardHeaderContent} alignItems="Center" gap="100"> <Box className={css.UploadBoardHeaderContent} alignItems="Center" gap="100">
{isSuccess && ( {isSuccess && (
@@ -100,12 +102,12 @@ export function UploadBoardHeader({
outlined outlined
after={<Icon src={Icons.Send} size="50" filled />} after={<Icon src={Icons.Send} size="50" filled />}
> >
<Text size="B300">Send</Text> <Text size="B300">{t('Organisms.UploadBoard.send')}</Text>
</Chip> </Chip>
)} )}
{isError && !open && ( {isError && !open && (
<Badge variant="Critical" fill="Solid" radii="300"> <Badge variant="Critical" fill="Solid" radii="300">
<Text size="L400">Upload Failed</Text> <Text size="L400">{t('Organisms.UploadBoard.upload_failed')}</Text>
</Badge> </Badge>
)} )}
{!isSuccess && !isError && !open && ( {!isSuccess && !isError && !open && (
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { ImageOverlay } from '../ImageOverlay'; import { ImageOverlay } from '../ImageOverlay';
@@ -1343,6 +1344,7 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }
} }
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const { t } = useTranslation();
const title = prev['og:title'] ?? ''; const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? ''; const description = prev['og:description'] ?? '';
const iconUrl = (prev['og:image'] as string | undefined) ?? ''; const iconUrl = (prev['og:image'] as string | undefined) ?? '';
@@ -1383,7 +1385,9 @@ function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
priority="300" priority="300"
> >
<SiteBadge label="Discord" colorClass={previewCss.BadgeDiscord} /> <SiteBadge label="Discord" colorClass={previewCss.BadgeDiscord} />
<span style={{ marginLeft: '6px', opacity: 0.7, fontSize: '0.85em' }}>Join Server</span> <span style={{ marginLeft: '6px', opacity: 0.7, fontSize: '0.85em' }}>
{t('Organisms.UrlPreview.join_server')}
</span>
</Text> </Text>
{title && ( {title && (
<Text truncate priority="400"> <Text truncate priority="400">
+20 -63
View File
@@ -87,7 +87,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const [pttMode] = useSetting(settingsAtom, 'pttMode'); const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey'); const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey'); const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [pttActive, setPttActive] = useState(false); const [pttActive, setPttActive] = useState(false);
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn) // Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
@@ -244,68 +243,26 @@ export function CallControls({ callEmbed }: CallControlsProps) {
justifyContent="Center" justifyContent="Center"
alignItems="Center" alignItems="Center"
> >
{pttMode && {pttMode && (
(lotusTerminal ? ( <Chip
<Box variant={pttActive ? 'Success' : 'Warning'}
style={{ fill="Soft"
position: 'absolute', radii="400"
top: '-2.5rem', style={{
left: '50%', position: 'absolute',
transform: 'translateX(-50%)', top: '-2.2rem',
background: pttActive ? 'var(--lt-accent-green-dim)' : 'var(--lt-accent-orange-dim)', left: '50%',
border: `1px solid ${pttActive ? 'var(--lt-accent-green-border)' : 'var(--lt-accent-orange-border)'}`, transform: 'translateX(-50%)',
borderRadius: '99px', pointerEvents: 'none',
padding: '0.2rem 0.9rem', whiteSpace: 'nowrap',
pointerEvents: 'none', }}
whiteSpace: 'nowrap', outlined
}} >
> <Text size="T200" style={{ fontWeight: 700 }}>
<Text {pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
size="T200" </Text>
style={{ </Chip>
color: pttActive ? 'var(--lt-accent-green)' : 'var(--lt-accent-orange)', )}
fontWeight: 700,
letterSpacing: '0.08em',
fontFamily: 'JetBrains Mono, monospace',
}}
>
{pttActive ? (
<>
<span
style={{
display: 'inline-block',
animation: 'pttLivePulse 900ms ease-in-out infinite',
}}
>
</span>
{' LIVE'}
</>
) : (
`PTT — Hold ${pttKeyLabel}`
)}
</Text>
</Box>
) : (
<Chip
variant={pttActive ? 'Success' : 'Warning'}
fill="Soft"
radii="400"
style={{
position: 'absolute',
top: '-2.2rem',
left: '50%',
transform: 'translateX(-50%)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
outlined
>
<Text size="T200" style={{ fontWeight: 700 }}>
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
</Text>
</Chip>
))}
{shareConfirm && ( {shareConfirm && (
<> <>
<div <div
+35 -2
View File
@@ -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"
@@ -1646,7 +1667,13 @@ function SeasonalBgGrid({
onChange: (v: Settings['seasonalThemeOverride']) => void; onChange: (v: Settings['seasonalThemeOverride']) => void;
}) { }) {
return ( return (
<Box wrap="Wrap" gap="200"> <Box
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${toRem(76)}, 1fr))`,
gap: config.space.S200,
}}
>
{SEASONAL_OPTIONS.map((opt) => { {SEASONAL_OPTIONS.map((opt) => {
const selected = value === opt.value; const selected = value === opt.value;
const isSpecial = opt.value === 'auto' || opt.value === 'off'; const isSpecial = opt.value === 'auto' || opt.value === 'off';
@@ -1706,7 +1733,13 @@ function ChatBgGrid() {
const isDark = theme.kind === ThemeKind.Dark; const isDark = theme.kind === ThemeKind.Dark;
return ( return (
<Box wrap="Wrap" gap="200"> <Box
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${toRem(76)}, 1fr))`,
gap: config.space.S200,
}}
>
{BG_OPTIONS.map((opt) => ( {BG_OPTIONS.map((opt) => (
<Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}> <Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}>
<button <button
+39 -11
View File
@@ -356,20 +356,48 @@ export class CallControl extends EventEmitter implements CallControlState {
const doc = this.document; const doc = this.document;
if (!doc) return; if (!doc) return;
// Find the mute icon / aria-label element that identifies this participant // EC labels participant tiles inconsistently across versions — the user's
const userEl = doc.querySelector<HTMLElement>(`[aria-label="${CSS.escape(userId)}"]`); // matrix id may be the full aria-label, a substring of it, or carried on a
// Walk up to the nearest video tile container // data attribute (and sometimes the visible label is the display name, not
const tile = // the id at all). Try several strategies before giving up, then walk up to
userEl?.closest<HTMLElement>('[data-testid="videoTile"]') ?? // the enclosing video tile.
userEl?.closest<HTMLElement>('[data-video-fit]'); const findTile = (): HTMLElement | undefined => {
const escaped = CSS.escape(userId);
const el =
doc.querySelector<HTMLElement>(`[aria-label="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-testid="videoTile"][aria-label*="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[aria-label*="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-member-id="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-id="${escaped}"]`) ??
undefined;
return (
el?.closest<HTMLElement>('[data-testid="videoTile"]') ??
el?.closest<HTMLElement>('[data-video-fit]') ??
el ??
undefined
);
};
if (!this.spotlight) { const applyFocus = () => {
this.spotlightButton?.click(); const tile = findTile();
if (tile) {
tile.click();
} else if (import.meta.env.DEV) {
console.warn(`[CallControl] focusCameraParticipant: no tile matched ${userId}`);
}
};
if (this.spotlight) {
// Already in spotlight — pin immediately.
applyFocus();
return;
} }
if (tile) { // Switching to spotlight re-renders EC's layout asynchronously; clicking the
tile.click(); // tile in the same tick would land in the old (grid) DOM. Toggle spotlight,
} // then click on a later frame once the spotlight tiles have mounted.
this.spotlightButton?.click();
requestAnimationFrame(() => requestAnimationFrame(applyFocus));
} }
public dispose() { public dispose() {
+37 -8
View File
@@ -70,7 +70,9 @@ export class CallEmbed {
private loadError?: CallLoadErrorReason; private loadError?: CallLoadErrorReason;
private readonly loadErrorListeners = new Set<(reason: CallLoadErrorReason) => void>(); private readonly loadErrorListeners = new Set<
(reason: CallLoadErrorReason | undefined) => void
>();
// Arrow-function class fields so dispose() passes the exact same reference to mx.off() // Arrow-function class fields so dispose() passes the exact same reference to mx.off()
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev); private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
@@ -375,17 +377,44 @@ export class CallEmbed {
} }
} }
private notifyLoadListeners(reason: CallLoadErrorReason | undefined): void {
this.loadErrorListeners.forEach((cb) => {
try {
cb(reason);
} catch {
// a misbehaving subscriber must not block the others
}
});
}
/** /**
* Marks the load lifecycle as settled. Called on success (no reason) or on * Marks the load lifecycle as settled.
* failure (reason set). Idempotent so the first signal wins. *
* - Failure (reason set): the FIRST failure wins; a later success can still
* heal it (below). Once we've genuinely succeeded, later spurious failures
* are ignored.
* - Success (no reason): always clears the watchdog. Crucially, if we had
* previously settled as a failure (e.g. the 25s watchdog fired on a slow
* network but EC then finished loading), we self-heal: clear the error and
* notify subscribers with `undefined` so the recovery UI dismisses itself
* instead of stranding the user on an error screen over a live call.
*/ */
private settleLoad(reason?: CallLoadErrorReason): void { private settleLoad(reason?: CallLoadErrorReason): void {
if (this.loadSettled) return;
this.loadSettled = true;
this.clearLoadWatchdog();
if (reason) { if (reason) {
if (this.loadSettled) return;
this.loadSettled = true;
this.clearLoadWatchdog();
this.loadError = reason; this.loadError = reason;
this.loadErrorListeners.forEach((cb) => cb(reason)); this.notifyLoadListeners(reason);
return;
}
this.clearLoadWatchdog();
const wasFailed = this.loadError !== undefined;
this.loadSettled = true;
this.loadError = undefined;
if (wasFailed) {
this.notifyLoadListeners(undefined);
} }
} }
@@ -402,7 +431,7 @@ export class CallEmbed {
* immediately so late subscribers still see the error. * immediately so late subscribers still see the error.
* @returns an unsubscribe function. * @returns an unsubscribe function.
*/ */
public onLoadError(callback: (reason: CallLoadErrorReason) => void): () => void { public onLoadError(callback: (reason: CallLoadErrorReason | undefined) => void): () => void {
this.loadErrorListeners.add(callback); this.loadErrorListeners.add(callback);
if (this.loadError) callback(this.loadError); if (this.loadError) callback(this.loadError);
return () => { return () => {
+15
View File
@@ -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; // 0100 ringtoneVolume: number; // 0100
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 ?? {}),
+11 -3
View File
@@ -169,12 +169,17 @@ const matrixErrorFromUnknown = (e: unknown): MatrixError => {
// HTTP statuses that should not be retried — client errors are deterministic // HTTP statuses that should not be retried — client errors are deterministic
// (e.g. 413 payload too large, 400 bad request, 401/403 auth) and won't succeed on retry. // (e.g. 413 payload too large, 400 bad request, 401/403 auth) and won't succeed on retry.
const isRetryableUploadError = (e: unknown): boolean => { const isRetryableUploadError = (e: unknown): boolean => {
// A user-cancelled / aborted upload must never be retried. matrix-js-sdk's
// mx.cancelUpload() rejects the upload with a DOMException named "AbortError";
// without this guard the retry loop would resurrect an upload the user just
// cancelled.
if ((e as { name?: unknown } | null | undefined)?.name === 'AbortError') return false;
if (e instanceof MatrixError) { if (e instanceof MatrixError) {
const status = e.httpStatus; const status = e.httpStatus;
// No status => network/transport failure (transient): retry. // No status => network/transport failure (transient): retry.
if (typeof status !== 'number') return true; if (typeof status !== 'number') return true;
// Retry on rate-limiting and server-side (5xx) errors only. // Retry on request-timeout, rate-limiting and server-side (5xx) errors only.
return status === 429 || status >= 500; return status === 408 || status === 429 || status >= 500;
} }
// Non-Matrix errors are typically network/transport failures: retry. // Non-Matrix errors are typically network/transport failures: retry.
return true; return true;
@@ -307,6 +312,8 @@ export const addRoomIdToMDirect = async (
// (it can only be a DM room for one person) // (it can only be a DM room for one person)
Object.keys(userIdToRoomIds).forEach((targetUserId) => { Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId]; const roomIds = userIdToRoomIds[targetUserId];
// Guard against a corrupt m.direct where a value isn't an array.
if (!Array.isArray(roomIds)) return;
if (targetUserId !== userId) { if (targetUserId !== userId) {
const indexOfRoomId = roomIds.indexOf(roomId); const indexOfRoomId = roomIds.indexOf(roomId);
@@ -316,7 +323,7 @@ export const addRoomIdToMDirect = async (
} }
}); });
const roomIds = userIdToRoomIds[userId] || []; const roomIds = Array.isArray(userIdToRoomIds[userId]) ? userIdToRoomIds[userId] : [];
if (roomIds.indexOf(roomId) === -1) { if (roomIds.indexOf(roomId) === -1) {
roomIds.push(roomId); roomIds.push(roomId);
} }
@@ -334,6 +341,7 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string):
Object.keys(userIdToRoomIds).forEach((targetUserId) => { Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId]; const roomIds = userIdToRoomIds[targetUserId];
if (!Array.isArray(roomIds)) return;
const indexOfRoomId = roomIds.indexOf(roomId); const indexOfRoomId = roomIds.indexOf(roomId);
if (indexOfRoomId > -1) { if (indexOfRoomId > -1) {
roomIds.splice(indexOfRoomId, 1); roomIds.splice(indexOfRoomId, 1);
+199
View File
@@ -0,0 +1,199 @@
import CallSound from '../../../public/sound/call.ogg';
import { RingtoneId } from '../state/settings';
export const RINGTONE_OPTIONS: { value: RingtoneId; label: string }[] = [
{ value: 'classic', label: 'Classic' },
{ value: 'chime', label: 'Chime' },
{ value: 'soft', label: 'Soft' },
{ value: 'retro', label: 'Retro' },
{ value: 'none', label: 'Silent' },
];
export const isRingtoneId = (v: unknown): v is RingtoneId =>
v === 'classic' || v === 'chime' || v === 'soft' || v === 'retro' || v === 'none';
type SynthStyle = 'chime' | 'soft' | 'retro';
const clamp01 = (n: number): number => Math.max(0, Math.min(1, n));
// Shared WebAudio context for synthesized ringtones. Kept separate from the
// join/leave-sound context (callSounds.ts) to keep blast radius small.
let sharedCtx: AudioContext | undefined;
const getCtx = (): AudioContext | undefined => {
try {
if (!sharedCtx || sharedCtx.state === 'closed') sharedCtx = new AudioContext();
if (sharedCtx.state === 'suspended') sharedCtx.resume().catch(() => undefined);
return sharedCtx;
} catch {
return undefined;
}
};
type Note = {
freq: number;
/** Offset from phrase start, in seconds */
at: number;
/** Duration in seconds */
dur: number;
};
// One looping phrase per synth style + the period before it repeats.
const PHRASES: Record<
SynthStyle,
{ type: OscillatorType; gain: number; period: number; notes: Note[] }
> = {
// Two-tone "ring … ring" telephone cadence.
chime: {
type: 'sine',
gain: 0.3,
period: 3,
notes: [
{ freq: 587.33, at: 0, dur: 0.35 },
{ freq: 880, at: 0.4, dur: 0.35 },
{ freq: 587.33, at: 1.0, dur: 0.35 },
{ freq: 880, at: 1.4, dur: 0.35 },
],
},
// Gentle rising triangle pair.
soft: {
type: 'triangle',
gain: 0.24,
period: 3.2,
notes: [
{ freq: 523.25, at: 0, dur: 0.5 },
{ freq: 659.25, at: 0.55, dur: 0.7 },
],
},
// Retro arpeggio sweep.
retro: {
type: 'square',
gain: 0.12,
period: 2.4,
notes: [
{ freq: 440, at: 0, dur: 0.12 },
{ freq: 554.37, at: 0.13, dur: 0.12 },
{ freq: 659.25, at: 0.26, dur: 0.12 },
{ freq: 880, at: 0.39, dur: 0.22 },
],
},
};
const playPhrase = (style: SynthStyle, volume: number, destination: AudioNode): void => {
const ctx = getCtx();
if (!ctx) return;
const { type, gain: peak, notes } = PHRASES[style];
const scaledPeak = peak * clamp01(volume);
if (scaledPeak <= 0) return;
const now = ctx.currentTime;
notes.forEach(({ freq, at, dur }) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
const start = now + at;
// Short attack + exponential decay to avoid clicks.
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
osc.connect(gain);
gain.connect(destination);
osc.start(start);
osc.stop(start + dur + 0.02);
});
};
const startClassic = (volume: number, loop: boolean): (() => void) => {
let audio: HTMLAudioElement | undefined;
try {
audio = new Audio(CallSound);
audio.loop = loop;
audio.volume = clamp01(volume);
audio.play().catch(() => undefined);
} catch {
audio = undefined;
}
return () => {
if (!audio) return;
audio.pause();
audio.currentTime = 0;
audio = undefined;
};
};
const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => {
const ctx = getCtx();
if (!ctx) return () => undefined;
// All notes route through a per-session master gain so stop() can silence
// everything instantly — including notes already scheduled slightly in the
// future — instead of letting the last phrase ring out after the user answers.
const master = ctx.createGain();
master.gain.value = 1;
master.connect(ctx.destination);
playPhrase(style, volume, master);
const id = loop
? window.setInterval(() => playPhrase(style, volume, master), PHRASES[style].period * 1000)
: 0;
let stopped = false;
return () => {
if (stopped) return;
stopped = true;
if (id) window.clearInterval(id);
try {
const now = ctx.currentTime;
master.gain.cancelScheduledValues(now);
master.gain.setValueAtTime(master.gain.value, now);
master.gain.linearRampToValueAtTime(0, now + 0.03);
} catch {
/* context may be closed */
}
window.setTimeout(() => {
try {
master.disconnect();
} catch {
/* already disconnected */
}
}, 100);
};
};
/**
* Start an incoming-call ringtone, looping until the returned stop fn is
* called. `volume` is 0..1. Returns a no-op stop fn for 'none'.
*
* Synthesized styles share the WebAudio autoplay limitation of the bundled
* 'classic' file: until the page has had a user gesture the browser may keep
* audio suspended, so the very first ring after a cold page load can be
* silent. This matches the pre-existing behaviour of the classic ringtone.
*/
export const startRingtone = (id: RingtoneId, volume: number): (() => void) => {
if (id === 'none') return () => undefined;
if (id === 'classic') return startClassic(volume, true);
return startSynth(id, volume, true);
};
// Only one preview may sound at a time; starting a new one cancels the last.
let activePreviewStop: (() => void) | null = null;
/**
* Play a single, non-looping preview of a ringtone (used by Settings).
* Auto-stops the bundled 'classic' clip after a few seconds and cancels any
* previously-playing preview. Returns a stop fn for early cancellation.
*/
export const previewRingtone = (id: RingtoneId, volume: number): (() => void) => {
activePreviewStop?.();
activePreviewStop = null;
if (id === 'none') return () => undefined;
const stop = id === 'classic' ? startClassic(volume, false) : startSynth(id, volume, false);
let timer = 0;
const wrapped = () => {
window.clearTimeout(timer);
stop();
if (activePreviewStop === wrapped) activePreviewStop = null;
};
timer = window.setTimeout(wrapped, 4000);
activePreviewStop = wrapped;
return wrapped;
};