Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 fff583fca5 feat(call): desktop without WebRTC offers "Open in browser" instead of a dead end
CI / Build & Quality Checks (pull_request) Successful in 2m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m22s
The Linux desktop app runs on WebKitGTK, which ships without WebRTC (2.52
has no RTCPeerConnection; 2.54 disables it outright pending a libwebrtc
backend around 2.56), so calls can't work there. Until now the call button
just disappeared, the call room said "Your browser does not support WebRTC"
with Join disabled, and an incoming call couldn't be answered.

In the desktop app (isTauri) without WebRTC:
- call rooms: "Calls aren't available in the desktop app on Linux yet: its
  web engine has no WebRTC" + an "Open in browser" button;
- incoming-call overlay: the same, with "Answer in browser";
- room header: the call button stays, and opens the room in the browser.

The link is the room in the web app (config.json `webAppUrl`, https only,
new key); the user presses Join there. Deliberately not an auto-join link:
a crafted URL must not be able to join a call and open someone's mic. It
opens through the desktop's new-window handler (web/mail schemes only → the
system browser). Without `webAppUrl` the explanation shows with no button;
browsers without WebRTC keep the old message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 16:54:29 -04:00
jared e2b23397bd Merge pull request #244: screenshare stays on the call bar in Firefox/Safari (#43)
CI / Build & Quality Checks (push) Successful in 3m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 11m34s
2026-09-27 13:54:11 -04:00
Lotus CIandClaude Opus 5.5 b929cbf74b chore(call): pin element-call-embedded 0.25.0-lotus.22 (#43)
CI / Build & Quality Checks (pull_request) Successful in 4m14s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m39s
lotus.22 draws the "Share your screen?" prompt inside the call frame on
request and drops the corner button. Verified against the published
package: no-delegation (Firefox path) cross- and same-origin — bar button,
in-frame prompt, Cancel, Share → screenshare tracks, bar Stop; Chromium
unchanged; picture-in-picture prompt fits and Share works; room policy
hides the button and refuses the prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 13:32:45 -04:00
Lotus CIandClaude Opus 5.5 8520de232d feat(call): screenshare stays on the call bar in Firefox/Safari via the in-frame prompt (#43)
CI / Build & Quality Checks (pull_request) Successful in 4m18s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m27s
With a fork that reports `screensharePrompt` (element-call lotus-screenshare-
prompt), the call bar and status bar keep their screenshare button on every
engine. Where the click can't be delegated, starting asks the fork to show
"Share your screen?" inside the call frame (io.lotus.prompt_screenshare)
instead of our own confirm; its Share click starts the share. Stopping works
from the bar as before (no click needed in the frame). Chromium is unchanged.

- useScreenshareMode: hidden (older fork's corner button) | prompt | direct.
- The room's call policy is also pushed to the fork in prompt mode, so the
  prompt never opens where sharing is forbidden.
- Picture-in-picture: the "Return to call" overlay covers the frame; while
  the fork reports the prompt open it lets clicks through to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 12:48:12 -04:00
jared 773e41311e Merge pull request #243: optional Element Call origin via config.json elementCallUrl (#43)
CI / Build & Quality Checks (push) Successful in 4m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 13s
CI / Playwright smoke (e2e) (push) Successful in 14m40s
2026-09-27 02:24:47 -04:00
13 changed files with 310 additions and 54 deletions
+4 -4
View File
@@ -81,7 +81,7 @@
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.13.0", "@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.21", "@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
}, },
"node_modules/@lotusguild/element-call-embedded": { "node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.21", "version": "0.25.0-lotus.22",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.21/element-call-embedded-0.25.0-lotus.21.tgz", "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
"integrity": "sha512-Phtjop2qdKRrCLqAhzipRaJaVnU3MjK9CV4QwHkwU/Xlj8oguZBfjOlV7zTqOTzAr57OteOfLcyxLsmv5+u9tg==", "integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
"dev": true "dev": true
}, },
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": { "node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.13.0", "@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.21", "@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
+7 -8
View File
@@ -38,7 +38,7 @@ import {
} from '../hooks/useCallEmbed'; } from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast'; import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState } from '../plugins/call'; import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
import { hangupCallAndWait } from '../plugins/call/hangup'; import { hangupCallAndWait } from '../plugins/call/hangup';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
@@ -82,6 +82,7 @@ import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet'; import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
import { CallAvatarAnimation } from '../styles/Animations.css'; import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc'; import { webRTCSupported } from '../utils/rtc';
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
import { zIndices } from '../styles/zIndex'; import { zIndices } from '../styles/zIndex';
const PIP_MIN_W = 200; const PIP_MIN_W = 200;
@@ -231,13 +232,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
</Text> </Text>
)} )}
{!webRTCSupported() && ( {!webRTCSupported() && (
<Text <CallsUnavailableMessage roomId={room.roomId} actionLabel="Answer in browser" />
style={{ margin: 'auto', color: color.Critical.Main }}
size="L400"
align="Center"
>
Your browser does not support WebRTC, which is required for calling.
</Text>
)} )}
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
{willAnswerWithCamera && ( {willAnswerWithCamera && (
@@ -882,6 +877,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callActive = callEmbed && joined; const callActive = callEmbed && joined;
const callVisible = inCallRoom && callActive && !chatOnlyView; const callVisible = inCallRoom && callActive && !chatOnlyView;
const pipMode = callActive && !inCallRoom; const pipMode = callActive && !inCallRoom;
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control); const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
@@ -1363,6 +1359,9 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
zIndex: 1, zIndex: 1,
background: 'transparent', background: 'transparent',
cursor: 'grab', cursor: 'grab',
// [Gitea #43] The call's "Share your screen?" prompt is drawn
// inside the frame: let the click reach its Share button.
pointerEvents: pipPromptOpen ? 'none' : undefined,
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
justifyContent: 'flex-end', justifyContent: 'flex-end',
+11 -7
View File
@@ -7,7 +7,7 @@ import {
CallEmbed, CallEmbed,
useCallControlState, useCallControlState,
useCallMicLevel, useCallMicLevel,
useFrameScreenshare, useScreenshareMode,
} from '../../plugins/call'; } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
@@ -197,10 +197,16 @@ export function CallControl({
// Keep a forbidden control visible while its track is still live (so the user // Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely. // can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video; const showCamera = allowCamera || video;
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame. // [Gitea #43] Hidden where EC shows its own screenshare button in the frame;
const frameScreenshare = useFrameScreenshare(callEmbed.control); // where the click can't be delegated, starting asks for the in-frame prompt.
const showScreenshare = !frameScreenshare && (allowScreenshare || screenshare); const screenshareMode = useScreenshareMode(callEmbed.control);
const showScreenshare = screenshareMode !== 'hidden' && (allowScreenshare || screenshare);
const [shareConfirm, setShareConfirm] = useState(false); const [shareConfirm, setShareConfirm] = useState(false);
const handleScreenshareToggle = () => {
if (screenshare) callEmbed.control.toggleScreenshare();
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
else setShareConfirm(true);
};
const handleMicrophoneToggle = useCallback( const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(), () => callEmbed.control.toggleMicrophone(),
@@ -255,9 +261,7 @@ export function CallControl({
{!compact && showScreenshare && ( {!compact && showScreenshare && (
<ScreenShareButton <ScreenShareButton
enabled={screenshare} enabled={screenshare}
onToggle={() => onToggle={() => handleScreenshareToggle()}
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
disabled={!callJoined} disabled={!callJoined}
/> />
)} )}
+11 -8
View File
@@ -33,7 +33,7 @@ import {
CallEmbed, CallEmbed,
useCallControlState, useCallControlState,
useCallMicLevel, useCallMicLevel,
useFrameScreenshare, useScreenshareMode,
} from '../../plugins/call'; } from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
@@ -112,9 +112,14 @@ export function CallControls({ callEmbed }: CallControlsProps) {
// can stop it); otherwise hide it entirely. // can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video; const showCamera = allowCamera || video;
// [Gitea #43] Where EC shows its own screenshare button in the frame, ours // [Gitea #43] Where EC shows its own screenshare button in the frame, ours
// is hidden (this engine can't start a share from the host); the // is hidden (the screenshare-audio mute stays); where the click can't be
// screenshare-audio mute stays. // delegated, starting asks for the fork's in-frame "Share your screen?".
const frameScreenshare = useFrameScreenshare(callEmbed.control); const screenshareMode = useScreenshareMode(callEmbed.control);
const handleScreenshareToggle = () => {
if (screenshare) callEmbed.control.toggleScreenshare();
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
else setShareConfirm(true);
};
const showScreenshare = allowScreenshare || screenshare; const showScreenshare = allowScreenshare || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled; const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
@@ -225,12 +230,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />} {showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && ( {showScreenshare && (
<> <>
{!frameScreenshare && ( {screenshareMode !== 'hidden' && (
<ScreenShareButton <ScreenShareButton
enabled={screenshare} enabled={screenshare}
onToggle={() => onToggle={() => handleScreenshareToggle()}
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
/> />
)} )}
{/* Mute-screenshare-audio sits directly next to the screenshare {/* Mute-screenshare-audio sits directly next to the screenshare
+5 -9
View File
@@ -23,6 +23,7 @@ import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css'; import * as css from './styles.css';
import { CallControls } from './CallControls'; import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { CallsUnavailableMessage } from './CallsUnavailable';
import { webRTCSupported } from '../../utils/rtc'; import { webRTCSupported } from '../../utils/rtc';
function LivekitServerMissingMessage() { 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({ function JoinMessage({
roomId,
hasParticipant, hasParticipant,
livekitSupported, livekitSupported,
rtcSupported, rtcSupported,
}: { }: {
roomId: string;
hasParticipant?: boolean; hasParticipant?: boolean;
livekitSupported?: boolean; livekitSupported?: boolean;
rtcSupported?: boolean; rtcSupported?: boolean;
}) { }) {
if (rtcSupported === false) { if (rtcSupported === false) {
return <WebRTCMissingError />; return <CallsUnavailableMessage roomId={roomId} />;
} }
if (livekitSupported === false) { if (livekitSupported === false) {
@@ -144,6 +139,7 @@ function CallPrescreen() {
)} )}
{!inOtherCall && hasPermission && !channelFull && ( {!inOtherCall && hasPermission && !channelFull && (
<JoinMessage <JoinMessage
roomId={room.roomId}
hasParticipant={hasParticipant} hasParticipant={hasParticipant}
livekitSupported={livekitSupported} livekitSupported={livekitSupported}
rtcSupported={rtcSupported} 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 { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { threadsListAtom } from '../../state/threadsList'; import { threadsListAtom } from '../../state/threadsList';
import { usePendingKnocks } from '../../hooks/usePendingKnocks'; import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { openCallInBrowser, useCallInBrowserUrl } from '../call/CallsUnavailable';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel'; import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
type RoomMenuProps = { 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() { function CallButton() {
const room = useRoom(); const room = useRoom();
const direct = useIsDirectRoom(); const direct = useIsDirectRoom();
@@ -555,6 +586,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
); );
const livekitSupported = useLivekitSupport(); const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported(); const rtcSupported = webRTCSupported();
const callInBrowserUrl = useCallInBrowserUrl(room.roomId);
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>(); const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
@@ -782,11 +814,16 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
{screenSize === ScreenSize.Desktop && <CallRulesChip room={room} />} {screenSize === ScreenSize.Desktop && <CallRulesChip room={room} />}
{!room.isCallRoom() && {!room.isCallRoom() &&
livekitSupported && livekitSupported &&
rtcSupported && (rtcSupported || callInBrowserUrl) &&
hasCallPermission && hasCallPermission &&
(direct || (direct ||
(room.getJoinRule() === 'invite' && (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 && ( {screenSize === ScreenSize.Desktop && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
+6
View File
@@ -25,6 +25,12 @@ export type ClientConfig = {
* Unset: the bundled copy on this origin. Ignored in the desktop app. * Unset: the bundled copy on this origin. Ignored in the desktop app.
*/ */
elementCallUrl?: string; 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); const ClientConfigContext = createContext<ClientConfig | null>(null);
+54 -5
View File
@@ -261,6 +261,15 @@ export class CallControl extends EventEmitter implements CallControlState {
private frameScreenshareListeners = new Set<() => void>(); private frameScreenshareListeners = new Set<() => void>();
// [Gitea #43] The fork draws the "Share your screen?" prompt inside the frame
// on request (io.lotus.prompt_screenshare), so where the click can't be
// delegated our call-bar button asks for that instead of starting the share.
private _screensharePrompt = false;
// [Gitea #43] The fork's in-frame prompt is showing: anything the host lays
// over the frame (the picture-in-picture overlay) must let clicks through.
private _screensharePromptOpen = false;
// [Gitea #43] Whether the room's call policy allows screensharing; the fork // [Gitea #43] Whether the room's call policy allows screensharing; the fork
// hides its in-frame button when it doesn't. // hides its in-frame button when it doesn't.
private frameScreenshareAllowed = true; private frameScreenshareAllowed = true;
@@ -274,7 +283,32 @@ export class CallControl extends EventEmitter implements CallControlState {
return this._frameScreenshare; return this._frameScreenshare;
} }
/** Subscribe to `frameScreenshare` changes. Returns an unsubscribe. */ /**
* Starting a share must go through the fork's in-frame prompt: this engine
* can't delegate the click (Firefox, Safari, WebKitGTK) and the fork has
* the prompt. Stopping never needs it.
*/
public get screenshareNeedsPrompt(): boolean {
return this._screensharePrompt && !canDelegateCapability();
}
/** The fork's in-frame "Share your screen?" prompt is showing. */
public get screensharePromptOpen(): boolean {
return this._screensharePromptOpen;
}
/**
* Ask the fork to show "Share your screen?" inside the call frame; its Share
* button starts the share with the frame's own click.
*/
public promptScreenshare(): void {
this.sendForkAction('io.lotus.prompt_screenshare', {});
}
/**
* Subscribe to `frameScreenshare` / `screenshareNeedsPrompt` /
* `screensharePromptOpen` changes. Returns an unsubscribe.
*/
public onFrameScreenshareChange(cb: () => void): () => void { public onFrameScreenshareChange(cb: () => void): () => void {
this.frameScreenshareListeners.add(cb); this.frameScreenshareListeners.add(cb);
return () => { return () => {
@@ -289,7 +323,8 @@ export class CallControl extends EventEmitter implements CallControlState {
} }
private sendFrameScreenshareAllowed(): void { private sendFrameScreenshareAllowed(): void {
if (!this.joined || !this.forkReady || !this._frameScreenshare) return; if (!this.joined || !this.forkReady) return;
if (!this._frameScreenshare && !this._screensharePrompt) return;
this.sendForkAction('io.lotus.set_frame_screenshare', { this.sendForkAction('io.lotus.set_frame_screenshare', {
visible: this.frameScreenshareAllowed, visible: this.frameScreenshareAllowed,
}); });
@@ -324,18 +359,32 @@ export class CallControl extends EventEmitter implements CallControlState {
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */ /** [Gitea #43] The fork's `io.lotus.controls_state` report. */
public onControlsState(data: unknown) { public onControlsState(data: unknown) {
if (typeof data !== 'object' || data === null) return; if (typeof data !== 'object' || data === null) return;
const { screensharing, layout, frameScreenshare } = data as { const { screensharing, layout, frameScreenshare, screensharePrompt, screensharePromptOpen } =
data as {
screensharing?: unknown; screensharing?: unknown;
layout?: unknown; layout?: unknown;
frameScreenshare?: unknown; frameScreenshare?: unknown;
screensharePrompt?: unknown;
screensharePromptOpen?: unknown;
}; };
const firstReport = !this.forkReady; const firstReport = !this.forkReady;
this.forkReady = true; this.forkReady = true;
const frame = frameScreenshare === true; const frame = frameScreenshare === true;
if (frame !== this._frameScreenshare) { const prompt = screensharePrompt === true;
this._frameScreenshare = frame; const promptOpen = screensharePromptOpen === true;
if (promptOpen !== this._screensharePromptOpen) {
this._screensharePromptOpen = promptOpen;
if (frame === this._frameScreenshare && prompt === this._screensharePrompt) {
this.frameScreenshareListeners.forEach((l) => l()); this.frameScreenshareListeners.forEach((l) => l());
} }
}
if (frame !== this._frameScreenshare || prompt !== this._screensharePrompt) {
const promptTurnedOn = prompt && !this._screensharePrompt;
this._frameScreenshare = frame;
this._screensharePrompt = prompt;
this.frameScreenshareListeners.forEach((l) => l());
if (promptTurnedOn && !firstReport) this.sendFrameScreenshareAllowed();
}
if (firstReport) { if (firstReport) {
this.sendHotkeyCodes(); this.sendHotkeyCodes();
this.sendFrameScreenshareAllowed(); this.sendFrameScreenshareAllowed();
+34 -7
View File
@@ -75,19 +75,46 @@ export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => {
}; };
/** /**
* [Gitea #43] True when EC's own screenshare button is shown inside the call * [Gitea #43] How the call bar's screenshare button behaves:
* frame (no Capability Delegation in this engine), so the host bar hides its. * - `hidden`: EC's own button is inside the frame (fork lotus.20/21), so ours hides;
* - `prompt`: starting asks the fork for its in-frame "Share your screen?"
* (no Capability Delegation in this engine);
* - `direct`: our own confirm, then a (delegated) set_screenshare.
*/ */
export const useFrameScreenshare = (control: CallControl | undefined): boolean => { export type ScreenshareMode = 'hidden' | 'prompt' | 'direct';
const [frame, setFrame] = useState(() => control?.frameScreenshare ?? false);
const screenshareMode = (control: CallControl): ScreenshareMode => {
if (control.frameScreenshare) return 'hidden';
return control.screenshareNeedsPrompt ? 'prompt' : 'direct';
};
export const useScreenshareMode = (control: CallControl | undefined): ScreenshareMode => {
const [mode, setMode] = useState<ScreenshareMode>(() =>
control ? screenshareMode(control) : 'direct',
);
useEffect(() => { useEffect(() => {
if (!control) { if (!control) {
setFrame(false); setMode('direct');
return undefined; return undefined;
} }
const sync = () => setFrame(control.frameScreenshare); const sync = () => setMode(screenshareMode(control));
sync(); sync();
return control.onFrameScreenshareChange(sync); return control.onFrameScreenshareChange(sync);
}, [control]); }, [control]);
return frame; return mode;
};
/** [Gitea #43] The fork's in-frame "Share your screen?" prompt is showing. */
export const useScreensharePromptOpen = (control: CallControl | undefined): boolean => {
const [open, setOpen] = useState(() => control?.screensharePromptOpen ?? false);
useEffect(() => {
if (!control) {
setOpen(false);
return undefined;
}
const sync = () => setOpen(control.screensharePromptOpen);
sync();
return control.onFrameScreenshareChange(sync);
}, [control]);
return open;
}; };
+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);