Compare commits

...

7 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
20 changed files with 331 additions and 171 deletions
+24 -24
View File
@@ -175,26 +175,26 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
| Category | Issue Description | File Path | Status | | Category | Issue Description | File Path | Status |
| :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Localization | Hardcoded UI string: "Chat Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN | | Localization | Hardcoded UI string: "Chat Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Messages, photos, and videos." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN | | Localization | Hardcoded UI string: "Messages, photos, and videos." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Voice Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN | | Localization | Hardcoded UI string: "Voice Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Live audio and video conversations." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN | | Localization | Hardcoded UI string: "Live audio and video conversations." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Download" | `src/app/components/image-viewer/ImageViewer.tsx` | OPEN | | Localization | Hardcoded UI string: "Download" | `src/app/components/image-viewer/ImageViewer.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Open Location" | `src/app/components/message/MsgTypeRenderers.tsx` | OPEN | | Localization | Hardcoded UI string: "Open Location" | `src/app/components/message/MsgTypeRenderers.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | OPEN | | Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | OPEN | | Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | OPEN | | Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | OPEN | | Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | OPEN | | Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | OPEN | | Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | OPEN | | Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | OPEN | | Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | OPEN | | Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | OPEN | | Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN | | Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN | | Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN | | Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Localization | Hardcoded UI string: "Password" | `src/app/components/uia-stages/PasswordStage.tsx` | OPEN | | Localization | Hardcoded UI string: "Password" | `src/app/components/uia-stages/PasswordStage.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
| Bundle Size | Large unoptimized media asset (213KB) | `public/res/Lotus.png` | OPEN | | Bundle Size | Large unoptimized media asset (213KB) | `public/res/Lotus.png` | OPEN |
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/features/lobby/Lobby.tsx` | FALSE POSITIVE — `Lobby` already routes its render loop through the memoized `useGetRoom(allJoinedRooms)`. The two remaining `mx.getRoom()` calls are inside drag/drop event handlers (not render loops) and are O(1) SDK map lookups. No change warranted. | | Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/features/lobby/Lobby.tsx` | FALSE POSITIVE — `Lobby` already routes its render loop through the memoized `useGetRoom(allJoinedRooms)`. The two remaining `mx.getRoom()` calls are inside drag/drop event handlers (not render loops) and are O(1) SDK map lookups. No change warranted. |
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/components/emoji-board/EmojiBoard.tsx` | FIXED (`b7e1f89c`) — pack-label `mx.getRoom()` lookups in `EmojiSidebar`/`StickerSidebar` hoisted into a `useMemo`'d `Map` built once per pack list. | | Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/components/emoji-board/EmojiBoard.tsx` | FIXED (`b7e1f89c`) — pack-label `mx.getRoom()` lookups in `EmojiSidebar`/`StickerSidebar` hoisted into a `useMemo`'d `Map` built once per pack list. |
@@ -376,8 +376,8 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N53 — PTT Badge (Lotus Terminal path): Raw `<div>` tree with `--lt-*` CSS vars instead of folds `<Chip>`** **N53 — PTT Badge (Lotus Terminal path): Raw `<div>` tree with `--lt-*` CSS vars instead of folds `<Chip>`**
- **File:** `src/app/features/call/CallControls.tsx`, lines 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.
@@ -440,8 +440,8 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
| N78 | HasLink Chip Active Color | `SearchFilters.tsx` | 755 | `HasLink` active state uses `variant="Primary"` (blue); all boolean scope-toggle chips in the same bar use `variant="Success"` (green) with `outlined`**FIXED**: changed to `variant={containsUrl ? 'Success' : 'SurfaceVariant'} outlined={!!containsUrl}` | `variant="Success" outlined` is the established active-state pattern for boolean toggles in the filter bar | | N78 | HasLink Chip Active Color | `SearchFilters.tsx` | 755 | `HasLink` active state uses `variant="Primary"` (blue); all boolean scope-toggle chips in the same bar use `variant="Success"` (green) with `outlined`**FIXED**: changed to `variant={containsUrl ? 'Success' : 'SurfaceVariant'} outlined={!!containsUrl}` | `variant="Success" outlined` is the established active-state pattern for boolean toggles in the filter bar |
| N79 | Server Notice Chip Radii | `RoomViewHeader.tsx` | 570 | `<Chip size="400" radii="Pill">``Pill` radii on a room-type label — **FIXED**: changed to `radii="300"` | Room/space type labels in lobby (`RoomItem.tsx:83`, `SpaceItem.tsx:63`) use `radii="300"`; `radii="Pill"` is for filter/tag chips only | | N79 | Server Notice Chip Radii | `RoomViewHeader.tsx` | 570 | `<Chip size="400" radii="Pill">``Pill` radii on a room-type label — **FIXED**: changed to `radii="300"` | Room/space type labels in lobby (`RoomItem.tsx:83`, `SpaceItem.tsx:63`) use `radii="300"`; `radii="Pill"` is for filter/tag chips only |
| N80 | Server Support Contact Layout | `About.tsx` | 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 |
--- ---
+11
View File
@@ -118,6 +118,7 @@ This guards against a permanently-stuck "Loading…" call.
- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with Retry / Leave** buttons. - 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. - **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. - 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.
--- ---
@@ -254,6 +255,11 @@ In Settings → Appearance:
2. Pick a **seasonal theme** → confirm the **chat background** auto-clears to none. 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). 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) ## G. Calls — additional unverified (👥 2 people)
@@ -271,6 +277,11 @@ In a call with at least one other person, pop out the **Picture-in-Picture** min
1. In a **camera-only** call (no screenshare), confirm the **Fullscreen** button is available (previously only showed during screenshare). 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.) 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) ## H. Media / performance (needs a room with many images)
+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."
} }
} }
} }
+7 -2
View File
@@ -289,11 +289,16 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
); );
// Single soft ping (non-looping) on arrival, respecting the chosen ringtone // Single soft ping (non-looping) on arrival, respecting the chosen ringtone
// + volume. We intentionally do NOT loop here — the user is mid-call. // + 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(() => { useEffect(() => {
if (info.notificationType !== 'ring') return; 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))); previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
}, [info.notificationType, ringtoneId, ringtoneVolume]); }, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume]);
useEffect(() => { useEffect(() => {
const remaining = info.senderTs + info.lifetime - Date.now(); const remaining = info.senderTs + info.lifetime - Date.now();
+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>
); );
+7 -3
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,7 +38,9 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
), ),
); );
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => ( export const ThreadIndicator = as<'div'>(({ ...props }, ref) => {
const { t } = useTranslation();
return (
<Box <Box
shrink="No" shrink="No"
className={css.ThreadIndicator} className={css.ThreadIndicator}
@@ -47,9 +50,10 @@ export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
ref={ref} ref={ref}
> >
<Icon size="50" src={Icons.Thread} /> <Icon size="50" src={Icons.Thread} />
<Text size="L400">Thread</Text> <Text size="L400">{t('Organisms.Message.thread')}</Text>
</Box> </Box>
)); );
});
type ReplyProps = { type ReplyProps = {
room: Room; room: Room;
@@ -1,4 +1,5 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
Badge, Badge,
Box, Box,
@@ -81,6 +82,7 @@ export const ImageContent = as<'div', ImageContentProps>(
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const { t } = useTranslation();
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false); const [viewer, setViewer] = useState(false);
@@ -168,7 +170,7 @@ export const ImageContent = as<'div', ImageContentProps>(
onClick={loadSrc} onClick={loadSrc}
before={<Icon size="Inherit" src={Icons.Photo} filled />} before={<Icon size="Inherit" src={Icons.Photo} filled />}
> >
<Text size="B300">View</Text> <Text size="B300">{t('Organisms.ImageContent.view')}</Text>
</Button> </Button>
</Box> </Box>
)} )}
@@ -212,7 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
} }
}} }}
> >
<Text size="B300">Spoiler</Text> <Text size="B300">{t('Organisms.ImageContent.spoiler')}</Text>
</Chip> </Chip>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -247,7 +249,7 @@ export const ImageContent = as<'div', ImageContentProps>(
onClick={handleRetry} onClick={handleRetry}
before={<Icon size="Inherit" src={Icons.Warning} filled />} before={<Icon size="Inherit" src={Icons.Warning} filled />}
> >
<Text size="B300">Retry</Text> <Text size="B300">{t('Organisms.ImageContent.retry')}</Text>
</Button> </Button>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -1,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">
+2 -45
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,49 +243,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
justifyContent="Center" justifyContent="Center"
alignItems="Center" alignItems="Center"
> >
{pttMode && {pttMode && (
(lotusTerminal ? (
<Box
style={{
position: 'absolute',
top: '-2.5rem',
left: '50%',
transform: 'translateX(-50%)',
background: pttActive ? 'var(--lt-accent-green-dim)' : 'var(--lt-accent-orange-dim)',
border: `1px solid ${pttActive ? 'var(--lt-accent-green-border)' : 'var(--lt-accent-orange-border)'}`,
borderRadius: '99px',
padding: '0.2rem 0.9rem',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
>
<Text
size="T200"
style={{
color: pttActive ? 'var(--lt-accent-green)' : 'var(--lt-accent-orange)',
fontWeight: 700,
letterSpacing: '0.08em',
fontFamily: 'JetBrains Mono, monospace',
}}
>
{pttActive ? (
<>
<span
style={{
display: 'inline-block',
animation: 'pttLivePulse 900ms ease-in-out infinite',
}}
>
</span>
{' LIVE'}
</>
) : (
`PTT — Hold ${pttKeyLabel}`
)}
</Text>
</Box>
) : (
<Chip <Chip
variant={pttActive ? 'Success' : 'Warning'} variant={pttActive ? 'Success' : 'Warning'}
fill="Soft" fill="Soft"
@@ -305,7 +262,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`} {pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
</Text> </Text>
</Chip> </Chip>
))} )}
{shareConfirm && ( {shareConfirm && (
<> <>
<div <div
+14 -2
View File
@@ -1667,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';
@@ -1727,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
+38 -10
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);
if (!this.spotlight) { const el =
this.spotlightButton?.click(); doc.querySelector<HTMLElement>(`[aria-label="${escaped}"]`) ??
} doc.querySelector<HTMLElement>(`[data-testid="videoTile"][aria-label*="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[aria-label*="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-member-id="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-id="${escaped}"]`) ??
undefined;
return (
el?.closest<HTMLElement>('[data-testid="videoTile"]') ??
el?.closest<HTMLElement>('[data-video-fit]') ??
el ??
undefined
);
};
const applyFocus = () => {
const tile = findTile();
if (tile) { if (tile) {
tile.click(); tile.click();
} else if (import.meta.env.DEV) {
console.warn(`[CallControl] focusCameraParticipant: no tile matched ${userId}`);
} }
};
if (this.spotlight) {
// Already in spotlight — pin immediately.
applyFocus();
return;
}
// Switching to spotlight re-renders EC's layout asynchronously; clicking the
// tile in the same tick would land in the old (grid) DOM. Toggle spotlight,
// then click on a later frame once the spotlight tiles have mounted.
this.spotlightButton?.click();
requestAnimationFrame(() => requestAnimationFrame(applyFocus));
} }
public dispose() { public dispose() {
+35 -6
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 (reason) {
if (this.loadSettled) return; if (this.loadSettled) return;
this.loadSettled = true; this.loadSettled = true;
this.clearLoadWatchdog(); this.clearLoadWatchdog();
if (reason) {
this.loadError = reason; this.loadError = reason;
this.loadErrorListeners.forEach((cb) => cb(reason)); this.notifyLoadListeners(reason);
return;
}
this.clearLoadWatchdog();
const wasFailed = this.loadError !== undefined;
this.loadSettled = true;
this.loadError = undefined;
if (wasFailed) {
this.notifyLoadListeners(undefined);
} }
} }
@@ -402,7 +431,7 @@ export class CallEmbed {
* immediately so late subscribers still see the error. * immediately so late subscribers still see the error.
* @returns an unsubscribe function. * @returns an unsubscribe function.
*/ */
public onLoadError(callback: (reason: CallLoadErrorReason) => void): () => void { public onLoadError(callback: (reason: CallLoadErrorReason | undefined) => void): () => void {
this.loadErrorListeners.add(callback); this.loadErrorListeners.add(callback);
if (this.loadError) callback(this.loadError); if (this.loadError) callback(this.loadError);
return () => { return () => {
+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);
+37 -7
View File
@@ -78,7 +78,7 @@ const PHRASES: Record<
}, },
}; };
const playPhrase = (style: SynthStyle, volume: number): void => { const playPhrase = (style: SynthStyle, volume: number, destination: AudioNode): void => {
const ctx = getCtx(); const ctx = getCtx();
if (!ctx) return; if (!ctx) return;
const { type, gain: peak, notes } = PHRASES[style]; const { type, gain: peak, notes } = PHRASES[style];
@@ -96,7 +96,7 @@ const playPhrase = (style: SynthStyle, volume: number): void => {
gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015); gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur); gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
osc.connect(gain); osc.connect(gain);
gain.connect(ctx.destination); gain.connect(destination);
osc.start(start); osc.start(start);
osc.stop(start + dur + 0.02); osc.stop(start + dur + 0.02);
}); });
@@ -121,11 +121,41 @@ const startClassic = (volume: number, loop: boolean): (() => void) => {
}; };
const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => { const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => {
playPhrase(style, volume); const ctx = getCtx();
if (!loop) return () => undefined; if (!ctx) return () => undefined;
const period = PHRASES[style].period * 1000; // All notes route through a per-session master gain so stop() can silence
const id = window.setInterval(() => playPhrase(style, volume), period); // everything instantly — including notes already scheduled slightly in the
return () => window.clearInterval(id); // future — instead of letting the last phrase ring out after the user answers.
const master = ctx.createGain();
master.gain.value = 1;
master.connect(ctx.destination);
playPhrase(style, volume, master);
const id = loop
? window.setInterval(() => playPhrase(style, volume, master), PHRASES[style].period * 1000)
: 0;
let stopped = false;
return () => {
if (stopped) return;
stopped = true;
if (id) window.clearInterval(id);
try {
const now = ctx.currentTime;
master.gain.cancelScheduledValues(now);
master.gain.setValueAtTime(master.gain.value, now);
master.gain.linearRampToValueAtTime(0, now + 0.03);
} catch {
/* context may be closed */
}
window.setTimeout(() => {
try {
master.disconnect();
} catch {
/* already disconnected */
}
}, 100);
};
}; };
/** /**