diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx
index c74705a85..c2f38bbdb 100644
--- a/src/app/components/CallEmbedProvider.tsx
+++ b/src/app/components/CallEmbedProvider.tsx
@@ -82,6 +82,7 @@ import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
+import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
import { zIndices } from '../styles/zIndex';
const PIP_MIN_W = 200;
@@ -231,13 +232,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
)}
{!webRTCSupported() && (
-
- Your browser does not support WebRTC, which is required for calling.
-
+
)}
{willAnswerWithCamera && (
diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx
index 7611aa39d..f4e21a5ab 100644
--- a/src/app/features/call/CallView.tsx
+++ b/src/app/features/call/CallView.tsx
@@ -23,6 +23,7 @@ import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
+import { CallsUnavailableMessage } from './CallsUnavailable';
import { webRTCSupported } from '../../utils/rtc';
function LivekitServerMissingMessage() {
@@ -33,25 +34,19 @@ function LivekitServerMissingMessage() {
);
}
-function WebRTCMissingError() {
- return (
-
- Your browser does not support WebRTC, which is required for calling.
-
- );
-}
-
function JoinMessage({
+ roomId,
hasParticipant,
livekitSupported,
rtcSupported,
}: {
+ roomId: string;
hasParticipant?: boolean;
livekitSupported?: boolean;
rtcSupported?: boolean;
}) {
if (rtcSupported === false) {
- return ;
+ return ;
}
if (livekitSupported === false) {
@@ -144,6 +139,7 @@ function CallPrescreen() {
)}
{!inOtherCall && hasPermission && !channelFull && (
{
+ const { webAppUrl } = useClientConfig();
+ return useMemo(
+ () =>
+ isTauri() && !webRTCSupported()
+ ? getCallInBrowserUrl(resolveWebAppUrl(webAppUrl), roomId)
+ : undefined,
+ [webAppUrl, roomId],
+ );
+};
+
+/** Opens through the desktop's new-window handler → the system browser. */
+export const openCallInBrowser = (url: string): void => {
+ window.open(url, '_blank', 'noopener,noreferrer');
+};
+
+const desktopUnavailableText = (): string =>
+ typeof navigator !== 'undefined' && /Linux/i.test(navigator.userAgent)
+ ? 'Calls aren’t available in the desktop app on Linux yet: its web engine has no WebRTC.'
+ : 'Calls aren’t available in this desktop app: its web engine has no WebRTC.';
+
+type CallsUnavailableMessageProps = {
+ roomId: string;
+ /** Button label, e.g. "Answer in browser" for an incoming call. */
+ actionLabel?: string;
+};
+
+export function CallsUnavailableMessage({
+ roomId,
+ actionLabel = 'Open in browser',
+}: CallsUnavailableMessageProps) {
+ const url = useCallInBrowserUrl(roomId);
+
+ if (!isTauri()) {
+ return (
+
+ Your browser does not support WebRTC, which is required for calling.
+
+ );
+ }
+
+ return (
+
+
+ {desktopUnavailableText()}
+ {url ? ' You can join from the web app in your browser.' : ''}
+
+ {url && (
+ }
+ onClick={() => openCallInBrowser(url)}
+ >
+ {actionLabel}
+
+ )}
+
+ );
+}
diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx
index 38a75ff05..fe6847da4 100644
--- a/src/app/features/room/RoomViewHeader.tsx
+++ b/src/app/features/room/RoomViewHeader.tsx
@@ -80,6 +80,7 @@ import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { threadsListAtom } from '../../state/threadsList';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
+import { openCallInBrowser, useCallInBrowserUrl } from '../call/CallsUnavailable';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
type RoomMenuProps = {
@@ -465,6 +466,36 @@ function CallRulesChip({ room }: { room: Room }) {
);
}
+/**
+ * [cinny-desktop: Linux calls] Stand-in for CallButton where this app can't
+ * make calls (desktop on WebKitGTK): opens the room in the web app instead.
+ */
+function CallInBrowserButton({ url }: { url: string }) {
+ return (
+
+ Call in your browser (this desktop app can’t make calls here)
+
+ }
+ >
+ {(triggerRef) => (
+ openCallInBrowser(url)}
+ >
+
+
+ )}
+
+ );
+}
+
function CallButton() {
const room = useRoom();
const direct = useIsDirectRoom();
@@ -555,6 +586,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
);
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
+ const callInBrowserUrl = useCallInBrowserUrl(room.roomId);
const [menuAnchor, setMenuAnchor] = useState();
const [pinMenuAnchor, setPinMenuAnchor] = useState();
@@ -782,11 +814,16 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
{screenSize === ScreenSize.Desktop && }
{!room.isCallRoom() &&
livekitSupported &&
- rtcSupported &&
+ (rtcSupported || callInBrowserUrl) &&
hasCallPermission &&
(direct ||
(room.getJoinRule() === 'invite' &&
- getStateEvents(room, StateEvent.SpaceParent).length === 0)) && }
+ getStateEvents(room, StateEvent.SpaceParent).length === 0)) &&
+ (rtcSupported ? (
+
+ ) : (
+ callInBrowserUrl &&
+ ))}
{screenSize === ScreenSize.Desktop && (
(null);
diff --git a/src/app/utils/callInBrowser.test.ts b/src/app/utils/callInBrowser.test.ts
new file mode 100644
index 000000000..9f6b6287c
--- /dev/null
+++ b/src/app/utils/callInBrowser.test.ts
@@ -0,0 +1,32 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { getCallInBrowserUrl, resolveWebAppUrl } from './callInBrowser';
+
+test('resolveWebAppUrl keeps an https origin (+ path) without a trailing slash', () => {
+ assert.equal(resolveWebAppUrl('https://chat.example.org'), 'https://chat.example.org');
+ assert.equal(resolveWebAppUrl('https://chat.example.org/'), 'https://chat.example.org');
+ assert.equal(resolveWebAppUrl('https://example.org/chat/'), 'https://example.org/chat');
+ assert.equal(resolveWebAppUrl('https://chat.example.org/?x=1#y'), 'https://chat.example.org');
+});
+
+test('resolveWebAppUrl rejects anything but absolute https', () => {
+ [
+ undefined,
+ null,
+ 1,
+ '',
+ ' ',
+ '/home',
+ 'http://chat.example.org',
+ 'not a url',
+ ['javascript', 'alert(1)'].join(':'),
+ 'data:text/html,x',
+ ].forEach((v) => assert.equal(resolveWebAppUrl(v), undefined, String(v)));
+});
+
+test('getCallInBrowserUrl points at the room in the web app (no auto-join)', () => {
+ const url = getCallInBrowserUrl('https://chat.example.org', '!room:example.org');
+ assert.equal(url, 'https://chat.example.org/home/!room%3Aexample.org');
+ assert.ok(!/join|call=/i.test(url ?? ''));
+ assert.equal(getCallInBrowserUrl(undefined, '!room:example.org'), undefined);
+});
diff --git a/src/app/utils/callInBrowser.ts b/src/app/utils/callInBrowser.ts
new file mode 100644
index 000000000..721cada66
--- /dev/null
+++ b/src/app/utils/callInBrowser.ts
@@ -0,0 +1,27 @@
+import { getLotusRoomPermalink } from '../plugins/lotus-permalink';
+
+/**
+ * The public web app's address from config.json `webAppUrl` (the desktop app
+ * sets it; its own origin is a local server). Absolute https only; anything
+ * else is ignored.
+ */
+export const resolveWebAppUrl = (value: unknown): string | undefined => {
+ if (typeof value !== 'string' || value.trim() === '') return undefined;
+ try {
+ const url = new URL(value);
+ if (url.protocol !== 'https:') return undefined;
+ return url.origin + url.pathname.replace(/\/+$/, '');
+ } catch {
+ return undefined;
+ }
+};
+
+/**
+ * Where to send the user to take a call this app can't make: the room in the
+ * web app (they press Join there themselves — never an auto-join link, which
+ * a crafted URL could abuse to open someone's mic).
+ */
+export const getCallInBrowserUrl = (
+ webAppUrl: string | undefined,
+ roomId: string,
+): string | undefined => (webAppUrl ? getLotusRoomPermalink(webAppUrl, roomId) : undefined);