Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 3ca252f633 chore(lotus): 0.25.0-lotus.14
CI / Build embedded bundle (push) Successful in 2m58s
CI / Publish to Gitea npm registry (push) Successful in 1m3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:16:39 -04:00
Lotus CIandClaude Opus 5.5 6beebd3ea7 feat(lotus): layout, settings and reactions over the widget API (cinny #43)
The Lotus host drove these by clicking EC's hidden controls in our DOM and
read screenshare/layout state from it, which only works while the frame is
same-origin and breaks whenever a data-testid changes. New actions:

- toWidget io.lotus.set_layout { layout: "grid" | "spotlight" } → the layout
  switch view model's setLayout.
- toWidget io.lotus.open_settings { open?: boolean } → setSettingsOpen
  (toggle when `open` is omitted).
- toWidget io.lotus.toggle_reactions → a window event the reactions button
  listens for.
- fromWidget io.lotus.controls_state { screensharing, layout } on change; its
  arrival tells the host the fork supports the actions above.

Screensharing stays host-driven for now: getDisplayMedia needs the user's
click to reach this frame (Capability Delegation), which a widget message
doesn't carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:16:24 -04:00
Lotus CIandClaude Opus 5.5 d8880daca5 chore(lotus): 0.25.0-lotus.13
CI / Build embedded bundle (push) Successful in 4m3s
CI / Publish to Gitea npm registry (push) Successful in 2m33s
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
12 changed files with 395 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.14",
"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": "Couldn’t connect to voice",
"livekit_not_allowed_description": "The voice server didn’t 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 couldn’t 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": {
+10
View File
@@ -26,6 +26,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import classNames from "classnames";
import { useReactionsSender } from "../reactions/useReactionsSender";
import { LOTUS_TOGGLE_REACTIONS_EVENT } from "../lotus/lotusControls";
import styles from "./ReactionToggleButton.module.css";
import {
type RaisedHandInfo,
@@ -196,6 +197,15 @@ export function ReactionToggleButton({
setErrorText(undefined);
}, [showReactionsMenu]);
// [cinny #43] The Lotus host's call bar toggles this menu over the widget
// API (lotusControls.ts) instead of clicking this button in our DOM.
useEffect(() => {
const toggle = (): void => setShowReactionsMenu((open) => !open);
window.addEventListener(LOTUS_TOGGLE_REACTIONS_EVENT, toggle);
return (): void =>
window.removeEventListener(LOTUS_TOGGLE_REACTIONS_EVENT, toggle);
}, []);
const sendRelation = useCallback(
async (reaction: ReactionOption) => {
try {
+6
View File
@@ -27,6 +27,9 @@ describe("LotusWidgetActions", () => {
LotusWidgetActions.Decorations,
LotusWidgetActions.SetDeafen,
LotusWidgetActions.SetAudioOutput,
LotusWidgetActions.SetLayout,
LotusWidgetActions.OpenSettings,
LotusWidgetActions.ToggleReactions,
];
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
@@ -44,5 +47,8 @@ describe("LotusWidgetActions", () => {
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(
LotusWidgetActions.DenoiseState,
);
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(
LotusWidgetActions.ControlsState,
);
});
});
+21
View File
@@ -62,6 +62,24 @@ export enum LotusWidgetActions {
* else in the call (#39). Each fires at most once per share.
*/
ScreenshareNotice = "io.lotus.screenshare_notice",
/**
* toWidget: switch the call layout `{ layout: "grid" | "spotlight" }`
* (cinny #43 — replaces the host clicking EC's hidden layout radio).
*/
SetLayout = "io.lotus.set_layout",
/**
* toWidget: open EC's settings modal, or close it with `{ open: false }`;
* omit `open` to toggle (cinny #43).
*/
OpenSettings = "io.lotus.open_settings",
/** toWidget: toggle the reactions / raise-hand menu (cinny #43). */
ToggleReactions = "io.lotus.toggle_reactions",
/**
* fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null }`
* whenever either changes (cinny #43), so the host stops reading EC's DOM for
* them. Its arrival also tells the host this fork supports the actions above.
*/
ControlsState = "io.lotus.controls_state",
}
/** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */
@@ -72,4 +90,7 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
LotusWidgetActions.Decorations,
LotusWidgetActions.SetDeafen,
LotusWidgetActions.SetAudioOutput,
LotusWidgetActions.SetLayout,
LotusWidgetActions.OpenSettings,
LotusWidgetActions.ToggleReactions,
];
+35
View File
@@ -0,0 +1,35 @@
/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { parseLayoutPayload, parseSettingsPayload } from "./lotusControls";
describe("parseLayoutPayload", () => {
it("accepts grid and spotlight", () => {
expect(parseLayoutPayload({ layout: "grid" })).toBe("grid");
expect(parseLayoutPayload({ layout: "spotlight" })).toBe("spotlight");
});
it("rejects anything else", () => {
expect(parseLayoutPayload({ layout: "pip" })).toBeUndefined();
expect(parseLayoutPayload({})).toBeUndefined();
expect(parseLayoutPayload(null)).toBeUndefined();
expect(parseLayoutPayload("grid")).toBeUndefined();
});
});
describe("parseSettingsPayload", () => {
it("uses an explicit open flag", () => {
expect(parseSettingsPayload({ open: true }, true)).toBe(true);
expect(parseSettingsPayload({ open: false }, false)).toBe(false);
});
it("toggles when open is missing or not a boolean", () => {
expect(parseSettingsPayload({}, false)).toBe(true);
expect(parseSettingsPayload(undefined, true)).toBe(false);
expect(parseSettingsPayload({ open: "yes" }, true)).toBe(false);
});
});
+104
View File
@@ -0,0 +1,104 @@
/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type IWidgetApiRequest } from "matrix-widget-api";
import { combineLatest, of, type Subscription } from "rxjs";
import { distinctUntilChanged, map, switchMap } from "rxjs/operators";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
import { widget } from "../widget";
import { LotusWidgetActions, lotusSendToHost } from "./lotusWidget";
/** Window event the reactions button listens for (see ReactionToggleButton). */
export const LOTUS_TOGGLE_REACTIONS_EVENT = "lotus:toggle-reactions";
export interface LotusControlsState {
screensharing: boolean;
/** Null while EC offers no layout switch (e.g. PiP or a 1:1 layout). */
layout: LayoutMode | null;
}
/** `{ layout }` payload → a layout mode, or undefined if invalid. Exported for tests. */
export function parseLayoutPayload(data: unknown): LayoutMode | undefined {
if (typeof data !== "object" || data === null) return undefined;
const { layout } = data as { layout?: unknown };
return layout === "grid" || layout === "spotlight" ? layout : undefined;
}
/** `{ open? }` payload → the settings-open state to apply. Exported for tests. */
export function parseSettingsPayload(data: unknown, current: boolean): boolean {
if (typeof data === "object" && data !== null && "open" in data) {
const { open } = data as { open?: unknown };
if (typeof open === "boolean") return open;
}
return !current;
}
/**
* [cinny #43] Widget-API replacements for the host's DOM access to EC's
* controls: layout switch, settings modal and reactions menu, plus a
* `controls_state` report (screensharing + layout) so the host no longer reads
* EC's DOM for them. Screensharing itself stays host-DOM driven for now:
* `getDisplayMedia` needs the user's click to reach this frame (Capability
* Delegation), which a plain widget message doesn't carry.
*
* No effect unless the host sends the actions; registering is safe whenever
* we're a widget. Returns a teardown function.
*/
export function startLotusControls(vm: CallViewModel): () => void {
const w = widget;
if (!w) return (): void => undefined;
const onSetLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
w.api.transport.reply(ev.detail, {});
const layout = parseLayoutPayload(ev.detail.data);
if (layout) vm.layoutSwitchVm$.value?.setLayout(layout);
};
const onOpenSettings = (ev: CustomEvent<IWidgetApiRequest>): void => {
w.api.transport.reply(ev.detail, {});
vm.setSettingsOpen$.value(
parseSettingsPayload(ev.detail.data, vm.settingsOpen$.value),
);
};
const onToggleReactions = (ev: CustomEvent<IWidgetApiRequest>): void => {
w.api.transport.reply(ev.detail, {});
window.dispatchEvent(new Event(LOTUS_TOGGLE_REACTIONS_EVENT));
};
w.lazyActions.on(LotusWidgetActions.SetLayout, onSetLayout);
w.lazyActions.on(LotusWidgetActions.OpenSettings, onOpenSettings);
w.lazyActions.on(LotusWidgetActions.ToggleReactions, onToggleReactions);
const sub: Subscription = combineLatest([
vm.sharingScreen$,
vm.layoutSwitchVm$.pipe(
switchMap((l) => (l ? l.layout$ : of<LayoutMode | null>(null))),
),
])
.pipe(
map(
([screensharing, layout]): LotusControlsState => ({
screensharing,
layout,
}),
),
distinctUntilChanged(
(a, b) => a.screensharing === b.screensharing && a.layout === b.layout,
),
)
.subscribe((state) => {
lotusSendToHost(LotusWidgetActions.ControlsState, state);
});
return (): void => {
sub.unsubscribe();
w.lazyActions.off(LotusWidgetActions.SetLayout, onSetLayout);
w.lazyActions.off(LotusWidgetActions.OpenSettings, onOpenSettings);
w.lazyActions.off(LotusWidgetActions.ToggleReactions, onToggleReactions);
};
}
+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("Couldn’t 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 didn’t let you in/
: /Your device couldn’t 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}`}
>
+4
View File
@@ -31,6 +31,7 @@ import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { widget } from "../widget";
import { startLotusCallState } from "../lotus/lotusCallState";
import { startLotusFocus } from "../lotus/lotusFocus";
import { startLotusControls } from "../lotus/lotusControls";
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
import { startLotusQuality } from "../lotus/lotusQuality";
import { startLotusDecorations } from "../lotus/lotusDecorations";
@@ -300,6 +301,9 @@ export const InCallView: FC<InCallViewProps> = ({
// [lotus] Handle the host's io.lotus.focus_participant action to pin a
// participant to the spotlight (#4). No-op unless the host sends it.
useEffect(() => startLotusFocus(vm), [vm]);
// [cinny #43] layout / settings / reactions over the widget API, plus a
// screensharing + layout report, replacing the host's DOM access.
useEffect(() => startLotusControls(vm), [vm]);
// [lotus] Handle the host's io.lotus.inject_audio action to mix a soundboard
// clip into the call as a separate track (#3). No-op unless the host sends it.
useEffect(() => startLotusAudioInject(vm), [vm]);
@@ -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
Couldn’t connect to voice
</h1>
<p>
An error occurred while connecting to the Livekit server (
<b>
Reason:
</b>
<code>
InternalError
</code>
).
Your device couldn’t 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
Couldn’t connect to voice
</h1>
<p>
An error occurred while connecting to the Livekit server (
<b>
Reason:
</b>
<code>
NotAllowed
</code>
).
The voice server didn’t 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
Couldn’t connect to voice
</h1>
<p>
An error occurred while connecting to the Livekit server (
<b>
Reason:
</b>
<code>
ServerUnreachable
</code>
).
Your device couldn’t 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
Couldn’t connect to voice
</h1>
<p>
An error occurred while connecting to the Livekit server (
<b>
Reason:
</b>
<code>
ServiceNotFound
</code>
).
Your device couldn’t 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
Couldn’t connect to voice
</h1>
<p>
An error occurred while connecting to the Livekit server (
<b>
Reason:
</b>
<code>
Timeout
</code>
).
Your device couldn’t 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");
}
}