fix(lotus): explain a failed voice connection and offer Try again

"Failed to connect to Livekit server (Reason: ServerUnreachable)" left a
user with nothing to try; they retried for a long time and gave up.

- Title "Couldn't connect to voice". The message depends on the reason:
  unreachable/timeout-style errors explain that voice needs its own live
  connection, which VPNs, antivirus web shields and some networks block,
  even when chat works, and say what to try; NotAllowed says the call may
  be full or access changed.
- The raw reason, status and message move under Technical details.
- LivekitConnectionError and PeerConnectionTimeoutError now get a
  Try again button (the existing reconnect path), shown as primary: the
  Lotus theme renders `secondary` as dark-on-dark.

Verified in the Lotus client against a local LiveKit with the signal
socket and validate request blocked: the dialog appears after ~20 s;
unblocking and pressing Try again joins with the mic published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-23 20:51:43 -04:00
co-authored by Claude Opus 5.5
parent e9723a21b5
commit 3e69a18a39
5 changed files with 214 additions and 75 deletions
+19 -5
View File
@@ -30,6 +30,8 @@ import {
ElementCallError,
ErrorCategory,
ErrorCode,
LivekitConnectionError,
PeerConnectionTimeoutError,
UnknownCallError,
} from "../utils/errors.ts";
import { FullScreenView } from "../FullScreenView.tsx";
@@ -78,10 +80,22 @@ const ErrorPage: FC<ErrorPageProps> = ({
label: t("call_ended_view.reconnect_button"),
onClick: () => void recoveryActionHandler("reconnect"),
});
} else if (
// [lotus] A failed connect is often transient (busy server, flaky Wi-Fi);
// offer the same re-enter path instead of a dead end.
error instanceof LivekitConnectionError ||
error instanceof PeerConnectionTimeoutError
) {
actions.push({
label: t("error.try_again"),
onClick: () => void recoveryActionHandler("reconnect"),
});
}
const technicalError =
error.cause instanceof MatrixError ? error.cause : null;
error.cause instanceof MatrixError
? error.cause.message
: (error.technicalDetails ?? null);
return (
<FullScreenView>
@@ -124,15 +138,15 @@ const ErrorPage: FC<ErrorPageProps> = ({
<summary className={styles.technicalDetailsSummary}>
{t("technical_details")}
</summary>
<pre className={styles.technicalDetailsPre}>
{technicalError.message}
</pre>
<pre className={styles.technicalDetailsPre}>{technicalError}</pre>
</details>
) : null}
{actions &&
actions.map((action, index) => (
<Button
kind="secondary"
// [lotus] primary: the Lotus theme renders `secondary` as dark
// text on a dark fill, and this is the action we want taken.
kind="primary"
onClick={action.onClick}
key={`action${index}`}
>