Compare commits

...
5 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 a475531b2b feat(calls): 'N Live' pill names who is in the call on hover/focus (#157)
CI / Build & Quality Checks (push) Successful in 1m51s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 1m37s
Tooltip (folds TooltipProvider, 300 ms) on the room-list Live badge listing the
call members' display names — deduped per user, capped at 6 + 'and N more' —
plus an aria-label with the same text so keyboard/screen-reader users get it
too (the badge is focusable). Names come from the same MatrixRTC memberships
that produce the count; no new data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:18:20 -04:00
jaredandClaude Opus 5 34574178a9 feat(calls): toast when a room admin turns off screen sharing / camera mid-call (#223)
Re-measured the revoke on the current stack (guard fix + fork lotus.4): the
SFU drops the sharer's tracks and EC's own button, the captured tracks and our
control bar all follow within ~2-4 s — the 30 s lag is gone. What remained was
that the button simply vanished with no explanation, so a call-lifetime hook
now toasts 'Screen sharing was turned off by a room admin.' (or the camera
variant) when a track ends while the room policy forbids it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 01:12:25 -04:00
jaredandClaude Opus 5 070a1ea012 fix(calls): hang up explicitly when answering another call while in one (#195)
Answering from the in-call banner is the only call-to-call switch path; it
started the new embed and let the atom dispose the old one, which just removed
the iframe — our m.call.member in the old room lingered ~17 s (until the
delayed leave expired), so everyone there still saw us in the call.

hangupAndWait moves out of LogoutDialog into plugins/call/hangup.ts and
handleAnswer now hangs up, waits for our membership to clear (bounded 4 s),
disposes the old embed itself (its HangupCall echo would otherwise land after
startCall and clear the NEW embed from the atom — seen in testing), then joins
the new call. Measured headless: old membership gone in 1 s, new call live
with both participants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 01:04:26 -04:00
jaredandClaude Opus 5 bbcbdad55a refactor(calls): drop the deafen iframe-DOM fallback now that the fork owns it (#209)
CallControl.setSound()/applyScreenshareAudioMuted() no longer poke <audio>.muted
inside the EC frame, and the per-membership re-apply (useCallMemberSoundSync)
is gone: with the pin at 0.25.0-lotus.4 the fork applies deafen and the
screenshare-audio mute through its own renderer, including for late joiners.
Verified headless: deafen set before a second participant joins mutes their
track on arrival; deafen/undeafen leaves the screenshare-audio mute in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:53:24 -04:00
jaredandClaude Opus 5 a9e0b893be fix(calls): opaque backdrop for the PiP call window (#224)
The fork renders EC transparent so the room wallpaper shows through the
full-size embed; in PiP the element behind the frame is whatever room you
navigated to, so the timeline bled through the participant tiles. The PiP
container now gets the surface colour (the full embed keeps the wallpaper).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:45:38 -04:00
7 changed files with 154 additions and 70 deletions
+26 -6
View File
@@ -34,18 +34,19 @@ import {
useCallHangupEvent,
useCallJoined,
useCallThemeSync,
useCallMemberSoundSync,
useCallStart,
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { hangupCallAndWait } from '../plugins/call/hangup';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
import { useCallHotkeys } from '../hooks/useCallHotkeys';
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
import { useCallQuality } from '../hooks/useCallQuality';
@@ -422,6 +423,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm);
const setCallEmbed = useSetAtom(callEmbedAtom);
const { microphone, sound } = useCallPreferences();
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
@@ -623,16 +625,26 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
);
const handleAnswer = useCallback(
(room: Room, video: boolean) => {
async (room: Room, video: boolean) => {
setCallInfo(undefined);
// [Gitea #195] Answering from the in-call banner is the one call-to-call
// switch path. Disposing the current embed alone leaves our ghost
// `m.call.member` in the old room for ~17 s (until the delayed leave
// expires) — hang up explicitly and wait for the membership to clear.
if (callEmbed?.joined) {
await hangupCallAndWait(mx, callEmbed);
// Dispose it ourselves now: its HangupCall echo would otherwise land
// after startCall() and clear the NEW embed from the atom.
setCallEmbed(undefined);
}
// Honour cameraOnJoin and the persisted mic/sound preferences instead of
// forcing camera+mic+sound on — every other join path does this, and
// Answer was skipping it, publishing the camera with no prescreen.
// (PTT's forceAudioOff is applied downstream inside useCallStart.)
startCall(room, { microphone, video: cameraOnJoin && video, sound });
setCallInfo(undefined);
navigateRoom(room.roomId);
},
[startCall, navigateRoom, microphone, sound, cameraOnJoin],
[startCall, navigateRoom, microphone, sound, cameraOnJoin, callEmbed, mx, setCallEmbed],
);
if (!callInfo) return null;
@@ -673,8 +685,8 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
// the mobile in-call chat. Both are gated on `joined`.
useCallHotkeys(embed, joined);
useAfkAutoMute(joined ? embed : undefined);
useCallMemberSoundSync(embed);
useCallJoinLeaveSounds(embed);
useCallPolicyRevokedToast(embed, joined);
useCallThemeSync(embed);
useCallQuality(embed);
useCallHangupEvent(
@@ -1231,7 +1243,15 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
left: 0,
width: '100%',
height: '50%',
...(callVisible && !pipMode ? wallpaperStyle : {}),
// [Gitea #224] The fork renders EC transparent (lotusTransparent) so the
// wallpaper shows through the full-size embed; in PiP the thing behind
// the frame is whatever room you navigated to, so give it an opaque
// surface instead of letting the timeline bleed through the tiles.
...(pipMode
? { background: color.Surface.Container }
: callVisible
? wallpaperStyle
: {}),
}}
ref={callEmbedRef}
>
+2 -24
View File
@@ -1,11 +1,10 @@
import React, { forwardRef, useCallback } from 'react';
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
import { useAtom } from 'jotai';
import { MatrixClient } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { logoutClient } from '../../client/initMatrix';
import { callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call';
import { hangupCallAndWait } from '../plugins/call/hangup';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useModalStyle } from '../hooks/useModalStyle';
import { useCrossSigningActive } from '../hooks/useCrossSigning';
@@ -20,27 +19,6 @@ import {
* membership has actually been removed from the room, so the leave reaches
* the homeserver before the client is stopped and the token is revoked.
*/
const hangupAndWait = async (mx: MatrixClient, embed: CallEmbed): Promise<void> => {
const myUserId = mx.getUserId();
const myDeviceId = mx.getDeviceId();
const stillIn = (): boolean =>
mx.matrixRTC
.getRoomSession(embed.room)
.memberships.some((m) => m.sender === myUserId && m.deviceId === myDeviceId);
try {
await embed.hangup();
} catch {
// widget already gone — fall through to the wait/timeout
}
const deadline = Date.now() + 4000;
while (stillIn() && Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => {
setTimeout(r, 150);
});
}
};
type LogoutDialogProps = {
handleClose: () => void;
};
@@ -64,7 +42,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
// membership (expires: 4 h) stays behind as a ghost participant and
// everyone else sees you "in call" until it times out.
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
await hangupAndWait(mx, callEmbed);
await hangupCallAndWait(mx, callEmbed);
setCallEmbed(undefined);
}
await logoutClient(mx);
+42 -6
View File
@@ -30,6 +30,8 @@ import {
RectCords,
Badge,
Spinner,
Tooltip,
TooltipProvider,
} from 'folds';
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
@@ -41,7 +43,12 @@ import isYesterday from 'dayjs/plugin/isYesterday';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import {
getDirectRoomAvatarUrl,
getMemberName,
getRoomAvatarUrl,
getStateEvent,
} from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
@@ -690,6 +697,14 @@ function RoomNavItem_({
const optionsVisible = hover || !!menuAnchor;
const callSession = useCallSession(room);
const callMembers = useCallMembers(callSession);
const liveNames = useMemo(() => {
const names = Array.from(
new Set(callMembers.map((m) => m.sender).filter((u): u is string => !!u)),
).map((u) => getMemberName(room, u));
const shown = names.slice(0, 6);
const more = names.length - shown.length;
return more > 0 ? `${shown.join(', ')} and ${more} more` : shown.join(', ');
}, [callMembers, room]);
const startCall = useCallStart(direct);
const callEmbed = useCallEmbed();
// [Gitea #30] Same voice-limit check the call prescreen uses, so the sidebar
@@ -853,11 +868,32 @@ function RoomNavItem_({
/>
)}
{callMembers.length > 0 && (
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
</Text>
</Badge>
// [Gitea #157] The count alone doesn't tell you whether it's your
// squad — name the people on hover/focus (long-press on touch).
<TooltipProvider
position="Right"
delay={300}
tooltip={
<Tooltip>
<Text size="T200">{liveNames}</Text>
</Tooltip>
}
>
{(tipRef) => (
<Badge
ref={tipRef}
variant="Critical"
fill="Solid"
size="400"
tabIndex={0}
aria-label={`${callMembers.length} in call: ${liveNames}`}
>
<Text as="span" size="L400" truncate>
{callMembers.length} Live
</Text>
</Badge>
)}
</TooltipProvider>
)}
</Box>
</NavItemContent>
-9
View File
@@ -13,7 +13,6 @@ import { ThemeKind, useTheme } from './useTheme';
import { callEmbedAtom } from '../state/callEmbed';
import { useResizeObserver } from './useResizeObserver';
import { CallControlState } from '../plugins/call/CallControlState';
import { useCallMembersChange, useCallSession } from './useCall';
import { CallPreferences } from '../state/callPreferences';
import { useSetting } from '../state/hooks/settings';
import { NoiseSuppressionMode, settingsAtom } from '../state/settings';
@@ -184,14 +183,6 @@ export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
};
export const useCallMemberSoundSync = (embed: CallEmbed) => {
const callSession = useCallSession(embed.room);
useCallMembersChange(
callSession,
useCallback(() => embed.control.applySound(), [embed]),
);
};
export const useCallThemeSync = (embed: CallEmbed) => {
const theme = useTheme();
@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
import { useSetAtom } from 'jotai';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { useRoomCallPolicy } from './useRoomCallPolicy';
import { toastQueueAtom } from '../state/toast';
/**
* [Gitea #223] When a room admin turns Allow Screen Sharing / Allow Camera
* off mid-call, the voice-limit-guard revokes the publish permission and the
* SFU drops our track within seconds — the button just vanishes with no
* explanation. Tell the user why: a track that went away while the room policy
* forbids it was not their doing.
*/
export function useCallPolicyRevokedToast(embed: CallEmbed, joined: boolean): void {
const { screenshare, video } = useCallControlState(embed.control);
const { allowScreenshare, allowCamera } = useRoomCallPolicy(embed.room);
const setToast = useSetAtom(toastQueueAtom);
const prev = useRef({ screenshare, video });
useEffect(() => {
const was = prev.current;
prev.current = { screenshare, video };
if (!joined) return;
const notify = (body: string) =>
setToast({
id: `call-policy-${Date.now()}`,
displayName: 'Lotus Chat',
body,
roomName: embed.room.name ?? 'Voice call',
roomId: embed.roomId,
});
if (was.screenshare && !screenshare && !allowScreenshare) {
notify('Screen sharing was turned off by a room admin.');
}
if (was.video && !video && !allowCamera) {
notify('Your camera was turned off by a room admin.');
}
}, [screenshare, video, allowScreenshare, allowCamera, joined, setToast, embed]);
}
+10 -25
View File
@@ -159,7 +159,7 @@ export class CallControl extends EventEmitter implements CallControlState {
audio_enabled: this.microphone,
video_enabled: this.video,
});
this.setSound(this.sound);
this.setSound();
this.emitStateUpdate();
}
@@ -251,10 +251,6 @@ export class CallControl extends EventEmitter implements CallControlState {
this.onControlMutation();
}
public applySound() {
this.setSound(this.sound);
}
private async setMediaState(state: ElementMediaStatePayload) {
// transport.send resolves once EC has ACK'd the command, which is enough to
// consider the mute applied. We deliberately do NOT gate completion on a
@@ -265,33 +261,22 @@ export class CallControl extends EventEmitter implements CallControlState {
return this.call.transport.send(ElementWidgetActions.DeviceMute, state);
}
private setSound(sound: boolean): void {
const callDocument = getCallDocument(this.iframe);
if (callDocument) {
callDocument.querySelectorAll('audio').forEach((el) => {
const isScreenshareAudio = el.getAttribute('data-lk-source') === 'screen_share_audio';
el.muted = !sound || (isScreenshareAudio && this.screenshareAudioMuted);
});
}
// [Gitea #209] Deafen is applied by the fork (io.lotus.set_deafen). The
// iframe-DOM `.muted` fallback that used to live here (plus the per-member
// re-apply in useCallMemberSoundSync) fought EC's audio renderer and is gone
// now that the pin is ≥ 0.25.0-lotus.2 — late joiners are the fork's job.
private setSound(): void {
this.sendDeafenState();
}
private applyScreenshareAudioMuted(): void {
if (!this.sound) return;
const callDocument = getCallDocument(this.iframe);
if (callDocument) {
callDocument
.querySelectorAll<HTMLAudioElement>('audio[data-lk-source="screen_share_audio"]')
.forEach((el) => {
el.muted = this.screenshareAudioMuted;
});
}
this.sendDeafenState();
}
// P6-2: send deafen state to the fork (io.lotus.set_deafen). The DOM .muted
// code above is a transitional fallback — remove once the fork ships & the
// pin is bumped.
// P6-2: send deafen state to the fork (io.lotus.set_deafen). Join-gated: the
// fork's handler only exists once joined; onCallJoined() re-sends the current
// state so a pre-join deafen is not lost.
private sendDeafenState(): void {
if (!this.joined) return;
this.call.transport
@@ -398,7 +383,7 @@ export class CallControl extends EventEmitter implements CallControlState {
);
this.state = state;
this.setSound(sound);
this.setSound();
// After un-deafening, re-apply screenshare audio mute if active
if (sound) this.applyScreenshareAudioMuted();
+35
View File
@@ -0,0 +1,35 @@
import { MatrixClient } from 'matrix-js-sdk';
import { CallEmbed } from './CallEmbed';
/**
* Ask EC to hang up and wait (bounded) until our own RTC membership has left
* the room's session. Used wherever the embed is about to be torn down for a
* reason other than the user pressing End — logout (#29) and answering another
* call while in one (#195) — because disposing the iframe alone leaves a ghost
* `m.call.member` for everyone else until the delayed leave event expires
* (~17–30 s measured).
*/
export const hangupCallAndWait = async (
mx: MatrixClient,
embed: CallEmbed,
timeoutMs = 4000,
): Promise<void> => {
const myUserId = mx.getUserId();
const myDeviceId = mx.getDeviceId();
const stillIn = (): boolean =>
mx.matrixRTC
.getRoomSession(embed.room)
.memberships.some((m) => m.sender === myUserId && m.deviceId === myDeviceId);
try {
await embed.hangup();
} catch {
// widget already gone — fall through to the wait/timeout
}
const deadline = Date.now() + timeoutMs;
while (stillIn() && Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => {
setTimeout(r, 150);
});
}
};