Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da78bff316 | ||
|
|
4dcc5176e2 | ||
|
|
df395776b4 | ||
|
|
49dec686f1 | ||
|
|
ba48e95993 | ||
|
|
a8db61f79f |
@@ -169,6 +169,56 @@ test.describe('local homeserver regression', () => {
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('a thread I started keeps its unread replies while I view the room (#217)', async ({
|
||||
page,
|
||||
}) => {
|
||||
const room = await createRoom(alice, 'Thread Unread Room', { invite: [bob.userId] });
|
||||
await joinRoom(bob, room);
|
||||
const inThread = (root: string) => ({
|
||||
'm.relates_to': {
|
||||
rel_type: 'm.thread',
|
||||
event_id: root,
|
||||
is_falling_back: true,
|
||||
'm.in_reply_to': { event_id: root },
|
||||
},
|
||||
});
|
||||
const root = await sendText(alice, room, 'my thread root');
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
// While the room is open and at the bottom: a thread reply, then a newer
|
||||
// main-timeline message. Neither may clear the thread without opening it.
|
||||
await sendText(bob, room, 'reply in your thread', inThread(root));
|
||||
await sendText(bob, room, 'newer main message');
|
||||
await expect(page.getByText('newer main message')).toBeVisible();
|
||||
const chip = page
|
||||
.locator('[data-message-item]', { hasText: 'my thread root' })
|
||||
.getByRole('button', { name: /1 reply/ });
|
||||
const threadUnread = async () => {
|
||||
// not_types varies so Synapse's sync response cache can't serve a stale answer.
|
||||
const filter = {
|
||||
room: {
|
||||
rooms: [room],
|
||||
timeline: { limit: 1, unread_thread_notifications: true, not_types: [uniq('x.')] },
|
||||
},
|
||||
};
|
||||
const sync = await api<{
|
||||
rooms: {
|
||||
join: Record<string, { unread_thread_notifications?: Record<string, unknown> }>;
|
||||
};
|
||||
}>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/sync?timeout=0&filter=${enc(JSON.stringify(filter))}`,
|
||||
alice.token,
|
||||
);
|
||||
return root in (sync.rooms.join[room]?.unread_thread_notifications ?? {});
|
||||
};
|
||||
await page.waitForTimeout(2000); // let any receipt the room view would send go out
|
||||
expect(await threadUnread()).toBe(true);
|
||||
await expect(chip).toHaveAccessibleName(/unread replies/);
|
||||
await chip.click();
|
||||
await expect.poll(threadUnread).toBe(false);
|
||||
});
|
||||
|
||||
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
|
||||
const room = await createRoom(alice, 'Lightbox Room');
|
||||
const png = Buffer.from(
|
||||
|
||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.19",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.21",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.19",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.19/element-call-embedded-0.25.0-lotus.19.tgz",
|
||||
"integrity": "sha512-xNWgja9PHeDuSgo6fvoI1BJkYHof0xJ9kkLnURqPkrHV0o1UagHgaYcFgbFZMtDsoPnz5UrOGleLsjFo7d5/wQ==",
|
||||
"version": "0.25.0-lotus.21",
|
||||
"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",
|
||||
"integrity": "sha512-Phtjop2qdKRrCLqAhzipRaJaVnU3MjK9CV4QwHkwU/Xlj8oguZBfjOlV7zTqOTzAr57OteOfLcyxLsmv5+u9tg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.19",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.21",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -78,6 +78,7 @@ import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
||||
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
||||
import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
|
||||
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
|
||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||
import { webRTCSupported } from '../utils/rtc';
|
||||
@@ -758,6 +759,12 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
useCallPolicyRevokedToast(embed, joined);
|
||||
useCallEndedToast(embed);
|
||||
useScreenshareNotices(embed);
|
||||
// [Gitea #43] The in-frame screenshare button follows the same room policy
|
||||
// as the host bar's (hidden where the server would refuse the share).
|
||||
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
||||
useEffect(() => {
|
||||
embed.control.setFrameScreenshareAllowed(allowScreenshare);
|
||||
}, [embed, allowScreenshare]);
|
||||
usePttHaptics();
|
||||
useCallAnnouncements(embed, joined);
|
||||
useMutedTalkWarning(embed, joined);
|
||||
|
||||
@@ -3,7 +3,12 @@ import React, { useCallback, useState } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { MicLevelBars } from '../call/MicLevelBars';
|
||||
import { StatusDivider } from './components';
|
||||
import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call';
|
||||
import {
|
||||
CallEmbed,
|
||||
useCallControlState,
|
||||
useCallMicLevel,
|
||||
useFrameScreenshare,
|
||||
} from '../../plugins/call';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
@@ -192,7 +197,9 @@ export function CallControl({
|
||||
// Keep a forbidden control visible while its track is still live (so the user
|
||||
// can stop it); otherwise hide it entirely.
|
||||
const showCamera = allowCamera || video;
|
||||
const showScreenshare = allowScreenshare || screenshare;
|
||||
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame.
|
||||
const frameScreenshare = useFrameScreenshare(callEmbed.control);
|
||||
const showScreenshare = !frameScreenshare && (allowScreenshare || screenshare);
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
|
||||
const handleMicrophoneToggle = useCallback(
|
||||
|
||||
@@ -29,7 +29,12 @@ import {
|
||||
SoundButton,
|
||||
VideoButton,
|
||||
} from './Controls';
|
||||
import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call';
|
||||
import {
|
||||
CallEmbed,
|
||||
useCallControlState,
|
||||
useCallMicLevel,
|
||||
useFrameScreenshare,
|
||||
} from '../../plugins/call';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
@@ -106,6 +111,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
// Keep a forbidden control visible while its track is still live (so the user
|
||||
// can stop it); otherwise hide it entirely.
|
||||
const showCamera = allowCamera || video;
|
||||
// [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
|
||||
// screenshare-audio mute stays.
|
||||
const frameScreenshare = useFrameScreenshare(callEmbed.control);
|
||||
const showScreenshare = allowScreenshare || screenshare;
|
||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
@@ -216,12 +225,14 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
{showScreenshare && (
|
||||
<>
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
/>
|
||||
{!frameScreenshare && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||
control since they're the same concern. */}
|
||||
<ScreenshareAudioButton
|
||||
|
||||
@@ -115,7 +115,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
try {
|
||||
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||||
const vol = (flat.clip.volume / 100) * master;
|
||||
const result = await callEmbed.control.injectAudio(url, vol);
|
||||
// [Gitea #43] Send the bytes too: the call page may be on another
|
||||
// origin, where this blob: URL can't be fetched.
|
||||
const clipBytes = await fetch(url)
|
||||
.then((r) => r.arrayBuffer())
|
||||
.catch(() => undefined);
|
||||
const result = await callEmbed.control.injectAudio(url, vol, clipBytes);
|
||||
if (!result.played) {
|
||||
// [EC#13] Refused fork-side (only reason today: local mic muted) —
|
||||
// don't play it locally either, or the user would think it went out.
|
||||
|
||||
@@ -108,7 +108,7 @@ import {
|
||||
getIntersectionObserverEntry,
|
||||
useIntersectionObserver,
|
||||
} from '../../hooks/useIntersectionObserver';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { markAsRead, MarkAsReadOptions } from '../../utils/notifications';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import * as css from './RoomTimeline.css';
|
||||
@@ -505,6 +505,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
|
||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||
// [Gitea #217] Reads from just viewing the timeline are passive: threads the
|
||||
// user follows stay unread, and the open thread panel sends its own receipts.
|
||||
const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(room.roomId));
|
||||
const passiveReadRef = useRef<MarkAsReadOptions>({ passive: true });
|
||||
passiveReadRef.current = { passive: true, openThreadId: activeThreadId ?? undefined };
|
||||
// Thread summary chips only mount for events that already carry thread data
|
||||
// (perf: a chip subscribes room-level listeners, so mounting one per rendered
|
||||
// message would exceed the SDK's emitter cap). This single room-level
|
||||
@@ -707,7 +712,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// and either there are no unread messages or the latest message is from the current user.
|
||||
// If either condition is met, trigger the markAsRead function to send a read receipt.
|
||||
const _roomId = mEvt.getRoomId();
|
||||
if (_roomId) requestAnimationFrame(() => markAsRead(mx, _roomId, hideActivity));
|
||||
if (_roomId)
|
||||
requestAnimationFrame(() =>
|
||||
markAsRead(mx, _roomId, hideActivity, passiveReadRef.current),
|
||||
);
|
||||
}
|
||||
|
||||
if (!document.hasFocus() && !unreadInfo) {
|
||||
@@ -829,13 +837,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const tryAutoMarkAsRead = useCallback(() => {
|
||||
const readUptoEventId = readUptoEventIdRef.current;
|
||||
if (!readUptoEventId) {
|
||||
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
|
||||
requestAnimationFrame(() =>
|
||||
markAsRead(mx, room.roomId, hideActivity, passiveReadRef.current),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const evtTimeline = getEventTimeline(room, readUptoEventId);
|
||||
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
|
||||
if (latestTimeline === room.getLiveTimeline()) {
|
||||
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
|
||||
requestAnimationFrame(() =>
|
||||
markAsRead(mx, room.roomId, hideActivity, passiveReadRef.current),
|
||||
);
|
||||
}
|
||||
}, [mx, room, hideActivity]);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-a
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||
|
||||
type PendingAsk = {
|
||||
@@ -96,6 +97,9 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
}),
|
||||
);
|
||||
const clientApi = new ClientWidgetApi(current, iframe, driver);
|
||||
// Only messages from this widget's own frame and origin are handled, so a
|
||||
// widget can't impersonate another one (or the call).
|
||||
restrictWidgetMessages(clientApi, iframe, current.origin);
|
||||
clientApi.setViewedRoomId(room.roomId);
|
||||
|
||||
// Live room events for widgets that were allowed to see them
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { atom, useSetAtom } from 'jotai';
|
||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
@@ -105,32 +105,6 @@ const isInteractive = (el: HTMLElement): boolean => {
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bind listeners to the EC iframe's window and keep them bound across document
|
||||
* reloads. [Gitea #60] Listeners registered on a contentWindow are discarded
|
||||
* when that window navigates to a new document (crash recovery, in-widget
|
||||
* navigation), so re-run `bind` from the iframe's `load` event and detach the
|
||||
* previous set. Returns a cleanup that detaches everything.
|
||||
*/
|
||||
const bindIframeWindow = (
|
||||
iframe: HTMLIFrameElement,
|
||||
bind: (win: Window) => () => void,
|
||||
): (() => void) => {
|
||||
let unbind: (() => void) | undefined;
|
||||
const attach = () => {
|
||||
unbind?.();
|
||||
const win = iframe.contentWindow;
|
||||
unbind = win ? bind(win) : undefined;
|
||||
};
|
||||
attach();
|
||||
iframe.addEventListener('load', attach);
|
||||
return () => {
|
||||
iframe.removeEventListener('load', attach);
|
||||
unbind?.();
|
||||
unbind = undefined;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Push-to-talk and deafen hotkeys for the active call.
|
||||
*
|
||||
@@ -150,20 +124,6 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
||||
const { microphone } = useCallControlState(embed?.control);
|
||||
const setPttActive = useSetAtom(pttActiveAtom);
|
||||
|
||||
// [Gitea #43] Once the fork reports it handles hotkeys, keys pressed inside
|
||||
// the call frame arrive as io.lotus.hotkey and we stop adding listeners to
|
||||
// the frame's window (which needs same-origin access).
|
||||
const [forkHotkeys, setForkHotkeys] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!embed) {
|
||||
setForkHotkeys(false);
|
||||
return undefined;
|
||||
}
|
||||
const sync = () => setForkHotkeys(embed.control.forkHandlesHotkeys);
|
||||
sync();
|
||||
return embed.control.onForkHotkeysChange(sync);
|
||||
}, [embed]);
|
||||
|
||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||
const microphoneRef = useRef(microphone);
|
||||
useEffect(() => {
|
||||
@@ -268,25 +228,23 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
||||
};
|
||||
};
|
||||
const unbindHost = bind(window);
|
||||
// BUG-9: PTT also works with focus in the call frame — reported by the
|
||||
// fork, or (older fork) via listeners on the frame's window.
|
||||
// BUG-9: PTT also works with focus in the call frame, reported by the
|
||||
// fork (io.lotus.hotkey; the host no longer listens on the frame's window).
|
||||
embed.control.setHotkeyCodes('ptt', [pttKey]);
|
||||
const unbindFork = embed.control.onHotkey((r) => {
|
||||
if (r.type === 'focus') onFocusChange();
|
||||
else if (r.type === 'keydown') pttDown(r, r.editable, r.interactive);
|
||||
else if (r.code === pttKey) release();
|
||||
});
|
||||
const unbindIframe = forkHotkeys ? () => undefined : bindIframeWindow(embed.iframe, bind);
|
||||
return () => {
|
||||
unbindHost();
|
||||
unbindFork();
|
||||
unbindIframe();
|
||||
embed.control.setHotkeyCodes('ptt', []);
|
||||
// BUG-8: if the embed changes while PTT is active, release mic on cleanup
|
||||
if (pttActiveRef.current) release();
|
||||
};
|
||||
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
||||
}, [pttMode, pttKey, embed, setPttActive, forkHotkeys]);
|
||||
}, [pttMode, pttKey, embed, setPttActive]);
|
||||
|
||||
// [cinny-desktop #2] System-wide PTT/deafen while a game has focus. The
|
||||
// desktop polls the configured keys without consuming them and emits one
|
||||
@@ -366,17 +324,10 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
||||
embed.control.toggleSound();
|
||||
}
|
||||
});
|
||||
const unbindIframe = forkHotkeys
|
||||
? () => undefined
|
||||
: bindIframeWindow(embed.iframe, (win) => {
|
||||
win.addEventListener('keydown', onKeyDown);
|
||||
return () => win.removeEventListener('keydown', onKeyDown);
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
unbindFork();
|
||||
unbindIframe();
|
||||
embed.control.setHotkeyCodes('deafen', []);
|
||||
};
|
||||
}, [embed, deafenKey, deafenHotkey, forkHotkeys]);
|
||||
}, [embed, deafenKey, deafenHotkey]);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { getCallDocument } from '../plugins/call/utils';
|
||||
import { isUserId } from '../utils/matrix';
|
||||
import { nextSpeakerSet } from '../utils/speakerSet';
|
||||
import { useCallMembers, useCallSession } from './useCall';
|
||||
import { useCallJoined } from './useCallEmbed';
|
||||
|
||||
/**
|
||||
* Returns the set of Matrix user IDs currently speaking in the Element Call
|
||||
* iframe.
|
||||
* Returns the set of Matrix user IDs currently speaking in the call, from the
|
||||
* fork's io.lotus.call_state reports ([lotus #2]).
|
||||
*
|
||||
* EC renders each participant's video tile with a `[data-video-fit]` wrapper.
|
||||
* When a participant is speaking, EC draws a speaking indicator via the tile's
|
||||
* `::before` pseudo-element `background-image` (anything other than `none`).
|
||||
* The participant's Matrix user ID is exposed on the first descendant carrying
|
||||
* an `aria-label`.
|
||||
*
|
||||
* We watch the whole iframe document so tiles added/removed mid-call are picked
|
||||
* up automatically, and on every relevant mutation we re-scan ALL `[data-video-fit]`
|
||||
* tiles and rebuild the set from the full current DOM state (rather than just the
|
||||
* tiles in the mutation batch).
|
||||
* [Gitea #43] The fallback that scraped EC's rendered tiles (the speaking
|
||||
* ring's `::before` background) is gone: the bundled fork always reports call
|
||||
* state, and the host no longer reads the call frame's DOM.
|
||||
*/
|
||||
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||
const [speakers, setSpeakers] = useState(new Set<string>());
|
||||
@@ -28,9 +19,8 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||
const joined = useCallJoined(callEmbed);
|
||||
|
||||
// C-L5 — depend on a STABLE boolean, not the callMembers array (whose identity
|
||||
// changes on every membership change). The MutationObserver + io.lotus.call_state
|
||||
// subscription below already track tiles joining/leaving live, so rebuilding
|
||||
// them on each membership change is pure churn.
|
||||
// changes on every membership change). The io.lotus.call_state subscription
|
||||
// below already tracks participants joining/leaving live.
|
||||
const hasCallMembers = callMembers.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,123 +29,22 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const getDoc = (): Document | undefined => getCallDocument(callEmbed.iframe);
|
||||
|
||||
let tileObserver: MutationObserver | undefined;
|
||||
|
||||
const detachTileObserver = (): void => {
|
||||
tileObserver?.disconnect();
|
||||
tileObserver = undefined;
|
||||
};
|
||||
|
||||
// #32 — only attach the DOM fallback observer while the fork isn't
|
||||
// supplying usable speaker data; it stays disconnected for the rest of
|
||||
// the call once io.lotus.call_state starts reporting participants.
|
||||
const attachTileObserver = (): void => {
|
||||
if (tileObserver) return;
|
||||
const doc = getDoc();
|
||||
if (!doc?.body) return;
|
||||
// Watch the whole document for attribute changes on tiles (which carry
|
||||
// the speaking indicator) and for new tiles being added/removed.
|
||||
tileObserver = new MutationObserver((mutations) => {
|
||||
const relevant = mutations.some(
|
||||
(m) =>
|
||||
m.type === 'attributes' ||
|
||||
(m.type === 'childList' &&
|
||||
(Array.from(m.addedNodes).some(
|
||||
(n) => n instanceof Element && n.querySelector('[data-video-fit]'),
|
||||
) ||
|
||||
Array.from(m.removedNodes).some(
|
||||
(n) => n instanceof Element && n.querySelector('[data-video-fit]'),
|
||||
))),
|
||||
);
|
||||
if (relevant) syncState();
|
||||
});
|
||||
tileObserver.observe(doc.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
});
|
||||
};
|
||||
|
||||
const syncState = (): void => {
|
||||
// [lotus #2] Prefer the fork's io.lotus.call_state events over scraping
|
||||
// EC's rendered DOM. Falls back to the DOM path below when the fork hasn't
|
||||
// sent yet (null) OR sent a spurious empty list (you're always present in
|
||||
// your own joined call, so [] means "no usable data", not "nobody").
|
||||
const lotus = callEmbed.getLotusParticipants();
|
||||
if (lotus !== null && lotus.length > 0) {
|
||||
detachTileObserver();
|
||||
// #32 — bail out of setState (and the re-render it causes) when the
|
||||
// derived set is unchanged from the previous one.
|
||||
setSpeakers((prev) => nextSpeakerSet(prev, lotus));
|
||||
return;
|
||||
}
|
||||
const doc = getDoc();
|
||||
if (!doc) {
|
||||
setSpeakers(new Set<string>());
|
||||
return;
|
||||
}
|
||||
// Fork gave no usable data (older fork, or hasn't sent yet) — fall back
|
||||
// to scraping the DOM, and keep watching it for changes.
|
||||
attachTileObserver();
|
||||
const s = new Set<string>();
|
||||
// Re-scan every tile on each mutation and build the set from the full
|
||||
// current DOM state, not just the tiles that mutated this batch.
|
||||
const tiles = doc.querySelectorAll<HTMLElement>('[data-video-fit]');
|
||||
tiles.forEach((el) => {
|
||||
const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before');
|
||||
if (!style) return;
|
||||
const tileBackgroundImage = style.getPropertyValue('background-image');
|
||||
const speaking = tileBackgroundImage !== 'none';
|
||||
if (!speaking) return;
|
||||
|
||||
const speakerId = el.querySelector('[aria-label]')?.getAttribute('aria-label');
|
||||
if (speakerId && isUserId(speakerId)) {
|
||||
s.add(speakerId);
|
||||
}
|
||||
});
|
||||
setSpeakers(s);
|
||||
// #32 — bail out of setState (and the re-render it causes) when the
|
||||
// derived set is unchanged from the previous one.
|
||||
setSpeakers((prev) => nextSpeakerSet(prev, callEmbed.getLotusParticipants() ?? []));
|
||||
};
|
||||
|
||||
syncState();
|
||||
// [lotus #2] Re-derive whenever the fork pushes new call-state.
|
||||
const unsubLotus = callEmbed.onLotusCallState(syncState);
|
||||
|
||||
// If iframe isn't ready yet, wait for body to be available.
|
||||
let bodyWatcher: MutationObserver | undefined;
|
||||
if (!getDoc()?.body) {
|
||||
bodyWatcher = new MutationObserver(() => {
|
||||
if (getDoc()?.body) {
|
||||
bodyWatcher?.disconnect();
|
||||
bodyWatcher = undefined;
|
||||
syncState();
|
||||
}
|
||||
});
|
||||
const doc = getDoc();
|
||||
if (doc) bodyWatcher.observe(doc, { childList: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
detachTileObserver();
|
||||
bodyWatcher?.disconnect();
|
||||
unsubLotus();
|
||||
};
|
||||
return callEmbed.onLotusCallState(syncState);
|
||||
}, [callEmbed, hasCallMembers, joined]);
|
||||
|
||||
return speakers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when any REMOTE participant has their microphone muted in the
|
||||
* Element Call iframe.
|
||||
*
|
||||
* EC renders a mute-icon element per participant tile with a `data-muted`
|
||||
* attribute ("true" = muted, "false" = unmuted) and an `aria-label` set to
|
||||
* the participant's Matrix user ID. We watch for attribute changes on all
|
||||
* `[data-muted]` elements, filter out the local user, and return true if any
|
||||
* remaining participant is muted.
|
||||
* Returns true when there is at least one REMOTE participant and every one of
|
||||
* them has their microphone muted, from the fork's io.lotus.call_state reports.
|
||||
*/
|
||||
export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => {
|
||||
const [muted, setMuted] = useState(false);
|
||||
@@ -163,105 +52,17 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
|
||||
useEffect(() => {
|
||||
if (!callEmbed) return undefined;
|
||||
|
||||
const getDoc = (): Document | undefined => getCallDocument(callEmbed.iframe);
|
||||
|
||||
const localUserId = callEmbed.room.client?.getUserId() ?? '';
|
||||
|
||||
let tileObserver: MutationObserver | undefined;
|
||||
|
||||
const detachTileObserver = (): void => {
|
||||
tileObserver?.disconnect();
|
||||
tileObserver = undefined;
|
||||
};
|
||||
|
||||
// #32 — only attach the DOM fallback observer while the fork isn't
|
||||
// supplying usable participant data; it stays disconnected for the rest
|
||||
// of the call once io.lotus.call_state starts reporting participants.
|
||||
const attachTileObserver = (): void => {
|
||||
if (tileObserver) return;
|
||||
const doc = getDoc();
|
||||
if (!doc?.body) return;
|
||||
// Watch the whole document for attribute changes on data-muted elements
|
||||
// and for new tiles being added/removed.
|
||||
tileObserver = new MutationObserver((mutations) => {
|
||||
const relevant = mutations.some(
|
||||
(m) =>
|
||||
m.type === 'attributes' ||
|
||||
(m.type === 'childList' &&
|
||||
(Array.from(m.addedNodes).some(
|
||||
(n) => n instanceof Element && n.querySelector('[data-muted]'),
|
||||
) ||
|
||||
Array.from(m.removedNodes).some(
|
||||
(n) => n instanceof Element && n.querySelector('[data-muted]'),
|
||||
))),
|
||||
);
|
||||
if (relevant) syncState();
|
||||
});
|
||||
tileObserver.observe(doc.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['data-muted'],
|
||||
});
|
||||
};
|
||||
|
||||
const syncState = (): void => {
|
||||
// [lotus #2] Prefer the fork's io.lotus.call_state over DOM scraping;
|
||||
// ignore a spurious empty list (fall back to DOM).
|
||||
const lotus = callEmbed.getLotusParticipants();
|
||||
if (lotus !== null && lotus.length > 0) {
|
||||
detachTileObserver();
|
||||
const remote = lotus.filter((p) => p.userId !== localUserId);
|
||||
setMuted(remote.length > 0 && remote.every((p) => !p.audioEnabled));
|
||||
return;
|
||||
}
|
||||
const doc = getDoc();
|
||||
if (!doc) {
|
||||
setMuted(false);
|
||||
return;
|
||||
}
|
||||
// Fork gave no usable data (older fork, or hasn't sent yet) — fall back
|
||||
// to scraping the DOM, and keep watching it for changes.
|
||||
attachTileObserver();
|
||||
// Each participant's mute icon has data-muted="true"|"false" and
|
||||
// aria-label set to their Matrix user ID.
|
||||
const muteIcons = doc.querySelectorAll<HTMLElement>('[data-muted]');
|
||||
let remoteCount = 0;
|
||||
let remoteMutedCount = 0;
|
||||
muteIcons.forEach((el) => {
|
||||
const userId = el.getAttribute('aria-label') ?? '';
|
||||
if (userId === localUserId) return;
|
||||
remoteCount += 1;
|
||||
if (el.getAttribute('data-muted') === 'true') remoteMutedCount += 1;
|
||||
});
|
||||
// "All muted" badge: true only when there is at least one remote
|
||||
// participant and every one of them is muted (not merely any single one).
|
||||
setMuted(remoteCount > 0 && remoteMutedCount === remoteCount);
|
||||
const remote = (callEmbed.getLotusParticipants() ?? []).filter(
|
||||
(p) => p.userId !== localUserId,
|
||||
);
|
||||
setMuted(remote.length > 0 && remote.every((p) => !p.audioEnabled));
|
||||
};
|
||||
|
||||
syncState();
|
||||
// [lotus #2] Re-derive whenever the fork pushes new call-state.
|
||||
const unsubLotus = callEmbed.onLotusCallState(syncState);
|
||||
|
||||
// If iframe isn't ready yet, wait for body to be available.
|
||||
let bodyWatcher: MutationObserver | undefined;
|
||||
if (!getDoc()?.body) {
|
||||
bodyWatcher = new MutationObserver(() => {
|
||||
if (getDoc()?.body) {
|
||||
bodyWatcher?.disconnect();
|
||||
bodyWatcher = undefined;
|
||||
syncState();
|
||||
}
|
||||
});
|
||||
const doc = getDoc();
|
||||
if (doc) bodyWatcher.observe(doc, { childList: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
detachTileObserver();
|
||||
bodyWatcher?.disconnect();
|
||||
unsubLotus();
|
||||
};
|
||||
return callEmbed.onLotusCallState(syncState);
|
||||
}, [callEmbed]);
|
||||
|
||||
return muted;
|
||||
|
||||
@@ -19,6 +19,12 @@ export type ClientConfig = {
|
||||
|
||||
hashRouter?: HashRouterConfig;
|
||||
gifApiKey?: string;
|
||||
|
||||
/**
|
||||
* [Gitea #43] Absolute https URL of the Element Call page on its own origin.
|
||||
* Unset: the bundled copy on this origin. Ignored in the desktop app.
|
||||
*/
|
||||
elementCallUrl?: string;
|
||||
};
|
||||
|
||||
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomEventHandlerMap,
|
||||
ThreadEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
|
||||
import {
|
||||
@@ -34,7 +35,7 @@ export const isMediaMessage = (event: MatrixEvent): boolean =>
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export type RoomMediaTimeline = {
|
||||
/** Every loaded media message in the room, newest first. */
|
||||
/** Every loaded media message in the room and its threads, newest first. */
|
||||
events: MatrixEvent[];
|
||||
loadMore: () => Promise<void>;
|
||||
loading: boolean;
|
||||
@@ -57,11 +58,23 @@ export const useRoomMediaTimeline = (mx: MatrixClient, room: Room): RoomMediaTim
|
||||
[mx, room],
|
||||
);
|
||||
|
||||
const readEvents = useCallback(
|
||||
(): MatrixEvent[] =>
|
||||
collectTimelineEvents(detached.set.getLiveTimeline()).filter(isMediaMessage).reverse(),
|
||||
[detached],
|
||||
);
|
||||
// [Gitea #165] The SDK routes thread replies out of every room timeline set
|
||||
// (ours included) into the room's Thread objects, so media posted in a
|
||||
// thread only shows up if we read the loaded threads too.
|
||||
const readEvents = useCallback((): MatrixEvent[] => {
|
||||
const seen = new Set<string>();
|
||||
return [
|
||||
...collectTimelineEvents(detached.set.getLiveTimeline()),
|
||||
...room.getThreads().flatMap((thread) => thread.timeline),
|
||||
]
|
||||
.filter((event) => {
|
||||
const id = event.getId();
|
||||
if (!id || seen.has(id) || !isMediaMessage(event)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.getTs() - a.getTs());
|
||||
}, [detached, room]);
|
||||
|
||||
const [events, setEvents] = useState<MatrixEvent[]>(readEvents);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -119,11 +132,23 @@ export const useRoomMediaTimeline = (mx: MatrixClient, room: Room): RoomMediaTim
|
||||
refresh();
|
||||
};
|
||||
|
||||
const onThreadReply = (_thread: unknown, event: MatrixEvent) => {
|
||||
if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
|
||||
event.once(MatrixEventEvent.Decrypted, () => {
|
||||
if (isMediaMessage(event)) refresh();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isMediaMessage(event)) refresh();
|
||||
};
|
||||
|
||||
room.on(RoomEvent.Timeline, onTimeline);
|
||||
room.on(RoomEvent.Redaction, onRedaction);
|
||||
room.on(ThreadEvent.NewReply, onThreadReply);
|
||||
return () => {
|
||||
room.removeListener(RoomEvent.Timeline, onTimeline);
|
||||
room.removeListener(RoomEvent.Redaction, onRedaction);
|
||||
room.removeListener(ThreadEvent.NewReply, onThreadReply);
|
||||
};
|
||||
}, [detached, room, readEvents]);
|
||||
|
||||
|
||||
+22
-17
@@ -36,6 +36,7 @@ import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
||||
import { zIndices } from '../styles/zIndex';
|
||||
import { OIDC_CALLBACK_PATH } from './paths';
|
||||
import { OidcCallback } from './auth/oidc/OidcCallback';
|
||||
import { resolveCallPageUrl, setCallPageUrl } from '../plugins/call/callPageUrl';
|
||||
|
||||
// The emoji families (Twemoji when "Twitter emoji" is on, Twemoji flags on
|
||||
// Windows — see SystemEmojiFeature) must sit before the generic family, or the
|
||||
@@ -220,23 +221,27 @@ function App() {
|
||||
<ConfigConfigError error={err} retry={retry} ignore={ignore} />
|
||||
)}
|
||||
>
|
||||
{(clientConfig) => (
|
||||
<ClientConfigProvider value={clientConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider>
|
||||
<AppearanceEffects />
|
||||
<TauriEffects />
|
||||
<DesktopChrome>
|
||||
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
||||
</DesktopChrome>
|
||||
<SeasonalEffect />
|
||||
<NightLightOverlay />
|
||||
<LotusToastContainer />
|
||||
</JotaiProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</ClientConfigProvider>
|
||||
)}
|
||||
{(clientConfig) => {
|
||||
// [Gitea #43] Idempotent: where the call page is loaded from.
|
||||
setCallPageUrl(resolveCallPageUrl(clientConfig.elementCallUrl, isTauri()));
|
||||
return (
|
||||
<ClientConfigProvider value={clientConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider>
|
||||
<AppearanceEffects />
|
||||
<TauriEffects />
|
||||
<DesktopChrome>
|
||||
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
||||
</DesktopChrome>
|
||||
<SeasonalEffect />
|
||||
<NightLightOverlay />
|
||||
<LotusToastContainer />
|
||||
</JotaiProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</ClientConfigProvider>
|
||||
);
|
||||
}}
|
||||
</ClientConfigLoader>
|
||||
</FeatureCheck>
|
||||
</ScreenSizeProvider>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ClientWidgetApi } from 'matrix-widget-api';
|
||||
import { EventEmitter } from 'events';
|
||||
import { CallControlState } from './CallControlState';
|
||||
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
||||
import { getCallDocument } from './utils';
|
||||
import { canDelegateCapability } from './utils';
|
||||
|
||||
export enum CallControlEvent {
|
||||
StateUpdate = 'state_update',
|
||||
@@ -54,16 +54,6 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability Delegation (`postMessage(msg, { delegate })`) ships only in
|
||||
* Chromium; other engines silently ignore the option, so the frame would get
|
||||
* no activation. `navigator.userAgentData` is likewise Chromium-only, which
|
||||
* makes it the practical feature test.
|
||||
*/
|
||||
function canDelegateCapability(): boolean {
|
||||
return typeof navigator !== 'undefined' && 'userAgentData' in navigator;
|
||||
}
|
||||
|
||||
export class CallControl extends EventEmitter implements CallControlState {
|
||||
private state: CallControlState;
|
||||
|
||||
@@ -71,14 +61,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
|
||||
private iframe: HTMLIFrameElement;
|
||||
|
||||
private bodyMutationObserver: MutationObserver;
|
||||
|
||||
private controlMutationObserver: MutationObserver;
|
||||
|
||||
// C-H3: coalesces bursts of body-subtree mutations into a single debounced
|
||||
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
|
||||
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
// [Gitea #56] Tracks the participant currently pinned via focusCameraParticipant(),
|
||||
// so callers (MemberGlance) can render a "Focus camera" / "Unfocus camera" toggle
|
||||
// instead of a one-way pin with no way back. null == no manual pin (speaker-follows).
|
||||
@@ -107,68 +89,12 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
// timeout — io.lotus toWidget actions must only be sent after call-join).
|
||||
private joined = false;
|
||||
|
||||
private get document(): Document | undefined {
|
||||
return getCallDocument(this.iframe);
|
||||
}
|
||||
|
||||
private get screenshareButton(): HTMLElement | undefined {
|
||||
const screenshareBtn = this.document?.querySelector(
|
||||
'[data-testid="incall_screenshare"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
return screenshareBtn ?? undefined;
|
||||
}
|
||||
|
||||
private get leaveButton(): Element | undefined {
|
||||
const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]');
|
||||
|
||||
return leaveBtn ?? undefined;
|
||||
}
|
||||
|
||||
private get settingsButton(): HTMLElement | undefined {
|
||||
// EC 0.20.1: settings button moved to bottom-left; fall back to bottom-center.
|
||||
const settingsButtonLeft = this.document?.querySelector(
|
||||
'[data-testid="settings-bottom-left"]',
|
||||
) as HTMLButtonElement | undefined;
|
||||
const settingsButtonCenter = this.document?.querySelector(
|
||||
'[data-testid="settings-bottom-center"]',
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
||||
return settingsButtonLeft ?? settingsButtonCenter ?? undefined;
|
||||
}
|
||||
|
||||
private get reactionsButton(): HTMLElement | undefined {
|
||||
// EC 0.20.1: reactions/raise-hand button sits just before the leave button.
|
||||
const reactionsButton = this.leaveButton?.previousElementSibling as HTMLElement | null;
|
||||
|
||||
return reactionsButton ?? undefined;
|
||||
}
|
||||
|
||||
private get spotlightButton(): HTMLInputElement | undefined {
|
||||
const spotlightButton = this.document?.querySelector(
|
||||
'input[value="spotlight"]',
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
return spotlightButton ?? undefined;
|
||||
}
|
||||
|
||||
private get gridButton(): HTMLInputElement | undefined {
|
||||
const gridButton = this.document?.querySelector(
|
||||
'input[value="grid"]',
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
return gridButton ?? undefined;
|
||||
}
|
||||
|
||||
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
||||
super();
|
||||
|
||||
this.state = state;
|
||||
this.call = call;
|
||||
this.iframe = iframe;
|
||||
|
||||
this.bodyMutationObserver = new MutationObserver(this.onBodyMutation.bind(this));
|
||||
this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this));
|
||||
}
|
||||
|
||||
public getState(): CallControlState {
|
||||
@@ -225,6 +151,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.joined = true;
|
||||
this.sendDeafenState();
|
||||
this.sendQuality();
|
||||
this.sendHotkeyCodes();
|
||||
this.sendFrameScreenshareAllowed();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,55 +168,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
||||
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
||||
this.sendHotkeyCodes();
|
||||
}
|
||||
|
||||
public startObserving() {
|
||||
if (!this.document) return;
|
||||
|
||||
// C-H3: watch the whole body subtree (not just direct children) so we
|
||||
// re-bind the control observer when EC re-renders its controls deeper in the
|
||||
// tree. Debounced via onBodyMutation() to avoid thrashing on busy renders.
|
||||
this.bodyMutationObserver.observe(this.document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
this.applyBodyMutation();
|
||||
}
|
||||
|
||||
private onBodyMutation() {
|
||||
// C-H3: coalesce a burst of subtree mutations into one debounced pass.
|
||||
if (this.bodyMutationTimer !== undefined) return;
|
||||
this.bodyMutationTimer = setTimeout(() => {
|
||||
this.bodyMutationTimer = undefined;
|
||||
this.applyBodyMutation();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private applyBodyMutation() {
|
||||
if (!this.document) return;
|
||||
// Hiding EC's footer and the transparent background are the fork's job now
|
||||
// (lotusHostControls / lotusTransparent URL flags, Gitea #43).
|
||||
this.observeControls();
|
||||
}
|
||||
|
||||
private observeControls() {
|
||||
this.controlMutationObserver.disconnect();
|
||||
|
||||
const screenshareBtn = this.screenshareButton;
|
||||
if (screenshareBtn) {
|
||||
this.controlMutationObserver.observe(screenshareBtn, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-kind'],
|
||||
});
|
||||
}
|
||||
const spotlightBtn = this.spotlightButton;
|
||||
if (spotlightBtn) {
|
||||
this.controlMutationObserver.observe(spotlightBtn, {
|
||||
attributes: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.onControlMutation();
|
||||
this.sendFrameScreenshareAllowed();
|
||||
}
|
||||
|
||||
private async setMediaState(state: ElementMediaStatePayload) {
|
||||
@@ -369,39 +249,52 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
}
|
||||
}
|
||||
|
||||
// [Gitea #43] Set once the fork reports io.lotus.controls_state: from then on
|
||||
// layout / settings / reactions go over the widget API and screenshare +
|
||||
// layout state come from that report, not from EC's DOM. Older forks never
|
||||
// send it and keep the DOM path below.
|
||||
private forkControls = false;
|
||||
// [Gitea #43] The fork has reported io.lotus.controls_state, so its Lotus
|
||||
// action handlers are mounted. Screenshare and layout state come from that
|
||||
// report; the host never reads or clicks EC's DOM.
|
||||
private forkReady = false;
|
||||
|
||||
// [Gitea #43] The fork handles io.lotus.set_screenshare (reported in
|
||||
// controls_state). Used only where Capability Delegation exists.
|
||||
private forkScreenshare = false;
|
||||
// [Gitea #43] The fork shows EC's own screenshare button inside the frame
|
||||
// (lotusFrameScreenshare: no Capability Delegation here), so the host bar
|
||||
// hides its own.
|
||||
private _frameScreenshare = false;
|
||||
|
||||
// [Gitea #43] The fork reports call hotkeys pressed inside its frame
|
||||
// (io.lotus.set_hotkeys → io.lotus.hotkey), so the host stops adding key
|
||||
// listeners to the frame's window.
|
||||
private forkHotkeys = false;
|
||||
private frameScreenshareListeners = new Set<() => void>();
|
||||
|
||||
// [Gitea #43] Whether the room's call policy allows screensharing; the fork
|
||||
// hides its in-frame button when it doesn't.
|
||||
private frameScreenshareAllowed = true;
|
||||
|
||||
private hotkeyCodes = new Map<string, string[]>();
|
||||
|
||||
private hotkeyListeners = new Set<(report: ForkHotkeyReport) => void>();
|
||||
|
||||
private forkHotkeysListeners = new Set<() => void>();
|
||||
|
||||
public get forkHandlesHotkeys(): boolean {
|
||||
return this.forkHotkeys;
|
||||
/** EC's own screenshare button is shown in the frame instead of ours. */
|
||||
public get frameScreenshare(): boolean {
|
||||
return this._frameScreenshare;
|
||||
}
|
||||
|
||||
/** Subscribe to `forkHandlesHotkeys` turning on. Returns an unsubscribe. */
|
||||
public onForkHotkeysChange(cb: () => void): () => void {
|
||||
this.forkHotkeysListeners.add(cb);
|
||||
/** Subscribe to `frameScreenshare` changes. Returns an unsubscribe. */
|
||||
public onFrameScreenshareChange(cb: () => void): () => void {
|
||||
this.frameScreenshareListeners.add(cb);
|
||||
return () => {
|
||||
this.forkHotkeysListeners.delete(cb);
|
||||
this.frameScreenshareListeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
/** Room call policy for the in-frame screenshare button. */
|
||||
public setFrameScreenshareAllowed(allowed: boolean): void {
|
||||
this.frameScreenshareAllowed = allowed;
|
||||
this.sendFrameScreenshareAllowed();
|
||||
}
|
||||
|
||||
private sendFrameScreenshareAllowed(): void {
|
||||
if (!this.joined || !this.forkReady || !this._frameScreenshare) return;
|
||||
this.sendForkAction('io.lotus.set_frame_screenshare', {
|
||||
visible: this.frameScreenshareAllowed,
|
||||
});
|
||||
}
|
||||
|
||||
/** Key codes `source` (e.g. 'ptt', 'deafen') wants reported from the frame. */
|
||||
public setHotkeyCodes(source: string, codes: string[]): void {
|
||||
this.hotkeyCodes.set(source, codes);
|
||||
@@ -409,7 +302,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
}
|
||||
|
||||
private sendHotkeyCodes(): void {
|
||||
if (!this.joined || !this.forkHotkeys) return;
|
||||
if (!this.joined || !this.forkReady) return;
|
||||
const codes = [...new Set([...this.hotkeyCodes.values()].flat())];
|
||||
this.call.transport.send('io.lotus.set_hotkeys', { codes }).catch(() => undefined);
|
||||
}
|
||||
@@ -431,18 +324,21 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */
|
||||
public onControlsState(data: unknown) {
|
||||
if (typeof data !== 'object' || data === null) return;
|
||||
const { screensharing, layout, screenshareAction, hotkeys } = data as {
|
||||
const { screensharing, layout, frameScreenshare } = data as {
|
||||
screensharing?: unknown;
|
||||
layout?: unknown;
|
||||
screenshareAction?: unknown;
|
||||
hotkeys?: unknown;
|
||||
frameScreenshare?: unknown;
|
||||
};
|
||||
this.forkControls = true;
|
||||
this.forkScreenshare = screenshareAction === true;
|
||||
if (hotkeys === true && !this.forkHotkeys) {
|
||||
this.forkHotkeys = true;
|
||||
const firstReport = !this.forkReady;
|
||||
this.forkReady = true;
|
||||
const frame = frameScreenshare === true;
|
||||
if (frame !== this._frameScreenshare) {
|
||||
this._frameScreenshare = frame;
|
||||
this.frameScreenshareListeners.forEach((l) => l());
|
||||
}
|
||||
if (firstReport) {
|
||||
this.sendHotkeyCodes();
|
||||
this.forkHotkeysListeners.forEach((l) => l());
|
||||
this.sendFrameScreenshareAllowed();
|
||||
}
|
||||
this.applyControls(
|
||||
typeof screensharing === 'boolean' ? screensharing : this.screenshare,
|
||||
@@ -450,13 +346,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
);
|
||||
}
|
||||
|
||||
private onControlMutation() {
|
||||
if (this.forkControls) return;
|
||||
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
|
||||
const spotlight: boolean = this.spotlightButton?.checked ?? false;
|
||||
this.applyControls(screenshare, spotlight);
|
||||
}
|
||||
|
||||
private applyControls(screenshare: boolean, spotlight: boolean) {
|
||||
const wasScreensharing = this.screenshare;
|
||||
|
||||
@@ -560,16 +449,17 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
* Must be called synchronously inside the user's click: starting a share
|
||||
* calls getDisplayMedia in the frame, which needs that click. Where the
|
||||
* browser can hand the click over (Capability Delegation, Chromium incl.
|
||||
* WebView2) this goes over the widget API; elsewhere (Firefox, Safari,
|
||||
* WebKitGTK desktop) it still clicks EC's hidden button, which needs
|
||||
* same-origin access to the frame.
|
||||
* WebView2) it is sent with delegation. Elsewhere the host bar hides its
|
||||
* button and EC's own shows in the frame (`frameScreenshare`); a plain send
|
||||
* still stops a share, which needs no click.
|
||||
*/
|
||||
public toggleScreenshare() {
|
||||
if (this.forkScreenshare && canDelegateCapability()) {
|
||||
this.sendDelegated('io.lotus.set_screenshare', { on: !this.screenshare }, 'display-capture');
|
||||
const data = { on: !this.screenshare };
|
||||
if (canDelegateCapability()) {
|
||||
this.sendDelegated('io.lotus.set_screenshare', data, 'display-capture');
|
||||
return;
|
||||
}
|
||||
this.screenshareButton?.click();
|
||||
this.sendForkAction('io.lotus.set_screenshare', data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -602,34 +492,18 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
}
|
||||
|
||||
public toggleSpotlight() {
|
||||
if (this.forkControls) {
|
||||
this.sendForkAction('io.lotus.set_layout', {
|
||||
layout: this.spotlight ? 'grid' : 'spotlight',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.spotlight) {
|
||||
this.gridButton?.click();
|
||||
return;
|
||||
}
|
||||
this.spotlightButton?.click();
|
||||
this.sendForkAction('io.lotus.set_layout', {
|
||||
layout: this.spotlight ? 'grid' : 'spotlight',
|
||||
});
|
||||
}
|
||||
|
||||
public toggleReactions() {
|
||||
if (this.forkControls) {
|
||||
this.sendForkAction('io.lotus.toggle_reactions', {});
|
||||
return;
|
||||
}
|
||||
this.reactionsButton?.click();
|
||||
this.sendForkAction('io.lotus.toggle_reactions', {});
|
||||
}
|
||||
|
||||
public toggleSettings() {
|
||||
if (this.forkControls) {
|
||||
// Same as clicking EC's settings button: opens the modal.
|
||||
this.sendForkAction('io.lotus.open_settings', { open: true });
|
||||
return;
|
||||
}
|
||||
this.settingsButton?.click();
|
||||
// Same as clicking EC's settings button: opens the modal.
|
||||
this.sendForkAction('io.lotus.open_settings', { open: true });
|
||||
}
|
||||
|
||||
private sendForkAction(action: string, data: Record<string, unknown>): void {
|
||||
@@ -690,19 +564,27 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
* track (`io.lotus.inject_audio`) rather than splicing the mic. `url` must be
|
||||
* an https/blob URL the widget can fetch WITHOUT credentials — the host
|
||||
* resolves an mxc clip to a `blob:` object URL first (authenticated media
|
||||
* can't be fetched cross-realm by the widget). `volume` is 0–1.
|
||||
* can't be fetched cross-realm by the widget) — and `audio` the same clip's
|
||||
* bytes, which the fork prefers. `volume` is 0–1.
|
||||
*
|
||||
* The local user does not hear their own published track, so callers should
|
||||
* also play the clip locally for feedback.
|
||||
*/
|
||||
public injectAudio(url: string, volume = 1): Promise<{ played: boolean; reason?: string }> {
|
||||
public injectAudio(
|
||||
url: string,
|
||||
volume = 1,
|
||||
audio?: ArrayBuffer,
|
||||
): Promise<{ played: boolean; reason?: string }> {
|
||||
// [EC#13] The fork now refuses while the local mic is muted and replies
|
||||
// { played:false, reason:"muted" }; older forks reply {} (treated as played).
|
||||
// [Gitea #43] `audio` carries the clip's bytes: a `blob:` URL only works on
|
||||
// this origin, so a call page on its own origin can't fetch it. Forks that
|
||||
// predate `audio` ignore it and use `url`.
|
||||
return this.call.transport
|
||||
.send<{ url: string; volume: number }, { played?: boolean; reason?: string }>(
|
||||
'io.lotus.inject_audio',
|
||||
{ url, volume },
|
||||
)
|
||||
.send<
|
||||
{ url: string; volume: number; audio?: ArrayBuffer },
|
||||
{ played?: boolean; reason?: string }
|
||||
>('io.lotus.inject_audio', audio ? { url, volume, audio } : { url, volume })
|
||||
.then((r) => ({ played: r?.played !== false, reason: r?.reason }))
|
||||
.catch(() => ({ played: true }));
|
||||
}
|
||||
@@ -731,14 +613,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
if (this.bodyMutationTimer !== undefined) {
|
||||
clearTimeout(this.bodyMutationTimer);
|
||||
this.bodyMutationTimer = undefined;
|
||||
}
|
||||
// [Gitea #56] Don't let a manual focus pin outlive the call.
|
||||
this.clearFocusParticipant();
|
||||
this.bodyMutationObserver.disconnect();
|
||||
this.controlMutationObserver.disconnect();
|
||||
}
|
||||
|
||||
private emitStateUpdate() {
|
||||
|
||||
@@ -28,7 +28,9 @@ import {
|
||||
import { CallControl } from './CallControl';
|
||||
import { CallControlState } from './CallControlState';
|
||||
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||||
import { getCallDocument } from './utils';
|
||||
import { canDelegateCapability } from './utils';
|
||||
import { getCallPageUrl } from './callPageUrl';
|
||||
import { restrictWidgetMessages } from '../widgetTransport';
|
||||
|
||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||
@@ -218,6 +220,11 @@ export class CallEmbed {
|
||||
// [Gitea #43] The fork hides its own footer (we draw the call bar) and
|
||||
// sets its root color-scheme from the theme, instead of us injecting CSS.
|
||||
lotusHostControls: 'true',
|
||||
// [Gitea #43] Engines without Capability Delegation (Firefox, Safari,
|
||||
// WebKitGTK) can't start a share from our call bar: getDisplayMedia
|
||||
// needs the click inside the frame. There the fork shows EC's own
|
||||
// screenshare button and we hide ours.
|
||||
...(canDelegateCapability() ? {} : { lotusFrameScreenshare: 'true' }),
|
||||
// [lotus #3 / P5-15] Arm the fork's audio-inject handler so the in-call
|
||||
// soundboard can publish clips into the call. Dormant until the host
|
||||
// sends io.lotus.inject_audio (only on an explicit user click), so
|
||||
@@ -248,10 +255,14 @@ export class CallEmbed {
|
||||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
||||
}
|
||||
|
||||
const widgetUrl = new URL(
|
||||
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
|
||||
window.location.origin,
|
||||
);
|
||||
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
||||
const externalPage = getCallPageUrl();
|
||||
const widgetUrl = externalPage
|
||||
? new URL(externalPage)
|
||||
: new URL(
|
||||
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
|
||||
window.location.origin,
|
||||
);
|
||||
widgetUrl.search = params.toString();
|
||||
|
||||
const options: IWidget = {
|
||||
@@ -300,6 +311,8 @@ export class CallEmbed {
|
||||
|
||||
const callWidgetDriver: WidgetDriver = new CallWidgetDriver(mx, room.roomId);
|
||||
const call: ClientWidgetApi = new ClientWidgetApi(widget, iframe, callWidgetDriver);
|
||||
// Only messages from the call's own frame and origin reach the widget API.
|
||||
restrictWidgetMessages(call, iframe, widget.origin);
|
||||
|
||||
this.mx = mx;
|
||||
this.call = call;
|
||||
@@ -311,10 +324,6 @@ export class CallEmbed {
|
||||
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||||
this.control = new CallControl(controlState, call, iframe);
|
||||
this.initialState = controlState;
|
||||
this.control.startObserving();
|
||||
iframe.onload = () => {
|
||||
this.control.startObserving();
|
||||
};
|
||||
// If the iframe document itself fails to load, fail fast.
|
||||
iframe.onerror = () => {
|
||||
this.settleLoad('iframe');
|
||||
@@ -348,10 +357,6 @@ export class CallEmbed {
|
||||
return this.room.roomId;
|
||||
}
|
||||
|
||||
get document(): Document | undefined {
|
||||
return getCallDocument(this.iframe);
|
||||
}
|
||||
|
||||
public setTheme(theme: ElementCallThemeKind) {
|
||||
this.themeKind = theme;
|
||||
return this.call.transport
|
||||
@@ -573,13 +578,12 @@ export class CallEmbed {
|
||||
|
||||
private onCallJoined(): void {
|
||||
this.settleLoad();
|
||||
this.control.startObserving();
|
||||
|
||||
// C-H1: EC fires JoinCall again on an EC reconnect (this action has no
|
||||
// once-guard). forceState() would reset live mic/video/deafen back to the
|
||||
// join-time snapshot, so only run it on the FIRST join. On a rejoin we just
|
||||
// re-apply styles/observers (above) and re-push the sticky fork state
|
||||
// (deafen/quality), leaving the user's live media state untouched.
|
||||
// re-push the sticky fork state (deafen/quality), leaving the user's live
|
||||
// media state untouched.
|
||||
if (this.joined) {
|
||||
this.control.resendForkState();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveCallPageUrl } from './callPageUrl';
|
||||
|
||||
const URL_OK = 'https://call.chat.example.org/public/element-call/index.html';
|
||||
|
||||
test('uses an absolute https URL on the web', () => {
|
||||
assert.equal(resolveCallPageUrl(URL_OK, false), URL_OK);
|
||||
});
|
||||
|
||||
test('strips a query or hash (the app adds its own parameters)', () => {
|
||||
assert.equal(resolveCallPageUrl(`${URL_OK}?x=1#y`, false), URL_OK);
|
||||
});
|
||||
|
||||
test('allows http only on localhost (development)', () => {
|
||||
assert.equal(
|
||||
resolveCallPageUrl('http://127.0.0.1:5174/public/element-call/index.html', false),
|
||||
'http://127.0.0.1:5174/public/element-call/index.html',
|
||||
);
|
||||
assert.equal(resolveCallPageUrl('http://call.example.org/index.html', false), undefined);
|
||||
});
|
||||
|
||||
test('desktop keeps the bundled page', () => {
|
||||
assert.equal(resolveCallPageUrl(URL_OK, true), undefined);
|
||||
});
|
||||
|
||||
test('anything else falls back to the bundled page', () => {
|
||||
[
|
||||
undefined,
|
||||
null,
|
||||
42,
|
||||
'',
|
||||
' ',
|
||||
'not a url',
|
||||
'/public/element-call/index.html',
|
||||
'http://call.chat.example.org/index.html',
|
||||
['javascript', 'alert(1)'].join(':'),
|
||||
'data:text/html,x',
|
||||
].forEach((v) => assert.equal(resolveCallPageUrl(v, false), undefined, String(v)));
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* [Gitea #43] Where the Element Call page is loaded from.
|
||||
*
|
||||
* By default it's the copy bundled with this app (same origin). With
|
||||
* `elementCallUrl` in config.json (e.g.
|
||||
* "https://call.chat.lotusguild.org/public/element-call/index.html") the web
|
||||
* app loads it from that origin instead, so the call frame can no longer
|
||||
* reach this origin's storage (login token, crypto store) or service worker.
|
||||
*
|
||||
* Web only: the desktop app keeps its bundled copy (its CSP doesn't allow
|
||||
* another frame origin, and a network copy could drift from the bundle).
|
||||
* Anything that isn't an absolute https URL (http only on localhost, for
|
||||
* development) is ignored, so a bad value falls
|
||||
* back to the bundled page instead of breaking calls.
|
||||
*/
|
||||
export const resolveCallPageUrl = (value: unknown, desktop: boolean): string | undefined => {
|
||||
if (desktop || typeof value !== 'string' || value.trim() === '') return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
// http only for local development (localhost is a secure context).
|
||||
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined;
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.href;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
let callPageUrl: string | undefined;
|
||||
|
||||
export const setCallPageUrl = (url: string | undefined): void => {
|
||||
callPageUrl = url;
|
||||
};
|
||||
|
||||
export const getCallPageUrl = (): string | undefined => callPageUrl;
|
||||
@@ -73,3 +73,21 @@ export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => {
|
||||
);
|
||||
return useSyncExternalStore(subscribe, () => callEmbed?.getMicLevel() ?? 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #43] True when EC's own screenshare button is shown inside the call
|
||||
* frame (no Capability Delegation in this engine), so the host bar hides its.
|
||||
*/
|
||||
export const useFrameScreenshare = (control: CallControl | undefined): boolean => {
|
||||
const [frame, setFrame] = useState(() => control?.frameScreenshare ?? false);
|
||||
useEffect(() => {
|
||||
if (!control) {
|
||||
setFrame(false);
|
||||
return undefined;
|
||||
}
|
||||
const sync = () => setFrame(control.frameScreenshare);
|
||||
sync();
|
||||
return control.onFrameScreenshareChange(sync);
|
||||
}, [control]);
|
||||
return frame;
|
||||
};
|
||||
|
||||
@@ -118,17 +118,10 @@ export function getCallCapabilities(
|
||||
}
|
||||
|
||||
/**
|
||||
* The EC iframe's document, or undefined when it cannot be read. The widget is
|
||||
* same-origin, but when its navigation fails (offline, blocked) the frame
|
||||
* becomes a cross-origin error page and `contentWindow.document` THROWS a
|
||||
* SecurityError — which surfaced as page errors (and a React "Should not
|
||||
* already be working" cascade) from every DOM-driven call hook the moment the
|
||||
* load watchdog fired. Treat "can't read" the same as "not loaded yet".
|
||||
* Capability Delegation (`postMessage(msg, { delegate })`) ships only in
|
||||
* Chromium; other engines silently ignore the option, so the frame would get
|
||||
* no activation. `navigator.userAgentData` is likewise Chromium-only, which
|
||||
* makes it the practical feature test.
|
||||
*/
|
||||
export const getCallDocument = (iframe: HTMLIFrameElement): Document | undefined => {
|
||||
try {
|
||||
return iframe.contentDocument ?? iframe.contentWindow?.document ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
export const canDelegateCapability = (): boolean =>
|
||||
typeof navigator !== 'undefined' && 'userAgentData' in navigator;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ClientWidgetApi } from 'matrix-widget-api';
|
||||
import { isFromWidgetFrame, restrictWidgetMessages } from './widgetTransport';
|
||||
|
||||
const frame = {} as Window;
|
||||
const other = {} as Window;
|
||||
const ORIGIN = 'https://call.example.org';
|
||||
|
||||
test('isFromWidgetFrame: only the widget frame at its origin', () => {
|
||||
assert.equal(isFromWidgetFrame({ source: frame, origin: ORIGIN }, frame, ORIGIN), true);
|
||||
assert.equal(isFromWidgetFrame({ source: other, origin: ORIGIN }, frame, ORIGIN), false);
|
||||
assert.equal(isFromWidgetFrame({ source: frame, origin: 'null' }, frame, ORIGIN), false);
|
||||
assert.equal(
|
||||
isFromWidgetFrame({ source: frame, origin: 'https://evil.example' }, frame, ORIGIN),
|
||||
false,
|
||||
);
|
||||
assert.equal(isFromWidgetFrame({ source: null, origin: ORIGIN }, null, ORIGIN), false);
|
||||
});
|
||||
|
||||
test('restrictWidgetMessages swaps the listener and drops foreign messages', () => {
|
||||
const seen: unknown[] = [];
|
||||
const original = (ev: MessageEvent) => {
|
||||
seen.push(ev.data);
|
||||
};
|
||||
const transport = { handleMessage: original };
|
||||
const api = { transport } as unknown as ClientWidgetApi;
|
||||
const iframe = { contentWindow: frame } as HTMLIFrameElement;
|
||||
const listeners = new Set<EventListener>([original as EventListener]);
|
||||
const inbound = {
|
||||
addEventListener: (_t: string, l: EventListener) => listeners.add(l),
|
||||
removeEventListener: (_t: string, l: EventListener) => listeners.delete(l),
|
||||
} as unknown as Window;
|
||||
|
||||
restrictWidgetMessages(api, iframe, ORIGIN, inbound);
|
||||
assert.equal(listeners.has(original as EventListener), false);
|
||||
assert.equal(listeners.size, 1);
|
||||
assert.equal(transport.handleMessage === original, false);
|
||||
|
||||
const dispatch = (ev: Partial<MessageEvent>) =>
|
||||
listeners.forEach((l) => l(ev as unknown as Event));
|
||||
dispatch({ source: other, origin: ORIGIN, data: 'spoof' });
|
||||
dispatch({ source: frame, origin: 'null', data: 'wrong-origin' });
|
||||
dispatch({ source: frame, origin: ORIGIN, data: 'real' });
|
||||
assert.deepEqual(seen, ['real']);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ClientWidgetApi } from 'matrix-widget-api';
|
||||
|
||||
type MessageLike = Pick<MessageEvent, 'source' | 'origin'>;
|
||||
|
||||
/**
|
||||
* True when a message came from the widget's own frame, at the origin the
|
||||
* widget was loaded from.
|
||||
*/
|
||||
export const isFromWidgetFrame = (
|
||||
ev: MessageLike,
|
||||
frameWindow: Window | null | undefined,
|
||||
widgetOrigin: string,
|
||||
): boolean => !!frameWindow && ev.source === frameWindow && ev.origin === widgetOrigin;
|
||||
|
||||
type InboundTransport = {
|
||||
handleMessage: (ev: MessageEvent) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* matrix-widget-api's host transport accepts a message from ANY window on the
|
||||
* page as long as it carries the widget's id (its `strictOriginCheck` only
|
||||
* compares against the host's own origin, and is off by default). The call
|
||||
* widget's id is fixed ('call-embed'), so any other frame (a room widget, a
|
||||
* URL-preview embed) could post fromWidget actions as the call: e.g. a fake
|
||||
* push-to-talk keydown turned the user's mic on.
|
||||
*
|
||||
* Swap the transport's listener for one that only lets through messages from
|
||||
* this widget's iframe and origin. `stop()` removes `handleMessage`, which is
|
||||
* the guarded one after this. Call right after `new ClientWidgetApi(...)`,
|
||||
* which has already started the transport.
|
||||
*/
|
||||
export const restrictWidgetMessages = (
|
||||
api: ClientWidgetApi,
|
||||
iframe: HTMLIFrameElement,
|
||||
widgetOrigin: string,
|
||||
inbound: Pick<Window, 'addEventListener' | 'removeEventListener'> = window,
|
||||
): void => {
|
||||
const transport = api.transport as unknown as InboundTransport;
|
||||
const original = transport.handleMessage;
|
||||
const guarded = (ev: MessageEvent) => {
|
||||
if (!isFromWidgetFrame(ev, iframe.contentWindow, widgetOrigin)) return;
|
||||
original(ev);
|
||||
};
|
||||
inbound.removeEventListener('message', original as EventListener);
|
||||
transport.handleMessage = guarded;
|
||||
inbound.addEventListener('message', guarded as EventListener);
|
||||
};
|
||||
@@ -13,13 +13,16 @@ type ReceiptCall = { eventId: string; receiptType: ReceiptType; unthreaded?: boo
|
||||
|
||||
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
|
||||
|
||||
const thread = (id: string, lastReply: any) => ({ id, lastReply: () => lastReply }) as any;
|
||||
const thread = (id: string, lastReply: any, extra: Record<string, unknown> = {}) =>
|
||||
({ id, lastReply: () => lastReply, ...extra }) as any;
|
||||
|
||||
type RoomOpts = {
|
||||
timeline?: any[];
|
||||
readUpTo?: string | null;
|
||||
threads?: any[];
|
||||
threadUnread?: Record<string, number>;
|
||||
threadHighlight?: Record<string, number>;
|
||||
readEvents?: string[];
|
||||
markedUnread?: boolean;
|
||||
};
|
||||
|
||||
@@ -30,8 +33,12 @@ const setup = (opts: RoomOpts) => {
|
||||
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
|
||||
getEventReadUpTo: () => opts.readUpTo ?? null,
|
||||
getThreads: () => opts.threads ?? [],
|
||||
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
|
||||
opts.threadUnread?.[threadId] ?? 0,
|
||||
hasUserReadEvent: (_userId: string, eventId: string) =>
|
||||
(opts.readEvents ?? []).includes(eventId),
|
||||
getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
|
||||
(type === NotificationCountType.Highlight ? opts.threadHighlight : opts.threadUnread)?.[
|
||||
threadId
|
||||
] ?? 0,
|
||||
getAccountData: (type: string) =>
|
||||
opts.markedUnread && type === 'm.marked_unread'
|
||||
? { getContent: () => ({ unread: true }) }
|
||||
@@ -157,3 +164,127 @@ test('private receipt flag uses ReadPrivate', async () => {
|
||||
await markAsRead(mx, '!r:server', true);
|
||||
assert.equal(calls[0].receiptType, ReceiptType.ReadPrivate);
|
||||
});
|
||||
|
||||
// [Gitea #217] A passive read (just looking at the room) keeps threads the user
|
||||
// follows unread; an explicit mark-as-read still clears everything.
|
||||
const sender = (userId: string) => ({ getSender: () => userId });
|
||||
|
||||
test('passive: a thread I replied in stays unread; main receipt is scoped to main', async () => {
|
||||
const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true });
|
||||
const other = thread('$other', evt('$r2'));
|
||||
const { mx, calls } = setup({
|
||||
timeline: [evt('a'), evt('b')],
|
||||
readUpTo: 'a',
|
||||
threads: [mine, other],
|
||||
threadUnread: { $mine: 1, $other: 1 },
|
||||
});
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
[
|
||||
['b', false], // main-scoped: unthreaded would also read $mine's reply
|
||||
['$r2', false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('passive: threads I started or was mentioned in stay unread', async () => {
|
||||
const started = thread('$started', evt('$r1'), { rootEvent: sender('@me:server') });
|
||||
const byTimeline = thread('$t', evt('$r2'), { timeline: [sender('@me:server')] });
|
||||
const mentioned = thread('$mention', evt('$r3'));
|
||||
const { mx, calls } = setup({
|
||||
timeline: [evt('a')],
|
||||
readUpTo: 'a',
|
||||
threads: [started, byTimeline, mentioned],
|
||||
threadUnread: { $started: 1, $t: 1, $mention: 1 },
|
||||
threadHighlight: { $mention: 1 },
|
||||
});
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('passive: no followed thread unread → main receipt stays unthreaded', async () => {
|
||||
const other = thread('$other', evt('$r2'), { rootEvent: sender('@bob:server') });
|
||||
const { mx, calls } = setup({
|
||||
timeline: [evt('a'), evt('b')],
|
||||
readUpTo: 'a',
|
||||
threads: [other],
|
||||
threadUnread: { $other: 1 },
|
||||
});
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
[
|
||||
['b', true],
|
||||
['$r2', false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('explicit mark as read clears followed threads too', async () => {
|
||||
const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true });
|
||||
const { mx, calls } = setup({
|
||||
timeline: [evt('a'), evt('b')],
|
||||
readUpTo: 'a',
|
||||
threads: [mine],
|
||||
threadUnread: { $mine: 1 },
|
||||
});
|
||||
await markAsRead(mx, '!r:server', false);
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
[
|
||||
['b', true],
|
||||
['$r1', false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('passive: the thread open in the panel is left to the panel', async () => {
|
||||
const open = thread('$open', evt('$r1'));
|
||||
const other = thread('$other', evt('$r2'));
|
||||
const { mx, calls } = setup({
|
||||
timeline: [evt('a')],
|
||||
readUpTo: 'a',
|
||||
threads: [open, other],
|
||||
threadUnread: { $open: 1, $other: 1 },
|
||||
});
|
||||
await markAsRead(mx, '!r:server', false, { passive: true, openThreadId: '$open' });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => c.eventId),
|
||||
['$r2'],
|
||||
);
|
||||
});
|
||||
|
||||
test('passive: a followed thread whose count lags is still protected', async () => {
|
||||
// The reply and a newer main message arrived in the same sync; the thread's
|
||||
// count is still 0 but its latest reply (from bob) is unread.
|
||||
const reply = { ...evt('$r1'), getSender: () => '@bob:server' };
|
||||
const mine = thread('$mine', reply, { hasCurrentUserParticipated: true });
|
||||
const { mx, calls } = setup({
|
||||
timeline: [evt('a'), evt('b')],
|
||||
readUpTo: 'a',
|
||||
threads: [mine],
|
||||
threadUnread: { $mine: 0 },
|
||||
});
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
[['b', false]],
|
||||
);
|
||||
});
|
||||
|
||||
test('passive: followed thread already read → main receipt stays unthreaded', async () => {
|
||||
const reply = { ...evt('$r1'), getSender: () => '@bob:server' };
|
||||
const mine = thread('$mine', reply, { hasCurrentUserParticipated: true });
|
||||
const { mx, calls } = setup({
|
||||
timeline: [evt('a'), evt('b')],
|
||||
readUpTo: 'a',
|
||||
threads: [mine],
|
||||
readEvents: ['$r1'],
|
||||
});
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
[['b', true]],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,42 @@
|
||||
import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
|
||||
import { MatrixClient, NotificationCountType, ReceiptType, Room, Thread } from 'matrix-js-sdk';
|
||||
import { getSettings } from '../state/settings';
|
||||
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
|
||||
|
||||
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
||||
/**
|
||||
* [Gitea #217] Threads the user is following: they started it, replied in it,
|
||||
* or were mentioned in it. `hasCurrentUserParticipated` comes from the server's
|
||||
* thread bundle and lags a reply we just sent, so our own loaded events count too.
|
||||
*/
|
||||
export function isThreadFollowed(room: Room, thread: Thread, userId: string | null): boolean {
|
||||
if (thread.hasCurrentUserParticipated) return true;
|
||||
if (userId && thread.rootEvent?.getSender() === userId) return true;
|
||||
if (userId && thread.timeline?.some((e) => e.getSender() === userId)) return true;
|
||||
return (
|
||||
(room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export type MarkAsReadOptions = {
|
||||
/**
|
||||
* [Gitea #217] The read comes from simply looking at the room (timeline at
|
||||
* the bottom and focused), not an explicit "mark as read". Threads the user
|
||||
* follows stay unread until their panel is opened, like Slack and Discord;
|
||||
* the rest are cleared so they don't keep the room dot lit forever.
|
||||
*/
|
||||
passive?: boolean;
|
||||
/**
|
||||
* The thread open in the thread panel, which sends its own receipts; skip it
|
||||
* here so each reply isn't receipted twice.
|
||||
*/
|
||||
openThreadId?: string;
|
||||
};
|
||||
|
||||
export async function markAsRead(
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
privateReceipt: boolean,
|
||||
{ passive = false, openThreadId }: MarkAsReadOptions = {},
|
||||
) {
|
||||
const { privateReadReceipts } = getSettings();
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return;
|
||||
@@ -29,12 +63,32 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
|
||||
return null;
|
||||
};
|
||||
|
||||
const threads = room.getThreads();
|
||||
const threadUnread = (thread: Thread) =>
|
||||
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||
const keepThread = (thread: Thread) => passive && isThreadFollowed(room, thread, mx.getUserId());
|
||||
// [Gitea #217] An unthreaded receipt also reads every thread reply older than
|
||||
// it, so a passive read while a followed thread is unread must stay scoped
|
||||
// to the main timeline or the next main message would clear that thread.
|
||||
// The count can lag: a reply and a newer main message often arrive in the
|
||||
// same sync, before the thread's count is updated, so also ask whether the
|
||||
// latest reply has been read.
|
||||
const myUserId = mx.getUserId();
|
||||
const hasUnreadReply = (thread: Thread) => {
|
||||
if (threadUnread(thread) > 0) return true;
|
||||
const last = thread.lastReply();
|
||||
const lastId = last?.getId();
|
||||
if (!last || !lastId || !myUserId || last.getSender() === myUserId) return false;
|
||||
return !room.hasUserReadEvent(myUserId, lastId);
|
||||
};
|
||||
const keepAnyThread = threads.some((thread) => keepThread(thread) && hasUnreadReply(thread));
|
||||
|
||||
const latestEvent = timeline.length > 0 ? getLatestValidEvent() : null;
|
||||
if (latestEvent) {
|
||||
// Unthreaded receipt: with client threadSupport enabled the SDK would
|
||||
// otherwise scope this to the main timeline (thread_id: "main"). Unthreaded
|
||||
// clears the main timeline + every event up to this one.
|
||||
await mx.sendReadReceipt(latestEvent, receiptType, true);
|
||||
await mx.sendReadReceipt(latestEvent, receiptType, !keepAnyThread);
|
||||
}
|
||||
|
||||
// Clear per-thread notification counts too — the room's unread dot sums them,
|
||||
@@ -50,12 +104,10 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
|
||||
// returns null -> the room is reported unread on every mark-read call (this was
|
||||
// the P6 regression, amplified by the bulk mark-all-orphan-rooms-read callers).
|
||||
// If a thread's replies aren't loaded (lastReply() null), just skip it.
|
||||
const threads = room.getThreads();
|
||||
await Promise.all(
|
||||
threads.map((thread) => {
|
||||
const unread =
|
||||
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||
if (unread <= 0) return undefined;
|
||||
if (threadUnread(thread) <= 0) return undefined;
|
||||
if (keepThread(thread) || thread.id === openThreadId) return undefined;
|
||||
const lastReply = thread.lastReply();
|
||||
if (!lastReply || lastReply.isSending()) return undefined;
|
||||
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread
|
||||
|
||||
Reference in New Issue
Block a user