Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 d8880daca5 chore(lotus): 0.25.0-lotus.13
CI / Publish to Gitea npm registry (push) Successful in 2m33s
CI / Build embedded bundle (push) Successful in 4m3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 20:51:43 -04:00
Lotus CIandClaude Opus 5.5 3e69a18a39 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
2026-09-23 20:51:43 -04:00
6 changed files with 215 additions and 76 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lotusguild/element-call-embedded",
"version": "0.25.0-lotus.12",
"version": "0.25.0-lotus.13",
"files": [
"README.md",
"LICENSE-AGPL-3.0",
+4 -2
View File
@@ -104,8 +104,9 @@
"generic_description": "Submitting debug logs will help us track down the problem.",
"insufficient_capacity": "Insufficient capacity",
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
"livekit_connection_error": "Failed to connect to Livekit server",
"livekit_connection_error_description": "An error occurred while connecting to the Livekit server (<1>Reason:</1> <2>{{ reason }}</2>).",
"livekit_connection_error": "Couldnt connect to voice",
"livekit_not_allowed_description": "The voice server didnt let you in. The call may be full, or your access to this room may have changed. Try again in a moment.",
"livekit_unreachable_description": "Your device couldnt reach the voice server. Chat can still work when this happens: voice needs its own live connection, which VPNs, antivirus web protection and some work or school networks block. Try again. If it keeps failing, pause your VPN or antivirus web shield, or try another network such as a phone hotspot.",
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"membership_manager": "Membership Manager Error",
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consecutive failed network requests.",
@@ -119,6 +120,7 @@
"sfu_token_refused": "Can't join this call",
"sticky_events_required": "Homeserver does not support Matrix 2.0 calls",
"sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
"try_again": "Try again",
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
},
"group_call_loader": {
+24 -7
View File
@@ -365,17 +365,18 @@ describe("LiveKit ConnectionError variants", () => {
expectedReason: "InternalError",
},
])(
"should display LiveKit $name error correctly",
"should explain the LiveKit $name error and offer a retry",
async ({ error, expectedReason }) => {
const TestComponent = (): ReactNode => {
throw new LivekitConnectionError(error);
};
const recoveryActionHandler = vi.fn(async () => Promise.resolve());
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
recoveryActionHandler={recoveryActionHandler}
widget={null}
>
<TestComponent />
@@ -383,12 +384,28 @@ describe("LiveKit ConnectionError variants", () => {
</BrowserRouter>,
);
// Check title
await screen.findByText("Failed to connect to Livekit server");
await screen.findByText("Couldnt connect to voice");
// Check that reason is displayed in the description
expect(screen.getByText(/Reason:/i)).toBeInTheDocument();
expect(screen.getByText(expectedReason)).toBeInTheDocument();
// [lotus] Plain-language guidance instead of "(Reason: X)".
expect(
screen.getByText(
expectedReason === "NotAllowed"
? /The voice server didnt let you in/
: /Your device couldnt reach the voice server/,
),
).toBeInTheDocument();
// The raw reason is still there, under Technical details.
expect(screen.getByText("Technical details")).toBeInTheDocument();
expect(
screen.getByText(new RegExp(`Reason: ${expectedReason}`), {
selector: "pre",
}),
).toBeInTheDocument();
// Try again re-enters the call.
await userEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(recoveryActionHandler).toHaveBeenCalledWith("reconnect");
expect(asFragment()).toMatchSnapshot();
},
+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}`}
>
@@ -135,7 +135,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
</p>
<button
class="_button_1nw83_8"
data-kind="secondary"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
@@ -158,7 +158,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
</DocumentFragment>
`;
exports[`LiveKit ConnectionError variants > should display LiveKit 'internal' error correctly 1`] = `
exports[`LiveKit ConnectionError variants > should explain the LiveKit 'internal' error and offer a retry 1`] = `
<DocumentFragment>
<div
class="_page_4be5c0"
@@ -286,19 +286,35 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'internal' er
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Failed to connect to Livekit server
Couldnt connect to voice
</h1>
<p>
An error occurred while connecting to the Livekit server (
<b>
Reason:
</b>
<code>
InternalError
</code>
).
Your device couldnt reach the voice server. Chat can still work when this happens: voice needs its own live connection, which VPNs, antivirus web protection and some work or school networks block. Try again. If it keeps failing, pause your VPN or antivirus web shield, or try another network such as a phone hotspot.
</p>
<details
class="_technicalDetails_a69dc5"
>
<summary
class="_technicalDetailsSummary_a69dc5"
>
Technical details
</summary>
<pre
class="_technicalDetailsPre_a69dc5"
>
Reason: InternalError
Internal server error
</pre>
</details>
<button
class="_button_1nw83_8"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Try again
</button>
<button
class="_button_1nw83_8"
data-kind="tertiary"
@@ -315,7 +331,7 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'internal' er
</DocumentFragment>
`;
exports[`LiveKit ConnectionError variants > should display LiveKit 'notAllowed' error correctly 1`] = `
exports[`LiveKit ConnectionError variants > should explain the LiveKit 'notAllowed' error and offer a retry 1`] = `
<DocumentFragment>
<div
class="_page_4be5c0"
@@ -443,19 +459,36 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'notAllowed'
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Failed to connect to Livekit server
Couldnt connect to voice
</h1>
<p>
An error occurred while connecting to the Livekit server (
<b>
Reason:
</b>
<code>
NotAllowed
</code>
).
The voice server didnt let you in. The call may be full, or your access to this room may have changed. Try again in a moment.
</p>
<details
class="_technicalDetails_a69dc5"
>
<summary
class="_technicalDetailsSummary_a69dc5"
>
Technical details
</summary>
<pre
class="_technicalDetailsPre_a69dc5"
>
Reason: NotAllowed
Status: 403
Permission denied by server
</pre>
</details>
<button
class="_button_1nw83_8"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Try again
</button>
<button
class="_button_1nw83_8"
data-kind="tertiary"
@@ -472,7 +505,7 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'notAllowed'
</DocumentFragment>
`;
exports[`LiveKit ConnectionError variants > should display LiveKit 'serverUnreachable' error correctly 1`] = `
exports[`LiveKit ConnectionError variants > should explain the LiveKit 'serverUnreachable' error and offer a retry 1`] = `
<DocumentFragment>
<div
class="_page_4be5c0"
@@ -600,19 +633,36 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serverUnreac
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Failed to connect to Livekit server
Couldnt connect to voice
</h1>
<p>
An error occurred while connecting to the Livekit server (
<b>
Reason:
</b>
<code>
ServerUnreachable
</code>
).
Your device couldnt reach the voice server. Chat can still work when this happens: voice needs its own live connection, which VPNs, antivirus web protection and some work or school networks block. Try again. If it keeps failing, pause your VPN or antivirus web shield, or try another network such as a phone hotspot.
</p>
<details
class="_technicalDetails_a69dc5"
>
<summary
class="_technicalDetailsSummary_a69dc5"
>
Technical details
</summary>
<pre
class="_technicalDetailsPre_a69dc5"
>
Reason: ServerUnreachable
Status: 503
Server is unreachable
</pre>
</details>
<button
class="_button_1nw83_8"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Try again
</button>
<button
class="_button_1nw83_8"
data-kind="tertiary"
@@ -629,7 +679,7 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serverUnreac
</DocumentFragment>
`;
exports[`LiveKit ConnectionError variants > should display LiveKit 'serviceNotFound' error correctly 1`] = `
exports[`LiveKit ConnectionError variants > should explain the LiveKit 'serviceNotFound' error and offer a retry 1`] = `
<DocumentFragment>
<div
class="_page_4be5c0"
@@ -757,19 +807,35 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serviceNotFo
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Failed to connect to Livekit server
Couldnt connect to voice
</h1>
<p>
An error occurred while connecting to the Livekit server (
<b>
Reason:
</b>
<code>
ServiceNotFound
</code>
).
Your device couldnt reach the voice server. Chat can still work when this happens: voice needs its own live connection, which VPNs, antivirus web protection and some work or school networks block. Try again. If it keeps failing, pause your VPN or antivirus web shield, or try another network such as a phone hotspot.
</p>
<details
class="_technicalDetails_a69dc5"
>
<summary
class="_technicalDetailsSummary_a69dc5"
>
Technical details
</summary>
<pre
class="_technicalDetailsPre_a69dc5"
>
Reason: ServiceNotFound
RTC service not found
</pre>
</details>
<button
class="_button_1nw83_8"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Try again
</button>
<button
class="_button_1nw83_8"
data-kind="tertiary"
@@ -786,7 +852,7 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serviceNotFo
</DocumentFragment>
`;
exports[`LiveKit ConnectionError variants > should display LiveKit 'timeout' error correctly 1`] = `
exports[`LiveKit ConnectionError variants > should explain the LiveKit 'timeout' error and offer a retry 1`] = `
<DocumentFragment>
<div
class="_page_4be5c0"
@@ -914,19 +980,35 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'timeout' err
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Failed to connect to Livekit server
Couldnt connect to voice
</h1>
<p>
An error occurred while connecting to the Livekit server (
<b>
Reason:
</b>
<code>
Timeout
</code>
).
Your device couldnt reach the voice server. Chat can still work when this happens: voice needs its own live connection, which VPNs, antivirus web protection and some work or school networks block. Try again. If it keeps failing, pause your VPN or antivirus web shield, or try another network such as a phone hotspot.
</p>
<details
class="_technicalDetails_a69dc5"
>
<summary
class="_technicalDetailsSummary_a69dc5"
>
Technical details
</summary>
<pre
class="_technicalDetailsPre_a69dc5"
>
Reason: Timeout
Connection timed out
</pre>
</details>
<button
class="_button_1nw83_8"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Try again
</button>
<button
class="_button_1nw83_8"
data-kind="tertiary"
@@ -1084,6 +1166,15 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
</a>
or contact your server administrator.
</p>
<button
class="_button_1nw83_8"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Try again
</button>
<button
class="_button_1nw83_8"
data-kind="tertiary"
@@ -1697,7 +1788,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
</p>
<button
class="_button_1nw83_8"
data-kind="secondary"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
+19 -4
View File
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { t } from "i18next";
import { type ConnectionError } from "livekit-client";
import { type ConnectionError, ConnectionErrorReason } from "livekit-client";
import { i18nKey } from "./i18n";
@@ -53,6 +53,9 @@ export class ElementCallError extends Error {
public localisedMessageKey?: string;
public localisedMessageValues?: Record<string, string>;
/** [lotus] Raw detail shown under a collapsed "Technical details", not in the message. */
public technicalDetails?: string;
protected constructor(
localisedTitle: string,
code: ErrorCode,
@@ -307,9 +310,21 @@ export class LivekitConnectionError extends ElementCallError {
ErrorCode.SFU_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
);
this.localisedMessageKey = i18nKey(
"error.livekit_connection_error_description",
);
// [lotus] "An error occurred while connecting to the Livekit server
// (Reason: ServerUnreachable)" left people with nothing to try (Gitea
// element-call: a friend retried for ages while Element worked). Say what
// it means and what to do; keep the raw reason under Technical details.
this.localisedMessageKey =
cause.reason === ConnectionErrorReason.NotAllowed
? i18nKey("error.livekit_not_allowed_description")
: i18nKey("error.livekit_unreachable_description");
this.localisedMessageValues = { reason: cause.reasonName };
this.technicalDetails = [
`Reason: ${cause.reasonName}`,
cause.status ? `Status: ${cause.status}` : null,
cause.message,
]
.filter(Boolean)
.join("\n");
}
}