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 && ( + + )} + + ); +} 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);