Compare commits
5
Commits
68ce13f081
...
a475531b2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a475531b2b | ||
|
|
34574178a9 | ||
|
|
070a1ea012 | ||
|
|
bbcbdad55a | ||
|
|
a9e0b893be |
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user