Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da78bff316 | ||
|
|
4dcc5176e2 | ||
|
|
df395776b4 | ||
|
|
49dec686f1 | ||
|
|
ba48e95993 | ||
|
|
a8db61f79f | ||
|
|
f5d3c43b6c |
@@ -169,6 +169,56 @@ test.describe('local homeserver regression', () => {
|
|||||||
await ctx.close();
|
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 }) => {
|
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
|
||||||
const room = await createRoom(alice, 'Lightbox Room');
|
const room = await createRoom(alice, 'Lightbox Room');
|
||||||
const png = Buffer.from(
|
const png = Buffer.from(
|
||||||
|
|||||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.19",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.21",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
@@ -2695,9 +2695,9 @@
|
|||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
"node_modules/@lotusguild/element-call-embedded": {
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
"version": "0.25.0-lotus.19",
|
"version": "0.25.0-lotus.21",
|
||||||
"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",
|
"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-xNWgja9PHeDuSgo6fvoI1BJkYHof0xJ9kkLnURqPkrHV0o1UagHgaYcFgbFZMtDsoPnz5UrOGleLsjFo7d5/wQ==",
|
"integrity": "sha512-Phtjop2qdKRrCLqAhzipRaJaVnU3MjK9CV4QwHkwU/Xlj8oguZBfjOlV7zTqOTzAr57OteOfLcyxLsmv5+u9tg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.19",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.21",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
|||||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||||
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
||||||
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
||||||
|
import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
|
||||||
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
|
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
|
||||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||||
import { webRTCSupported } from '../utils/rtc';
|
import { webRTCSupported } from '../utils/rtc';
|
||||||
@@ -758,6 +759,12 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|||||||
useCallPolicyRevokedToast(embed, joined);
|
useCallPolicyRevokedToast(embed, joined);
|
||||||
useCallEndedToast(embed);
|
useCallEndedToast(embed);
|
||||||
useScreenshareNotices(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();
|
usePttHaptics();
|
||||||
useCallAnnouncements(embed, joined);
|
useCallAnnouncements(embed, joined);
|
||||||
useMutedTalkWarning(embed, joined);
|
useMutedTalkWarning(embed, joined);
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import React, { useCallback, useState } from 'react';
|
|||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { MicLevelBars } from '../call/MicLevelBars';
|
import { MicLevelBars } from '../call/MicLevelBars';
|
||||||
import { StatusDivider } from './components';
|
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 { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
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
|
// Keep a forbidden control visible while its track is still live (so the user
|
||||||
// can stop it); otherwise hide it entirely.
|
// can stop it); otherwise hide it entirely.
|
||||||
const showCamera = allowCamera || video;
|
const showCamera = allowCamera || video;
|
||||||
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 [shareConfirm, setShareConfirm] = useState(false);
|
||||||
|
|
||||||
const handleMicrophoneToggle = useCallback(
|
const handleMicrophoneToggle = useCallback(
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ import {
|
|||||||
SoundButton,
|
SoundButton,
|
||||||
VideoButton,
|
VideoButton,
|
||||||
} from './Controls';
|
} from './Controls';
|
||||||
import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call';
|
import {
|
||||||
|
CallEmbed,
|
||||||
|
useCallControlState,
|
||||||
|
useCallMicLevel,
|
||||||
|
useFrameScreenshare,
|
||||||
|
} from '../../plugins/call';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
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
|
// Keep a forbidden control visible while its track is still live (so the user
|
||||||
// can stop it); otherwise hide it entirely.
|
// can stop it); otherwise hide it entirely.
|
||||||
const showCamera = allowCamera || video;
|
const showCamera = allowCamera || video;
|
||||||
|
// [Gitea #43] 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 showScreenshare = allowScreenshare || screenshare;
|
||||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
@@ -216,12 +225,14 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||||
{showScreenshare && (
|
{showScreenshare && (
|
||||||
<>
|
<>
|
||||||
<ScreenShareButton
|
{!frameScreenshare && (
|
||||||
enabled={screenshare}
|
<ScreenShareButton
|
||||||
onToggle={() =>
|
enabled={screenshare}
|
||||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
onToggle={() =>
|
||||||
}
|
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||||
control since they're the same concern. */}
|
control since they're the same concern. */}
|
||||||
<ScreenshareAudioButton
|
<ScreenshareAudioButton
|
||||||
|
|||||||
@@ -115,7 +115,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
try {
|
try {
|
||||||
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||||||
const vol = (flat.clip.volume / 100) * master;
|
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) {
|
if (!result.played) {
|
||||||
// [EC#13] Refused fork-side (only reason today: local mic muted) —
|
// [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.
|
// don't play it locally either, or the user would think it went out.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import * as css from './MediaGallery.css';
|
import * as css from './MediaGallery.css';
|
||||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||||
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
||||||
|
import { canCopyImage, copyImageFromUrl } from '../../utils/copyImage';
|
||||||
|
|
||||||
type GalleryTab = 'image' | 'video' | 'file' | 'audio';
|
type GalleryTab = 'image' | 'video' | 'file' | 'audio';
|
||||||
|
|
||||||
@@ -160,6 +161,7 @@ function LightboxMedia({
|
|||||||
onMouseDown,
|
onMouseDown,
|
||||||
onTouchStart,
|
onTouchStart,
|
||||||
onImageDoubleClick,
|
onImageDoubleClick,
|
||||||
|
onUrl,
|
||||||
}: {
|
}: {
|
||||||
item: LightboxItem;
|
item: LightboxItem;
|
||||||
useAuthentication: boolean;
|
useAuthentication: boolean;
|
||||||
@@ -169,6 +171,7 @@ function LightboxMedia({
|
|||||||
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
||||||
onTouchStart: React.TouchEventHandler<HTMLElement>;
|
onTouchStart: React.TouchEventHandler<HTMLElement>;
|
||||||
onImageDoubleClick: () => void;
|
onImageDoubleClick: () => void;
|
||||||
|
onUrl: (url: string | undefined) => void;
|
||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const media = useDecryptedMediaUrl(
|
const media = useDecryptedMediaUrl(
|
||||||
@@ -178,6 +181,11 @@ function LightboxMedia({
|
|||||||
useAuthentication,
|
useAuthentication,
|
||||||
item.mimeType,
|
item.mimeType,
|
||||||
);
|
);
|
||||||
|
const readyUrl = media.status === 'ok' ? media.url : undefined;
|
||||||
|
useEffect(() => {
|
||||||
|
onUrl(readyUrl);
|
||||||
|
return () => onUrl(undefined);
|
||||||
|
}, [readyUrl, onUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -292,6 +300,24 @@ export function Lightbox({
|
|||||||
);
|
);
|
||||||
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
|
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
|
||||||
|
|
||||||
|
// [Gitea #164] Copy image. No "open in new tab": an E2EE image is a blob URL
|
||||||
|
// revoked when the viewer closes, and authenticated media 401s in a bare tab.
|
||||||
|
const [mediaUrl, setMediaUrl] = useState<string>();
|
||||||
|
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
|
||||||
|
useEffect(() => {
|
||||||
|
if (copyState === 'idle') return undefined;
|
||||||
|
const t = window.setTimeout(() => setCopyState('idle'), 2000);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [copyState]);
|
||||||
|
useEffect(() => setCopyState('idle'), [index]);
|
||||||
|
const handleCopyImage = useCallback(() => {
|
||||||
|
if (!mediaUrl) return;
|
||||||
|
copyImageFromUrl(mediaUrl).then(
|
||||||
|
() => setCopyState('copied'),
|
||||||
|
() => setCopyState('failed'),
|
||||||
|
);
|
||||||
|
}, [mediaUrl]);
|
||||||
|
|
||||||
// Reset zoom when navigating to another item (and thus pan, via usePan).
|
// Reset zoom when navigating to another item (and thus pan, via usePan).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setZoom(1);
|
setZoom(1);
|
||||||
@@ -409,6 +435,40 @@ export function Lightbox({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{isImage && canCopyImage() && (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>
|
||||||
|
{copyState === 'copied' && 'Copied'}
|
||||||
|
{copyState === 'failed' && 'Could not copy image'}
|
||||||
|
{copyState === 'idle' && 'Copy image'}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ref) => (
|
||||||
|
<IconButton
|
||||||
|
ref={ref}
|
||||||
|
variant="Surface"
|
||||||
|
aria-label={copyState === 'copied' ? 'Image copied' : 'Copy image'}
|
||||||
|
onClick={handleCopyImage}
|
||||||
|
disabled={!mediaUrl}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={
|
||||||
|
(copyState === 'copied' && Icons.Check) ||
|
||||||
|
(copyState === 'failed' && Icons.Warning) ||
|
||||||
|
Icons.Photo
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
{item.mxcUrl && (
|
{item.mxcUrl && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
@@ -512,6 +572,7 @@ export function Lightbox({
|
|||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onImageDoubleClick={toggleZoom}
|
onImageDoubleClick={toggleZoom}
|
||||||
|
onUrl={setMediaUrl}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{index < items.length - 1 && (
|
{index < items.length - 1 && (
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ import {
|
|||||||
getIntersectionObserverEntry,
|
getIntersectionObserverEntry,
|
||||||
useIntersectionObserver,
|
useIntersectionObserver,
|
||||||
} from '../../hooks/useIntersectionObserver';
|
} from '../../hooks/useIntersectionObserver';
|
||||||
import { markAsRead } from '../../utils/notifications';
|
import { markAsRead, MarkAsReadOptions } from '../../utils/notifications';
|
||||||
import { useDebounce } from '../../hooks/useDebounce';
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
import * as css from './RoomTimeline.css';
|
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 setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||||
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(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
|
// Thread summary chips only mount for events that already carry thread data
|
||||||
// (perf: a chip subscribes room-level listeners, so mounting one per rendered
|
// (perf: a chip subscribes room-level listeners, so mounting one per rendered
|
||||||
// message would exceed the SDK's emitter cap). This single room-level
|
// 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.
|
// 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.
|
// If either condition is met, trigger the markAsRead function to send a read receipt.
|
||||||
const _roomId = mEvt.getRoomId();
|
const _roomId = mEvt.getRoomId();
|
||||||
if (_roomId) requestAnimationFrame(() => markAsRead(mx, _roomId, hideActivity));
|
if (_roomId)
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
markAsRead(mx, _roomId, hideActivity, passiveReadRef.current),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!document.hasFocus() && !unreadInfo) {
|
if (!document.hasFocus() && !unreadInfo) {
|
||||||
@@ -829,13 +837,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const tryAutoMarkAsRead = useCallback(() => {
|
const tryAutoMarkAsRead = useCallback(() => {
|
||||||
const readUptoEventId = readUptoEventIdRef.current;
|
const readUptoEventId = readUptoEventIdRef.current;
|
||||||
if (!readUptoEventId) {
|
if (!readUptoEventId) {
|
||||||
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
|
requestAnimationFrame(() =>
|
||||||
|
markAsRead(mx, room.roomId, hideActivity, passiveReadRef.current),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const evtTimeline = getEventTimeline(room, readUptoEventId);
|
const evtTimeline = getEventTimeline(room, readUptoEventId);
|
||||||
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
|
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
|
||||||
if (latestTimeline === room.getLiveTimeline()) {
|
if (latestTimeline === room.getLiveTimeline()) {
|
||||||
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
|
requestAnimationFrame(() =>
|
||||||
|
markAsRead(mx, room.roomId, hideActivity, passiveReadRef.current),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [mx, room, hideActivity]);
|
}, [mx, room, hideActivity]);
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ import { useSetting } from '../../../state/hooks/settings';
|
|||||||
import { MessageLayout, settingsAtom } from '../../../state/settings';
|
import { MessageLayout, settingsAtom } from '../../../state/settings';
|
||||||
import { Message, Reactions, EncryptedContent } from '../message';
|
import { Message, Reactions, EncryptedContent } from '../message';
|
||||||
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
||||||
import { RoomMediaLightbox } from '../RoomMediaLightbox';
|
import { Lightbox, toLightboxItems } from '../MediaGallery';
|
||||||
import { Image } from '../../../components/media';
|
import { Image } from '../../../components/media';
|
||||||
import { ImageViewer } from '../../../components/image-viewer';
|
import { ImageViewer } from '../../../components/image-viewer';
|
||||||
import * as css from './ThreadTimeline.css';
|
import * as css from './ThreadTimeline.css';
|
||||||
@@ -308,7 +308,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
|
|
||||||
const [editId, setEditId] = useState<string>();
|
const [editId, setEditId] = useState<string>();
|
||||||
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
||||||
// [Gitea #219] Thread images open the room's shared media lightbox too.
|
// [Gitea #219] Thread images open the shared media lightbox too.
|
||||||
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
|
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
|
||||||
|
|
||||||
const linkifyOpts = useMemo<LinkifyOpts>(
|
const linkifyOpts = useMemo<LinkifyOpts>(
|
||||||
@@ -627,11 +627,9 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenReply: MouseEventHandler = useCallback(
|
// best-effort: scroll to an event if it is inside the loaded thread window
|
||||||
(evt) => {
|
const scrollToThreadEvent = useCallback(
|
||||||
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
(targetId: string) => {
|
||||||
if (!targetId) return;
|
|
||||||
// best-effort: scroll to referenced event if it is inside the loaded thread window
|
|
||||||
let absIndex = -1;
|
let absIndex = -1;
|
||||||
let acc = 0;
|
let acc = 0;
|
||||||
timeline.linkedTimelines.some((tl) => {
|
timeline.linkedTimelines.some((tl) => {
|
||||||
@@ -654,6 +652,39 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
[timeline.linkedTimelines, scrollToItem],
|
[timeline.linkedTimelines, scrollToItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleOpenReply: MouseEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
|
if (targetId) scrollToThreadEvent(targetId);
|
||||||
|
},
|
||||||
|
[scrollToThreadEvent],
|
||||||
|
);
|
||||||
|
|
||||||
|
// [Gitea #164] The viewer steps through this thread's media (root + loaded
|
||||||
|
// replies). Thread replies never reach the room's media timeline, so the
|
||||||
|
// room lightbox could only ever show a thread image as "1 / 1".
|
||||||
|
const lightboxItems = useMemo(() => {
|
||||||
|
if (!lightboxEventId) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const events = [thread.rootEvent, ...timeline.linkedTimelines.flatMap((tl) => tl.getEvents())]
|
||||||
|
.filter((ev): ev is MatrixEvent => !!ev)
|
||||||
|
.filter((ev) => {
|
||||||
|
const id = ev.getId();
|
||||||
|
if (!id || seen.has(id) || ev.isRedacted()) return false;
|
||||||
|
seen.add(id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return toLightboxItems(room, events);
|
||||||
|
}, [lightboxEventId, thread.rootEvent, timeline.linkedTimelines, room]);
|
||||||
|
const lightboxIndex = lightboxItems.findIndex((it) => it.eventId === lightboxEventId);
|
||||||
|
const handleLightboxJump = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setLightboxEventId(undefined);
|
||||||
|
scrollToThreadEvent(id);
|
||||||
|
},
|
||||||
|
[scrollToThreadEvent],
|
||||||
|
);
|
||||||
|
|
||||||
const renderMessageContent = useCallback(
|
const renderMessageContent = useCallback(
|
||||||
(mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => {
|
(mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => {
|
||||||
// Evaluated lazily so EncryptedContent can re-run it (re-reading getType())
|
// Evaluated lazily so EncryptedContent can re-run it (re-reading getType())
|
||||||
@@ -1051,11 +1082,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
onClose={() => setEditHistoryEvent(undefined)}
|
onClose={() => setEditHistoryEvent(undefined)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{lightboxEventId && (
|
{lightboxEventId && lightboxIndex !== -1 && (
|
||||||
<RoomMediaLightbox
|
<Lightbox
|
||||||
room={room}
|
items={lightboxItems}
|
||||||
eventId={lightboxEventId}
|
initialIndex={lightboxIndex}
|
||||||
|
useAuthentication={useAuthentication}
|
||||||
onClose={() => setLightboxEventId(undefined)}
|
onClose={() => setLightboxEventId(undefined)}
|
||||||
|
onJump={handleLightboxJump}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-a
|
|||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||||
|
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||||
|
|
||||||
type PendingAsk = {
|
type PendingAsk = {
|
||||||
@@ -96,6 +97,9 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const clientApi = new ClientWidgetApi(current, iframe, driver);
|
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);
|
clientApi.setViewedRoomId(room.roomId);
|
||||||
|
|
||||||
// Live room events for widgets that were allowed to see them
|
// 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 { atom, useSetAtom } from 'jotai';
|
||||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
@@ -105,32 +105,6 @@ const isInteractive = (el: HTMLElement): boolean => {
|
|||||||
return false;
|
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.
|
* 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 { microphone } = useCallControlState(embed?.control);
|
||||||
const setPttActive = useSetAtom(pttActiveAtom);
|
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)
|
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||||
const microphoneRef = useRef(microphone);
|
const microphoneRef = useRef(microphone);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -268,25 +228,23 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
const unbindHost = bind(window);
|
const unbindHost = bind(window);
|
||||||
// BUG-9: PTT also works with focus in the call frame — reported by the
|
// 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.
|
// fork (io.lotus.hotkey; the host no longer listens on the frame's window).
|
||||||
embed.control.setHotkeyCodes('ptt', [pttKey]);
|
embed.control.setHotkeyCodes('ptt', [pttKey]);
|
||||||
const unbindFork = embed.control.onHotkey((r) => {
|
const unbindFork = embed.control.onHotkey((r) => {
|
||||||
if (r.type === 'focus') onFocusChange();
|
if (r.type === 'focus') onFocusChange();
|
||||||
else if (r.type === 'keydown') pttDown(r, r.editable, r.interactive);
|
else if (r.type === 'keydown') pttDown(r, r.editable, r.interactive);
|
||||||
else if (r.code === pttKey) release();
|
else if (r.code === pttKey) release();
|
||||||
});
|
});
|
||||||
const unbindIframe = forkHotkeys ? () => undefined : bindIframeWindow(embed.iframe, bind);
|
|
||||||
return () => {
|
return () => {
|
||||||
unbindHost();
|
unbindHost();
|
||||||
unbindFork();
|
unbindFork();
|
||||||
unbindIframe();
|
|
||||||
embed.control.setHotkeyCodes('ptt', []);
|
embed.control.setHotkeyCodes('ptt', []);
|
||||||
// BUG-8: if the embed changes while PTT is active, release mic on cleanup
|
// BUG-8: if the embed changes while PTT is active, release mic on cleanup
|
||||||
if (pttActiveRef.current) release();
|
if (pttActiveRef.current) release();
|
||||||
};
|
};
|
||||||
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
// 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
|
// [cinny-desktop #2] System-wide PTT/deafen while a game has focus. The
|
||||||
// desktop polls the configured keys without consuming them and emits one
|
// 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();
|
embed.control.toggleSound();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const unbindIframe = forkHotkeys
|
|
||||||
? () => undefined
|
|
||||||
: bindIframeWindow(embed.iframe, (win) => {
|
|
||||||
win.addEventListener('keydown', onKeyDown);
|
|
||||||
return () => win.removeEventListener('keydown', onKeyDown);
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', onKeyDown);
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
unbindFork();
|
unbindFork();
|
||||||
unbindIframe();
|
|
||||||
embed.control.setHotkeyCodes('deafen', []);
|
embed.control.setHotkeyCodes('deafen', []);
|
||||||
};
|
};
|
||||||
}, [embed, deafenKey, deafenHotkey, forkHotkeys]);
|
}, [embed, deafenKey, deafenHotkey]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
import { getCallDocument } from '../plugins/call/utils';
|
|
||||||
import { isUserId } from '../utils/matrix';
|
|
||||||
import { nextSpeakerSet } from '../utils/speakerSet';
|
import { nextSpeakerSet } from '../utils/speakerSet';
|
||||||
import { useCallMembers, useCallSession } from './useCall';
|
import { useCallMembers, useCallSession } from './useCall';
|
||||||
import { useCallJoined } from './useCallEmbed';
|
import { useCallJoined } from './useCallEmbed';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the set of Matrix user IDs currently speaking in the Element Call
|
* Returns the set of Matrix user IDs currently speaking in the call, from the
|
||||||
* iframe.
|
* fork's io.lotus.call_state reports ([lotus #2]).
|
||||||
*
|
*
|
||||||
* EC renders each participant's video tile with a `[data-video-fit]` wrapper.
|
* [Gitea #43] The fallback that scraped EC's rendered tiles (the speaking
|
||||||
* When a participant is speaking, EC draws a speaking indicator via the tile's
|
* ring's `::before` background) is gone: the bundled fork always reports call
|
||||||
* `::before` pseudo-element `background-image` (anything other than `none`).
|
* state, and the host no longer reads the call frame's DOM.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||||
const [speakers, setSpeakers] = useState(new Set<string>());
|
const [speakers, setSpeakers] = useState(new Set<string>());
|
||||||
@@ -28,9 +19,8 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|||||||
const joined = useCallJoined(callEmbed);
|
const joined = useCallJoined(callEmbed);
|
||||||
|
|
||||||
// C-L5 — depend on a STABLE boolean, not the callMembers array (whose identity
|
// C-L5 — depend on a STABLE boolean, not the callMembers array (whose identity
|
||||||
// changes on every membership change). The MutationObserver + io.lotus.call_state
|
// changes on every membership change). The io.lotus.call_state subscription
|
||||||
// subscription below already track tiles joining/leaving live, so rebuilding
|
// below already tracks participants joining/leaving live.
|
||||||
// them on each membership change is pure churn.
|
|
||||||
const hasCallMembers = callMembers.length > 0;
|
const hasCallMembers = callMembers.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,123 +29,22 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|||||||
return undefined;
|
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 => {
|
const syncState = (): void => {
|
||||||
// [lotus #2] Prefer the fork's io.lotus.call_state events over scraping
|
// #32 — bail out of setState (and the re-render it causes) when the
|
||||||
// EC's rendered DOM. Falls back to the DOM path below when the fork hasn't
|
// derived set is unchanged from the previous one.
|
||||||
// sent yet (null) OR sent a spurious empty list (you're always present in
|
setSpeakers((prev) => nextSpeakerSet(prev, callEmbed.getLotusParticipants() ?? []));
|
||||||
// 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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
syncState();
|
syncState();
|
||||||
// [lotus #2] Re-derive whenever the fork pushes new call-state.
|
return callEmbed.onLotusCallState(syncState);
|
||||||
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();
|
|
||||||
};
|
|
||||||
}, [callEmbed, hasCallMembers, joined]);
|
}, [callEmbed, hasCallMembers, joined]);
|
||||||
|
|
||||||
return speakers;
|
return speakers;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true when any REMOTE participant has their microphone muted in the
|
* Returns true when there is at least one REMOTE participant and every one of
|
||||||
* Element Call iframe.
|
* them has their microphone muted, from the fork's io.lotus.call_state reports.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => {
|
export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => {
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
@@ -163,105 +52,17 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!callEmbed) return undefined;
|
if (!callEmbed) return undefined;
|
||||||
|
|
||||||
const getDoc = (): Document | undefined => getCallDocument(callEmbed.iframe);
|
|
||||||
|
|
||||||
const localUserId = callEmbed.room.client?.getUserId() ?? '';
|
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 => {
|
const syncState = (): void => {
|
||||||
// [lotus #2] Prefer the fork's io.lotus.call_state over DOM scraping;
|
const remote = (callEmbed.getLotusParticipants() ?? []).filter(
|
||||||
// ignore a spurious empty list (fall back to DOM).
|
(p) => p.userId !== localUserId,
|
||||||
const lotus = callEmbed.getLotusParticipants();
|
);
|
||||||
if (lotus !== null && lotus.length > 0) {
|
setMuted(remote.length > 0 && remote.every((p) => !p.audioEnabled));
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
syncState();
|
syncState();
|
||||||
// [lotus #2] Re-derive whenever the fork pushes new call-state.
|
return callEmbed.onLotusCallState(syncState);
|
||||||
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();
|
|
||||||
};
|
|
||||||
}, [callEmbed]);
|
}, [callEmbed]);
|
||||||
|
|
||||||
return muted;
|
return muted;
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export type ClientConfig = {
|
|||||||
|
|
||||||
hashRouter?: HashRouterConfig;
|
hashRouter?: HashRouterConfig;
|
||||||
gifApiKey?: string;
|
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);
|
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
RoomEventHandlerMap,
|
RoomEventHandlerMap,
|
||||||
|
ThreadEvent,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +35,7 @@ export const isMediaMessage = (event: MatrixEvent): boolean =>
|
|||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
export type RoomMediaTimeline = {
|
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[];
|
events: MatrixEvent[];
|
||||||
loadMore: () => Promise<void>;
|
loadMore: () => Promise<void>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -57,11 +58,23 @@ export const useRoomMediaTimeline = (mx: MatrixClient, room: Room): RoomMediaTim
|
|||||||
[mx, room],
|
[mx, room],
|
||||||
);
|
);
|
||||||
|
|
||||||
const readEvents = useCallback(
|
// [Gitea #165] The SDK routes thread replies out of every room timeline set
|
||||||
(): MatrixEvent[] =>
|
// (ours included) into the room's Thread objects, so media posted in a
|
||||||
collectTimelineEvents(detached.set.getLiveTimeline()).filter(isMediaMessage).reverse(),
|
// thread only shows up if we read the loaded threads too.
|
||||||
[detached],
|
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 [events, setEvents] = useState<MatrixEvent[]>(readEvents);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -119,11 +132,23 @@ export const useRoomMediaTimeline = (mx: MatrixClient, room: Room): RoomMediaTim
|
|||||||
refresh();
|
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.Timeline, onTimeline);
|
||||||
room.on(RoomEvent.Redaction, onRedaction);
|
room.on(RoomEvent.Redaction, onRedaction);
|
||||||
|
room.on(ThreadEvent.NewReply, onThreadReply);
|
||||||
return () => {
|
return () => {
|
||||||
room.removeListener(RoomEvent.Timeline, onTimeline);
|
room.removeListener(RoomEvent.Timeline, onTimeline);
|
||||||
room.removeListener(RoomEvent.Redaction, onRedaction);
|
room.removeListener(RoomEvent.Redaction, onRedaction);
|
||||||
|
room.removeListener(ThreadEvent.NewReply, onThreadReply);
|
||||||
};
|
};
|
||||||
}, [detached, room, readEvents]);
|
}, [detached, room, readEvents]);
|
||||||
|
|
||||||
|
|||||||
+22
-17
@@ -36,6 +36,7 @@ import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
|||||||
import { zIndices } from '../styles/zIndex';
|
import { zIndices } from '../styles/zIndex';
|
||||||
import { OIDC_CALLBACK_PATH } from './paths';
|
import { OIDC_CALLBACK_PATH } from './paths';
|
||||||
import { OidcCallback } from './auth/oidc/OidcCallback';
|
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
|
// The emoji families (Twemoji when "Twitter emoji" is on, Twemoji flags on
|
||||||
// Windows — see SystemEmojiFeature) must sit before the generic family, or the
|
// Windows — see SystemEmojiFeature) must sit before the generic family, or the
|
||||||
@@ -220,23 +221,27 @@ function App() {
|
|||||||
<ConfigConfigError error={err} retry={retry} ignore={ignore} />
|
<ConfigConfigError error={err} retry={retry} ignore={ignore} />
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(clientConfig) => (
|
{(clientConfig) => {
|
||||||
<ClientConfigProvider value={clientConfig}>
|
// [Gitea #43] Idempotent: where the call page is loaded from.
|
||||||
<QueryClientProvider client={queryClient}>
|
setCallPageUrl(resolveCallPageUrl(clientConfig.elementCallUrl, isTauri()));
|
||||||
<JotaiProvider>
|
return (
|
||||||
<AppearanceEffects />
|
<ClientConfigProvider value={clientConfig}>
|
||||||
<TauriEffects />
|
<QueryClientProvider client={queryClient}>
|
||||||
<DesktopChrome>
|
<JotaiProvider>
|
||||||
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
<AppearanceEffects />
|
||||||
</DesktopChrome>
|
<TauriEffects />
|
||||||
<SeasonalEffect />
|
<DesktopChrome>
|
||||||
<NightLightOverlay />
|
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
||||||
<LotusToastContainer />
|
</DesktopChrome>
|
||||||
</JotaiProvider>
|
<SeasonalEffect />
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<NightLightOverlay />
|
||||||
</QueryClientProvider>
|
<LotusToastContainer />
|
||||||
</ClientConfigProvider>
|
</JotaiProvider>
|
||||||
)}
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ClientConfigProvider>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</ClientConfigLoader>
|
</ClientConfigLoader>
|
||||||
</FeatureCheck>
|
</FeatureCheck>
|
||||||
</ScreenSizeProvider>
|
</ScreenSizeProvider>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ClientWidgetApi } from 'matrix-widget-api';
|
|||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { CallControlState } from './CallControlState';
|
import { CallControlState } from './CallControlState';
|
||||||
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
||||||
import { getCallDocument } from './utils';
|
import { canDelegateCapability } from './utils';
|
||||||
|
|
||||||
export enum CallControlEvent {
|
export enum CallControlEvent {
|
||||||
StateUpdate = 'state_update',
|
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 {
|
export class CallControl extends EventEmitter implements CallControlState {
|
||||||
private state: CallControlState;
|
private state: CallControlState;
|
||||||
|
|
||||||
@@ -71,14 +61,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
|
|
||||||
private iframe: HTMLIFrameElement;
|
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(),
|
// [Gitea #56] Tracks the participant currently pinned via focusCameraParticipant(),
|
||||||
// so callers (MemberGlance) can render a "Focus camera" / "Unfocus camera" toggle
|
// 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).
|
// 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).
|
// timeout — io.lotus toWidget actions must only be sent after call-join).
|
||||||
private joined = false;
|
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) {
|
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.call = call;
|
this.call = call;
|
||||||
this.iframe = iframe;
|
this.iframe = iframe;
|
||||||
|
|
||||||
this.bodyMutationObserver = new MutationObserver(this.onBodyMutation.bind(this));
|
|
||||||
this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getState(): CallControlState {
|
public getState(): CallControlState {
|
||||||
@@ -225,6 +151,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
this.joined = true;
|
this.joined = true;
|
||||||
this.sendDeafenState();
|
this.sendDeafenState();
|
||||||
this.sendQuality();
|
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.
|
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
||||||
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
||||||
this.sendHotkeyCodes();
|
this.sendHotkeyCodes();
|
||||||
}
|
this.sendFrameScreenshareAllowed();
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setMediaState(state: ElementMediaStatePayload) {
|
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
|
// [Gitea #43] The fork has reported io.lotus.controls_state, so its Lotus
|
||||||
// layout / settings / reactions go over the widget API and screenshare +
|
// action handlers are mounted. Screenshare and layout state come from that
|
||||||
// layout state come from that report, not from EC's DOM. Older forks never
|
// report; the host never reads or clicks EC's DOM.
|
||||||
// send it and keep the DOM path below.
|
private forkReady = false;
|
||||||
private forkControls = false;
|
|
||||||
|
|
||||||
// [Gitea #43] The fork handles io.lotus.set_screenshare (reported in
|
// [Gitea #43] The fork shows EC's own screenshare button inside the frame
|
||||||
// controls_state). Used only where Capability Delegation exists.
|
// (lotusFrameScreenshare: no Capability Delegation here), so the host bar
|
||||||
private forkScreenshare = false;
|
// hides its own.
|
||||||
|
private _frameScreenshare = false;
|
||||||
|
|
||||||
// [Gitea #43] The fork reports call hotkeys pressed inside its frame
|
private frameScreenshareListeners = new Set<() => void>();
|
||||||
// (io.lotus.set_hotkeys → io.lotus.hotkey), so the host stops adding key
|
|
||||||
// listeners to the frame's window.
|
// [Gitea #43] Whether the room's call policy allows screensharing; the fork
|
||||||
private forkHotkeys = false;
|
// hides its in-frame button when it doesn't.
|
||||||
|
private frameScreenshareAllowed = true;
|
||||||
|
|
||||||
private hotkeyCodes = new Map<string, string[]>();
|
private hotkeyCodes = new Map<string, string[]>();
|
||||||
|
|
||||||
private hotkeyListeners = new Set<(report: ForkHotkeyReport) => void>();
|
private hotkeyListeners = new Set<(report: ForkHotkeyReport) => void>();
|
||||||
|
|
||||||
private forkHotkeysListeners = new Set<() => void>();
|
/** EC's own screenshare button is shown in the frame instead of ours. */
|
||||||
|
public get frameScreenshare(): boolean {
|
||||||
public get forkHandlesHotkeys(): boolean {
|
return this._frameScreenshare;
|
||||||
return this.forkHotkeys;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subscribe to `forkHandlesHotkeys` turning on. Returns an unsubscribe. */
|
/** Subscribe to `frameScreenshare` changes. Returns an unsubscribe. */
|
||||||
public onForkHotkeysChange(cb: () => void): () => void {
|
public onFrameScreenshareChange(cb: () => void): () => void {
|
||||||
this.forkHotkeysListeners.add(cb);
|
this.frameScreenshareListeners.add(cb);
|
||||||
return () => {
|
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. */
|
/** Key codes `source` (e.g. 'ptt', 'deafen') wants reported from the frame. */
|
||||||
public setHotkeyCodes(source: string, codes: string[]): void {
|
public setHotkeyCodes(source: string, codes: string[]): void {
|
||||||
this.hotkeyCodes.set(source, codes);
|
this.hotkeyCodes.set(source, codes);
|
||||||
@@ -409,7 +302,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sendHotkeyCodes(): void {
|
private sendHotkeyCodes(): void {
|
||||||
if (!this.joined || !this.forkHotkeys) return;
|
if (!this.joined || !this.forkReady) return;
|
||||||
const codes = [...new Set([...this.hotkeyCodes.values()].flat())];
|
const codes = [...new Set([...this.hotkeyCodes.values()].flat())];
|
||||||
this.call.transport.send('io.lotus.set_hotkeys', { codes }).catch(() => undefined);
|
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. */
|
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */
|
||||||
public onControlsState(data: unknown) {
|
public onControlsState(data: unknown) {
|
||||||
if (typeof data !== 'object' || data === null) return;
|
if (typeof data !== 'object' || data === null) return;
|
||||||
const { screensharing, layout, screenshareAction, hotkeys } = data as {
|
const { screensharing, layout, frameScreenshare } = data as {
|
||||||
screensharing?: unknown;
|
screensharing?: unknown;
|
||||||
layout?: unknown;
|
layout?: unknown;
|
||||||
screenshareAction?: unknown;
|
frameScreenshare?: unknown;
|
||||||
hotkeys?: unknown;
|
|
||||||
};
|
};
|
||||||
this.forkControls = true;
|
const firstReport = !this.forkReady;
|
||||||
this.forkScreenshare = screenshareAction === true;
|
this.forkReady = true;
|
||||||
if (hotkeys === true && !this.forkHotkeys) {
|
const frame = frameScreenshare === true;
|
||||||
this.forkHotkeys = true;
|
if (frame !== this._frameScreenshare) {
|
||||||
|
this._frameScreenshare = frame;
|
||||||
|
this.frameScreenshareListeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
if (firstReport) {
|
||||||
this.sendHotkeyCodes();
|
this.sendHotkeyCodes();
|
||||||
this.forkHotkeysListeners.forEach((l) => l());
|
this.sendFrameScreenshareAllowed();
|
||||||
}
|
}
|
||||||
this.applyControls(
|
this.applyControls(
|
||||||
typeof screensharing === 'boolean' ? screensharing : this.screenshare,
|
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) {
|
private applyControls(screenshare: boolean, spotlight: boolean) {
|
||||||
const wasScreensharing = this.screenshare;
|
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
|
* Must be called synchronously inside the user's click: starting a share
|
||||||
* calls getDisplayMedia in the frame, which needs that click. Where the
|
* calls getDisplayMedia in the frame, which needs that click. Where the
|
||||||
* browser can hand the click over (Capability Delegation, Chromium incl.
|
* browser can hand the click over (Capability Delegation, Chromium incl.
|
||||||
* WebView2) this goes over the widget API; elsewhere (Firefox, Safari,
|
* WebView2) it is sent with delegation. Elsewhere the host bar hides its
|
||||||
* WebKitGTK desktop) it still clicks EC's hidden button, which needs
|
* button and EC's own shows in the frame (`frameScreenshare`); a plain send
|
||||||
* same-origin access to the frame.
|
* still stops a share, which needs no click.
|
||||||
*/
|
*/
|
||||||
public toggleScreenshare() {
|
public toggleScreenshare() {
|
||||||
if (this.forkScreenshare && canDelegateCapability()) {
|
const data = { on: !this.screenshare };
|
||||||
this.sendDelegated('io.lotus.set_screenshare', { on: !this.screenshare }, 'display-capture');
|
if (canDelegateCapability()) {
|
||||||
|
this.sendDelegated('io.lotus.set_screenshare', data, 'display-capture');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.screenshareButton?.click();
|
this.sendForkAction('io.lotus.set_screenshare', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -602,34 +492,18 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public toggleSpotlight() {
|
public toggleSpotlight() {
|
||||||
if (this.forkControls) {
|
this.sendForkAction('io.lotus.set_layout', {
|
||||||
this.sendForkAction('io.lotus.set_layout', {
|
layout: this.spotlight ? 'grid' : 'spotlight',
|
||||||
layout: this.spotlight ? 'grid' : 'spotlight',
|
});
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.spotlight) {
|
|
||||||
this.gridButton?.click();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.spotlightButton?.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleReactions() {
|
public toggleReactions() {
|
||||||
if (this.forkControls) {
|
this.sendForkAction('io.lotus.toggle_reactions', {});
|
||||||
this.sendForkAction('io.lotus.toggle_reactions', {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.reactionsButton?.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleSettings() {
|
public toggleSettings() {
|
||||||
if (this.forkControls) {
|
// Same as clicking EC's settings button: opens the modal.
|
||||||
// Same as clicking EC's settings button: opens the modal.
|
this.sendForkAction('io.lotus.open_settings', { open: true });
|
||||||
this.sendForkAction('io.lotus.open_settings', { open: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.settingsButton?.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendForkAction(action: string, data: Record<string, unknown>): void {
|
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
|
* 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
|
* an https/blob URL the widget can fetch WITHOUT credentials — the host
|
||||||
* resolves an mxc clip to a `blob:` object URL first (authenticated media
|
* 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
|
* The local user does not hear their own published track, so callers should
|
||||||
* also play the clip locally for feedback.
|
* 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
|
// [EC#13] The fork now refuses while the local mic is muted and replies
|
||||||
// { played:false, reason:"muted" }; older forks reply {} (treated as played).
|
// { 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
|
return this.call.transport
|
||||||
.send<{ url: string; volume: number }, { played?: boolean; reason?: string }>(
|
.send<
|
||||||
'io.lotus.inject_audio',
|
{ url: string; volume: number; audio?: ArrayBuffer },
|
||||||
{ url, volume },
|
{ played?: boolean; reason?: string }
|
||||||
)
|
>('io.lotus.inject_audio', audio ? { url, volume, audio } : { url, volume })
|
||||||
.then((r) => ({ played: r?.played !== false, reason: r?.reason }))
|
.then((r) => ({ played: r?.played !== false, reason: r?.reason }))
|
||||||
.catch(() => ({ played: true }));
|
.catch(() => ({ played: true }));
|
||||||
}
|
}
|
||||||
@@ -731,14 +613,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public dispose() {
|
public dispose() {
|
||||||
if (this.bodyMutationTimer !== undefined) {
|
|
||||||
clearTimeout(this.bodyMutationTimer);
|
|
||||||
this.bodyMutationTimer = undefined;
|
|
||||||
}
|
|
||||||
// [Gitea #56] Don't let a manual focus pin outlive the call.
|
// [Gitea #56] Don't let a manual focus pin outlive the call.
|
||||||
this.clearFocusParticipant();
|
this.clearFocusParticipant();
|
||||||
this.bodyMutationObserver.disconnect();
|
|
||||||
this.controlMutationObserver.disconnect();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitStateUpdate() {
|
private emitStateUpdate() {
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ import {
|
|||||||
import { CallControl } from './CallControl';
|
import { CallControl } from './CallControl';
|
||||||
import { CallControlState } from './CallControlState';
|
import { CallControlState } from './CallControlState';
|
||||||
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
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
|
// 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
|
// 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
|
// [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.
|
// sets its root color-scheme from the theme, instead of us injecting CSS.
|
||||||
lotusHostControls: 'true',
|
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
|
// [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
|
// soundboard can publish clips into the call. Dormant until the host
|
||||||
// sends io.lotus.inject_audio (only on an explicit user click), so
|
// 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');
|
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
||||||
}
|
}
|
||||||
|
|
||||||
const widgetUrl = new URL(
|
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
||||||
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
|
const externalPage = getCallPageUrl();
|
||||||
window.location.origin,
|
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();
|
widgetUrl.search = params.toString();
|
||||||
|
|
||||||
const options: IWidget = {
|
const options: IWidget = {
|
||||||
@@ -300,6 +311,8 @@ export class CallEmbed {
|
|||||||
|
|
||||||
const callWidgetDriver: WidgetDriver = new CallWidgetDriver(mx, room.roomId);
|
const callWidgetDriver: WidgetDriver = new CallWidgetDriver(mx, room.roomId);
|
||||||
const call: ClientWidgetApi = new ClientWidgetApi(widget, iframe, callWidgetDriver);
|
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.mx = mx;
|
||||||
this.call = call;
|
this.call = call;
|
||||||
@@ -311,10 +324,6 @@ export class CallEmbed {
|
|||||||
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||||||
this.control = new CallControl(controlState, call, iframe);
|
this.control = new CallControl(controlState, call, iframe);
|
||||||
this.initialState = controlState;
|
this.initialState = controlState;
|
||||||
this.control.startObserving();
|
|
||||||
iframe.onload = () => {
|
|
||||||
this.control.startObserving();
|
|
||||||
};
|
|
||||||
// If the iframe document itself fails to load, fail fast.
|
// If the iframe document itself fails to load, fail fast.
|
||||||
iframe.onerror = () => {
|
iframe.onerror = () => {
|
||||||
this.settleLoad('iframe');
|
this.settleLoad('iframe');
|
||||||
@@ -348,10 +357,6 @@ export class CallEmbed {
|
|||||||
return this.room.roomId;
|
return this.room.roomId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get document(): Document | undefined {
|
|
||||||
return getCallDocument(this.iframe);
|
|
||||||
}
|
|
||||||
|
|
||||||
public setTheme(theme: ElementCallThemeKind) {
|
public setTheme(theme: ElementCallThemeKind) {
|
||||||
this.themeKind = theme;
|
this.themeKind = theme;
|
||||||
return this.call.transport
|
return this.call.transport
|
||||||
@@ -573,13 +578,12 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private onCallJoined(): void {
|
private onCallJoined(): void {
|
||||||
this.settleLoad();
|
this.settleLoad();
|
||||||
this.control.startObserving();
|
|
||||||
|
|
||||||
// C-H1: EC fires JoinCall again on an EC reconnect (this action has no
|
// 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
|
// 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
|
// 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
|
// re-push the sticky fork state (deafen/quality), leaving the user's live
|
||||||
// (deafen/quality), leaving the user's live media state untouched.
|
// media state untouched.
|
||||||
if (this.joined) {
|
if (this.joined) {
|
||||||
this.control.resendForkState();
|
this.control.resendForkState();
|
||||||
return;
|
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);
|
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
|
* Capability Delegation (`postMessage(msg, { delegate })`) ships only in
|
||||||
* same-origin, but when its navigation fails (offline, blocked) the frame
|
* Chromium; other engines silently ignore the option, so the frame would get
|
||||||
* becomes a cross-origin error page and `contentWindow.document` THROWS a
|
* no activation. `navigator.userAgentData` is likewise Chromium-only, which
|
||||||
* SecurityError — which surfaced as page errors (and a React "Should not
|
* makes it the practical feature test.
|
||||||
* 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".
|
|
||||||
*/
|
*/
|
||||||
export const getCallDocument = (iframe: HTMLIFrameElement): Document | undefined => {
|
export const canDelegateCapability = (): boolean =>
|
||||||
try {
|
typeof navigator !== 'undefined' && 'userAgentData' in navigator;
|
||||||
return iframe.contentDocument ?? iframe.contentWindow?.document ?? undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { afterEach, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { canCopyImage, copyImageFromUrl, toPngBlob } from './copyImage';
|
||||||
|
|
||||||
|
const g = globalThis as Record<string, unknown>;
|
||||||
|
const saved = {
|
||||||
|
ClipboardItem: Object.getOwnPropertyDescriptor(g, 'ClipboardItem'),
|
||||||
|
navigator: Object.getOwnPropertyDescriptor(g, 'navigator'),
|
||||||
|
fetch: Object.getOwnPropertyDescriptor(g, 'fetch'),
|
||||||
|
};
|
||||||
|
const stub = (key: keyof typeof saved, value: unknown) =>
|
||||||
|
Object.defineProperty(g, key, { value, configurable: true, writable: true });
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(Object.keys(saved) as (keyof typeof saved)[]).forEach((key) => {
|
||||||
|
const desc = saved[key];
|
||||||
|
if (desc) Object.defineProperty(g, key, desc);
|
||||||
|
else delete g[key];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeClipboardItem {
|
||||||
|
constructor(public data: Record<string, Promise<Blob>>) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('canCopyImage is false without ClipboardItem', () => {
|
||||||
|
stub('ClipboardItem', undefined);
|
||||||
|
assert.equal(canCopyImage(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canCopyImage is true with ClipboardItem and clipboard.write', () => {
|
||||||
|
stub('ClipboardItem', FakeClipboardItem);
|
||||||
|
stub('navigator', { clipboard: { write: async () => undefined } });
|
||||||
|
assert.equal(canCopyImage(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toPngBlob passes a PNG through untouched', async () => {
|
||||||
|
const png = new Blob(['x'], { type: 'image/png' });
|
||||||
|
assert.equal(await toPngBlob(png), png);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyImageFromUrl writes a single image/png item', async () => {
|
||||||
|
const png = new Blob(['x'], { type: 'image/png' });
|
||||||
|
stub('fetch', async () => ({ ok: true, blob: async () => png }));
|
||||||
|
stub('ClipboardItem', FakeClipboardItem);
|
||||||
|
const written: FakeClipboardItem[][] = [];
|
||||||
|
stub('navigator', {
|
||||||
|
clipboard: {
|
||||||
|
write: async (items: FakeClipboardItem[]) => {
|
||||||
|
written.push(items);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await copyImageFromUrl('blob:x');
|
||||||
|
assert.equal(written.length, 1);
|
||||||
|
assert.deepEqual(Object.keys(written[0][0].data), ['image/png']);
|
||||||
|
assert.equal(await written[0][0].data['image/png'], png);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyImageFromUrl rejects when the fetch fails', async () => {
|
||||||
|
stub('fetch', async () => ({ ok: false, status: 404 }));
|
||||||
|
stub('ClipboardItem', FakeClipboardItem);
|
||||||
|
stub('navigator', {
|
||||||
|
clipboard: {
|
||||||
|
write: async (items: FakeClipboardItem[]) => {
|
||||||
|
await items[0].data['image/png'];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(copyImageFromUrl('blob:x'), /404/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #164] Copy an image to the system clipboard.
|
||||||
|
*
|
||||||
|
* The async Clipboard API only guarantees `image/png`, so anything else (JPEG,
|
||||||
|
* WebP, a GIF's first frame) is re-encoded through a canvas first. The blob is
|
||||||
|
* handed to `ClipboardItem` as a promise so the write starts inside the click's
|
||||||
|
* user activation — Safari rejects a write that begins after an await.
|
||||||
|
*/
|
||||||
|
export function canCopyImage(): boolean {
|
||||||
|
return (
|
||||||
|
typeof ClipboardItem !== 'undefined' &&
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
typeof navigator.clipboard?.write === 'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toPngBlob(blob: Blob): Promise<Blob> {
|
||||||
|
if (blob.type === 'image/png') return blob;
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('no 2d context');
|
||||||
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
|
return await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('png encode failed'))), 'image/png');
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyImageFromUrl(url: string): Promise<void> {
|
||||||
|
const png = fetch(url)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
||||||
|
return res.blob();
|
||||||
|
})
|
||||||
|
.then(toPngBlob);
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
|
||||||
|
}
|
||||||
@@ -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 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 = {
|
type RoomOpts = {
|
||||||
timeline?: any[];
|
timeline?: any[];
|
||||||
readUpTo?: string | null;
|
readUpTo?: string | null;
|
||||||
threads?: any[];
|
threads?: any[];
|
||||||
threadUnread?: Record<string, number>;
|
threadUnread?: Record<string, number>;
|
||||||
|
threadHighlight?: Record<string, number>;
|
||||||
|
readEvents?: string[];
|
||||||
markedUnread?: boolean;
|
markedUnread?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,8 +33,12 @@ const setup = (opts: RoomOpts) => {
|
|||||||
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
|
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
|
||||||
getEventReadUpTo: () => opts.readUpTo ?? null,
|
getEventReadUpTo: () => opts.readUpTo ?? null,
|
||||||
getThreads: () => opts.threads ?? [],
|
getThreads: () => opts.threads ?? [],
|
||||||
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
|
hasUserReadEvent: (_userId: string, eventId: string) =>
|
||||||
opts.threadUnread?.[threadId] ?? 0,
|
(opts.readEvents ?? []).includes(eventId),
|
||||||
|
getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
|
||||||
|
(type === NotificationCountType.Highlight ? opts.threadHighlight : opts.threadUnread)?.[
|
||||||
|
threadId
|
||||||
|
] ?? 0,
|
||||||
getAccountData: (type: string) =>
|
getAccountData: (type: string) =>
|
||||||
opts.markedUnread && type === 'm.marked_unread'
|
opts.markedUnread && type === 'm.marked_unread'
|
||||||
? { getContent: () => ({ unread: true }) }
|
? { getContent: () => ({ unread: true }) }
|
||||||
@@ -157,3 +164,127 @@ test('private receipt flag uses ReadPrivate', async () => {
|
|||||||
await markAsRead(mx, '!r:server', true);
|
await markAsRead(mx, '!r:server', true);
|
||||||
assert.equal(calls[0].receiptType, ReceiptType.ReadPrivate);
|
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 { getSettings } from '../state/settings';
|
||||||
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
|
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 { privateReadReceipts } = getSettings();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
@@ -29,12 +63,32 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
|
|||||||
return null;
|
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;
|
const latestEvent = timeline.length > 0 ? getLatestValidEvent() : null;
|
||||||
if (latestEvent) {
|
if (latestEvent) {
|
||||||
// Unthreaded receipt: with client threadSupport enabled the SDK would
|
// Unthreaded receipt: with client threadSupport enabled the SDK would
|
||||||
// otherwise scope this to the main timeline (thread_id: "main"). Unthreaded
|
// otherwise scope this to the main timeline (thread_id: "main"). Unthreaded
|
||||||
// clears the main timeline + every event up to this one.
|
// 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,
|
// 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
|
// 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).
|
// 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.
|
// If a thread's replies aren't loaded (lastReply() null), just skip it.
|
||||||
const threads = room.getThreads();
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
threads.map((thread) => {
|
threads.map((thread) => {
|
||||||
const unread =
|
if (threadUnread(thread) <= 0) return undefined;
|
||||||
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
if (keepThread(thread) || thread.id === openThreadId) return undefined;
|
||||||
if (unread <= 0) return undefined;
|
|
||||||
const lastReply = thread.lastReply();
|
const lastReply = thread.lastReply();
|
||||||
if (!lastReply || lastReply.isSending()) return undefined;
|
if (!lastReply || lastReply.isSending()) return undefined;
|
||||||
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread
|
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread
|
||||||
|
|||||||
Reference in New Issue
Block a user