Compare commits

...
3 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 50b4e2c16c fix(calls): incoming ring stops on every device once answered or declined elsewhere, or when the caller hangs up (#161)
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 1m39s
The incoming-call dialog only went away on Ignore/Answer/Reject on THAT
device or when the notification lifetime expired, so a DM call answered
on the desktop kept the phone ringing for up to two minutes, and a caller
who gave up left everyone ringing. While a ring is showing we now watch
the room's MatrixRTC session and timeline: our own membership from any
device (answered elsewhere), our own RTCDecline for this ring (declined
elsewhere), or an empty session after it has settled (caller hung up)
all dismiss it. Verified with two alice devices + bob on the local
LiveKit stack: answer elsewhere → dismissed; decline elsewhere →
dismissed; caller End → both dialogs gone in 0.5 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 22:27:13 -04:00
jaredandClaude Opus 5 230d147ec1 fix(calls): logging out mid-call hangs up first so no ghost MatrixRTC membership is left behind (#29)
Logout stopped the client with the call still joined; the m.call.member
state (expires 4 h) stayed and everyone saw the user 'in call'. The
logout dialog now sends HangupCall and waits (≤4 s) until our own
membership is gone from the room's RTC session before stopping the
client. Verified on the local LiveKit stack: membership count 1 → 0,
logout completes in ~2 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 22:13:45 -04:00
jaredandClaude Opus 5 9fefd14944 fix(calls): undeafen restores the microphone it muted (#173)
Deafen muted the mic (correct) but undeafen left you muted, so every
deafen cycle silently turned into a mute. Remember whether the mic was on
when deafening and turn it back on when undeafening (Discord semantics).
Verified in a real two-party LiveKit call on the local stack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 21:58:39 -04:00
3 changed files with 95 additions and 2 deletions
+45
View File
@@ -26,6 +26,7 @@ import {
RoomEvent, RoomEvent,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types'; import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { import {
CallEmbedContextProvider, CallEmbedContextProvider,
@@ -559,6 +560,50 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
setCallInfo(undefined); setCallInfo(undefined);
}, []); }, []);
// [Gitea #161] Stop ringing here when the call was handled ELSEWHERE:
// - answered on another of our devices (our own m.call.member appears),
// - declined on another of our devices (our own RTCDecline for this ring),
// - the caller hung up before we picked up (nobody left in the session).
// Without this every other device kept ringing for the full lifetime.
useEffect(() => {
if (!callInfo) return undefined;
const { room, refEventId } = callInfo;
const myUserId = mx.getSafeUserId();
const dismiss = () =>
setCallInfo((current) => (current?.refEventId === refEventId ? undefined : current));
const session = mx.matrixRTC.getRoomSession(room);
const checkMemberships = () => {
const { memberships } = session;
if (memberships.some((m) => m.sender === myUserId))
dismiss(); // answered elsewhere
else if (memberships.length === 0) dismiss(); // caller gone
};
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
event,
eventRoom,
_s,
_r,
data,
) => {
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
const related = event.getRelation()?.event_id;
if (!related || related === refEventId) dismiss(); // declined elsewhere
}
};
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
mx.on(RoomEvent.Timeline, onTimeline);
// The caller's membership may not have arrived yet when the ring starts —
// only treat "empty" as hung-up after it has had a moment to sync.
const settle = setTimeout(checkMemberships, 5000);
return () => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
mx.removeListener(RoomEvent.Timeline, onTimeline);
clearTimeout(settle);
};
}, [mx, callInfo]);
const handleReject = useCallback( const handleReject = useCallback(
(room: Room, eventId: string) => { (room: Room, eventId: string) => {
// Best-effort: the local UI dismisses regardless (below), but a failed // Best-effort: the local UI dismisses regardless (below), but a failed
+40 -1
View File
@@ -1,7 +1,11 @@
import React, { forwardRef, useCallback } from 'react'; import React, { forwardRef, useCallback } from 'react';
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; 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 { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { logoutClient } from '../../client/initMatrix'; import { logoutClient } from '../../client/initMatrix';
import { callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call';
import { useMatrixClient } from '../hooks/useMatrixClient'; import { useMatrixClient } from '../hooks/useMatrixClient';
import { useModalStyle } from '../hooks/useModalStyle'; import { useModalStyle } from '../hooks/useModalStyle';
import { useCrossSigningActive } from '../hooks/useCrossSigning'; import { useCrossSigningActive } from '../hooks/useCrossSigning';
@@ -11,6 +15,32 @@ import {
VerificationStatus, VerificationStatus,
} from '../hooks/useDeviceVerificationStatus'; } from '../hooks/useDeviceVerificationStatus';
/**
* Ask Element Call to hang up and wait (bounded) until our own MatrixRTC
* 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 = { type LogoutDialogProps = {
handleClose: () => void; handleClose: () => void;
}; };
@@ -26,10 +56,19 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
mx.getDeviceId() ?? undefined, mx.getDeviceId() ?? undefined,
); );
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
const [logoutState, logout] = useAsyncCallback<void, Error, []>( const [logoutState, logout] = useAsyncCallback<void, Error, []>(
useCallback(async () => { useCallback(async () => {
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
// 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);
setCallEmbed(undefined);
}
await logoutClient(mx); await logoutClient(mx);
}, [mx]), }, [mx, callEmbed, setCallEmbed]),
); );
const ongoingLogout = logoutState.status === AsyncStatus.Loading; const ongoingLogout = logoutState.status === AsyncStatus.Loading;
+10 -1
View File
@@ -50,6 +50,11 @@ export class CallControl extends EventEmitter implements CallControlState {
// user-initiated unmute that auto-undeafens the user. // user-initiated unmute that auto-undeafens the user.
public pttActive = false; public pttActive = false;
// Deafen mutes the mic; undeafen should give it back ONLY if it was on
// before (Discord semantics — verified against the real client, Gitea #173:
// undeafen used to leave you muted). Cleared by any manual mic toggle.
private micOnBeforeDeafen = false;
// P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed // P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed
// invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send // invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send
// before the fork's widget handler mounts (pre-join sends pend to a 10s // before the fork's widget handler mounts (pre-join sends pend to a 10s
@@ -398,7 +403,11 @@ export class CallControl extends EventEmitter implements CallControlState {
this.emitStateUpdate(); this.emitStateUpdate();
if (!this.sound && this.microphone) { if (!sound) {
this.micOnBeforeDeafen = this.microphone;
if (this.microphone) this.toggleMicrophone();
} else if (this.micOnBeforeDeafen && !this.microphone) {
this.micOnBeforeDeafen = false;
this.toggleMicrophone(); this.toggleMicrophone();
} }
} }