Desktop without WebRTC: "Open in browser" instead of a dead end (cinny-desktop #21) #245

Merged
jared merged 1 commits from calls-open-in-browser into lotus 2026-09-27 17:10:51 -04:00
7 changed files with 187 additions and 18 deletions
+2 -7
View File
@@ -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
</Text>
)}
{!webRTCSupported() && (
<Text
style={{ margin: 'auto', color: color.Critical.Main }}
size="L400"
align="Center"
>
Your browser does not support WebRTC, which is required for calling.
</Text>
<CallsUnavailableMessage roomId={room.roomId} actionLabel="Answer in browser" />
)}
<Box direction="Column" gap="300">
{willAnswerWithCamera && (
+5 -9
View File
@@ -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 (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your browser does not support WebRTC, which is required for calling.
</Text>
);
}
function JoinMessage({
roomId,
hasParticipant,
livekitSupported,
rtcSupported,
}: {
roomId: string;
hasParticipant?: boolean;
livekitSupported?: boolean;
rtcSupported?: boolean;
}) {
if (rtcSupported === false) {
return <WebRTCMissingError />;
return <CallsUnavailableMessage roomId={roomId} />;
}
if (livekitSupported === false) {
@@ -144,6 +139,7 @@ function CallPrescreen() {
)}
{!inOtherCall && hasPermission && !channelFull && (
<JoinMessage
roomId={room.roomId}
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
@@ -0,0 +1,76 @@
import React, { useMemo } from 'react';
import { Box, Button, Icon, Icons, Text, color } from 'folds';
import { useClientConfig } from '../../hooks/useClientConfig';
import { isTauri } from '../../hooks/useTauri';
import { webRTCSupported } from '../../utils/rtc';
import { getCallInBrowserUrl, resolveWebAppUrl } from '../../utils/callInBrowser';
/**
* [cinny-desktop: Linux calls] The desktop app on Linux runs on WebKitGTK,
* which ships without WebRTC (disabled in 2.54; a libwebrtc backend is due
* around 2.56). Where calls can't work here, send the user to the room in the
* web app instead of a dead end. Only in the desktop app, and only when
* config.json sets `webAppUrl`.
*/
export const useCallInBrowserUrl = (roomId: string): string | undefined => {
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 (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your browser does not support WebRTC, which is required for calling.
</Text>
);
}
return (
<Box direction="Column" alignItems="Center" gap="200" style={{ margin: 'auto' }}>
<Text style={{ color: color.Warning.Main }} size="L400" align="Center">
{desktopUnavailableText()}
{url ? ' You can join from the web app in your browser.' : ''}
</Text>
{url && (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
before={<Icon size="100" src={Icons.External} />}
onClick={() => openCallInBrowser(url)}
>
<Text size="B300">{actionLabel}</Text>
</Button>
)}
</Box>
);
}
+39 -2
View File
@@ -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 (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Call in your browser (this desktop app can’t make calls here)</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
variant="Surface"
fill="None"
ref={triggerRef}
aria-label="Start call in browser"
onClick={() => openCallInBrowser(url)}
>
<Icon size="400" src={Icons.VideoCamera} />
</IconButton>
)}
</TooltipProvider>
);
}
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<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
@@ -782,11 +814,16 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
{screenSize === ScreenSize.Desktop && <CallRulesChip room={room} />}
{!room.isCallRoom() &&
livekitSupported &&
rtcSupported &&
(rtcSupported || callInBrowserUrl) &&
hasCallPermission &&
(direct ||
(room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
getStateEvents(room, StateEvent.SpaceParent).length === 0)) &&
(rtcSupported ? (
<CallButton />
) : (
callInBrowserUrl && <CallInBrowserButton url={callInBrowserUrl} />
))}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
+6
View File
@@ -25,6 +25,12 @@ export type ClientConfig = {
* Unset: the bundled copy on this origin. Ignored in the desktop app.
*/
elementCallUrl?: string;
/**
* Absolute https URL of the public web app (e.g. https://chat.lotusguild.org).
* The desktop app sets it so it can hand calls it can't make to the browser.
*/
webAppUrl?: string;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);
+32
View File
@@ -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);
});
+27
View File
@@ -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);