fix(timeline): thread panel shows read receipts; receipts recompute incrementally
- ReadPositionsContext is provided once at Room level so the thread panel (a sibling of RoomView) gets real positions instead of the empty default; own thread messages no longer sit on "Sent" forever (#38). - Receipt events only recompute the users they name, merged into the previous map with reference equality preserved for untouched rows, so a receipt no longer re-renders every message (#40). Unit-tested. Fixes #38 Fixes #40 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -28,6 +28,8 @@ import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
|||||||
import { threadsListAtom } from '../../state/threadsList';
|
import { threadsListAtom } from '../../state/threadsList';
|
||||||
import { ThreadPanel } from './thread';
|
import { ThreadPanel } from './thread';
|
||||||
import { ThreadsListPanel } from './thread/ThreadsListPanel';
|
import { ThreadsListPanel } from './thread/ThreadsListPanel';
|
||||||
|
import { ReadPositionsContext } from './ReadPositionsContext';
|
||||||
|
import { useRoomReadPositions } from '../../hooks/useRoomReadPositions';
|
||||||
|
|
||||||
export function Room() {
|
export function Room() {
|
||||||
const { eventId } = useParams();
|
const { eventId } = useParams();
|
||||||
@@ -52,6 +54,10 @@ export function Room() {
|
|||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const members = useRoomMembers(mx, room.roomId);
|
const members = useRoomMembers(mx, room.roomId);
|
||||||
const chat = useAtomValue(callChatAtom);
|
const chat = useAtomValue(callChatAtom);
|
||||||
|
// Hoisted here (instead of inside RoomTimeline) so ThreadPanel/ThreadTimeline —
|
||||||
|
// rendered as siblings of RoomView, not descendants — share the same computed
|
||||||
|
// positions instead of falling back to the context default (Gitea #38).
|
||||||
|
const readPositions = useRoomReadPositions(room);
|
||||||
useKeyDown(
|
useKeyDown(
|
||||||
window,
|
window,
|
||||||
useCallback(
|
useCallback(
|
||||||
@@ -147,87 +153,89 @@ export function Room() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<Box grow="Yes">
|
<ReadPositionsContext.Provider value={readPositions}>
|
||||||
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
<Box grow="Yes">
|
||||||
<Box grow="Yes" direction="Column">
|
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
||||||
<RoomViewHeader callView />
|
<Box grow="Yes" direction="Column">
|
||||||
<Box grow="Yes">
|
<RoomViewHeader callView />
|
||||||
<CallView />
|
<Box grow="Yes">
|
||||||
|
<CallView />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
)}
|
{!callView && (
|
||||||
{!callView && (
|
<Box grow="Yes" direction="Column">
|
||||||
<Box grow="Yes" direction="Column">
|
<RoomViewHeader />
|
||||||
<RoomViewHeader />
|
<Box grow="Yes">
|
||||||
<Box grow="Yes">
|
<RoomView eventId={eventId} />
|
||||||
<RoomView eventId={eventId} />
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{callView && chat && (
|
{callView && chat && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
)}
|
)}
|
||||||
<CallChatView />
|
<CallChatView />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showGallery && (
|
{showGallery && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
)}
|
)}
|
||||||
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showWidgets && (
|
{showWidgets && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
)}
|
)}
|
||||||
<WidgetsPanel
|
<WidgetsPanel
|
||||||
key={room.roomId}
|
key={room.roomId}
|
||||||
room={room}
|
room={room}
|
||||||
requestClose={() => setWidgetsOpen(false)}
|
requestClose={() => setWidgetsOpen(false)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showThreadsList && (
|
{showThreadsList && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
)}
|
)}
|
||||||
<ThreadsListPanel
|
<ThreadsListPanel
|
||||||
key={room.roomId}
|
key={room.roomId}
|
||||||
room={room}
|
room={room}
|
||||||
onClose={closeThreadsList}
|
onClose={closeThreadsList}
|
||||||
onOpenThread={openThreadFromList}
|
onOpenThread={openThreadFromList}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showThreadPanel && activeThreadId && (
|
{showThreadPanel && activeThreadId && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
)}
|
)}
|
||||||
<ThreadPanel
|
<ThreadPanel
|
||||||
key={`${room.roomId}${activeThreadId}`}
|
key={`${room.roomId}${activeThreadId}`}
|
||||||
room={room}
|
room={room}
|
||||||
threadId={activeThreadId}
|
threadId={activeThreadId}
|
||||||
requestClose={() => setActiveThreadId(null)}
|
requestClose={() => setActiveThreadId(null)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showMembers && (
|
{showMembers && (
|
||||||
<>
|
<>
|
||||||
{screenSize === ScreenSize.Desktop && (
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
)}
|
)}
|
||||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</ReadPositionsContext.Provider>
|
||||||
</PowerLevelsContextProvider>
|
</PowerLevelsContextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,8 +90,6 @@ import { useSetting } from '../../state/hooks/settings';
|
|||||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
import { MessageLayout, settingsAtom } from '../../state/settings';
|
||||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||||
import { Reactions, Message, Event, EncryptedContent } from './message';
|
import { Reactions, Message, Event, EncryptedContent } from './message';
|
||||||
import { ReadPositionsContext } from './ReadPositionsContext';
|
|
||||||
import { useRoomReadPositions } from '../../hooks/useRoomReadPositions';
|
|
||||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||||
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
||||||
import { RoomIntro } from '../../components/room-intro';
|
import { RoomIntro } from '../../components/room-intro';
|
||||||
@@ -463,7 +461,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
const direct = useIsDirectRoom();
|
const direct = useIsDirectRoom();
|
||||||
const readPositions = useRoomReadPositions(room);
|
// Read positions are computed once in Room.tsx and provided via ReadPositionsContext
|
||||||
|
// so both RoomTimeline and ThreadTimeline consume the same value (Gitea #38).
|
||||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
@@ -2128,7 +2127,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReadPositionsContext.Provider value={readPositions}>
|
<>
|
||||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
<Box grow="Yes" style={{ position: 'relative' }}>
|
||||||
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
||||||
<TimelineFloat position="Top">
|
<TimelineFloat position="Top">
|
||||||
@@ -2264,6 +2263,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
onClose={() => setEditHistoryEvent(undefined)}
|
onClose={() => setEditHistoryEvent(undefined)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ReadPositionsContext.Provider>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { Room, MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
|
||||||
|
import { computeUpdatedPositions, getReceiptUserIds } from './useRoomReadPositions';
|
||||||
|
|
||||||
|
// Fake, renderable (no reaction/edit relation) event.
|
||||||
|
const fakeEvent = (id: string) =>
|
||||||
|
({
|
||||||
|
getId: () => id,
|
||||||
|
getRelation: () => null,
|
||||||
|
}) as unknown as MatrixEvent;
|
||||||
|
|
||||||
|
// Minimal fake room: a fixed live timeline plus a mutable per-user read-up-to map,
|
||||||
|
// which the test mutates between calls to simulate new receipts arriving.
|
||||||
|
const makeFakeRoom = (eventIds: string[]) => {
|
||||||
|
const readUpTo = new Map<string, string>();
|
||||||
|
const events = eventIds.map(fakeEvent);
|
||||||
|
const room = {
|
||||||
|
getLiveTimeline: () => ({ getEvents: () => events }),
|
||||||
|
getEventReadUpTo: (userId: string) => readUpTo.get(userId) ?? null,
|
||||||
|
} as unknown as Room;
|
||||||
|
return { room, readUpTo };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('getReceiptUserIds collects every userId across events/types in the content', () => {
|
||||||
|
const content = {
|
||||||
|
$a: {
|
||||||
|
[ReceiptType.Read]: { '@alice:hs': { ts: 1 }, '@bob:hs': { ts: 2 } },
|
||||||
|
},
|
||||||
|
$b: {
|
||||||
|
[ReceiptType.ReadPrivate]: { '@carol:hs': { ts: 3 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ids = getReceiptUserIds(content).sort();
|
||||||
|
assert.deepEqual(ids, ['@alice:hs', '@bob:hs', '@carol:hs']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getReceiptUserIds returns empty for an empty receipt content', () => {
|
||||||
|
assert.deepEqual(getReceiptUserIds({}), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeUpdatedPositions returns the SAME map reference when no named user changed position', () => {
|
||||||
|
const { room, readUpTo } = makeFakeRoom(['$a', '$b']);
|
||||||
|
readUpTo.set('@alice:hs', '$a');
|
||||||
|
const prev = computeUpdatedPositions(room, '@me:hs', new Map(), ['@alice:hs']);
|
||||||
|
assert.deepEqual(prev.get('$a'), ['@alice:hs']);
|
||||||
|
|
||||||
|
// Alice's read-up-to did not move; recompute should be a no-op reference-wise.
|
||||||
|
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@alice:hs']);
|
||||||
|
assert.equal(next, prev);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeUpdatedPositions moves only the named user, leaving other entries untouched by reference', () => {
|
||||||
|
const { room, readUpTo } = makeFakeRoom(['$a', '$b']);
|
||||||
|
readUpTo.set('@alice:hs', '$a');
|
||||||
|
readUpTo.set('@bob:hs', '$a');
|
||||||
|
const prev = computeUpdatedPositions(room, '@me:hs', new Map(), ['@alice:hs', '@bob:hs']);
|
||||||
|
const bArrayBefore = prev.get('$a');
|
||||||
|
|
||||||
|
// Only alice moves to $b.
|
||||||
|
readUpTo.set('@alice:hs', '$b');
|
||||||
|
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@alice:hs']);
|
||||||
|
|
||||||
|
assert.notEqual(next, prev);
|
||||||
|
assert.deepEqual(next.get('$a'), ['@bob:hs']);
|
||||||
|
assert.deepEqual(next.get('$b'), ['@alice:hs']);
|
||||||
|
// Untouched arrays for other targets should keep their old reference where possible.
|
||||||
|
assert.notEqual(next.get('$a'), bArrayBefore); // this one WAS filtered, expected to change
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeUpdatedPositions ignores changedUserIds not present after filtering myUserId', () => {
|
||||||
|
const { room } = makeFakeRoom(['$a']);
|
||||||
|
const prev = new Map([['$a', ['@bob:hs']]]);
|
||||||
|
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@me:hs']);
|
||||||
|
assert.equal(next, prev);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeUpdatedPositions removes a user with no read-up-to event from the map', () => {
|
||||||
|
const { room, readUpTo } = makeFakeRoom(['$a']);
|
||||||
|
readUpTo.set('@alice:hs', '$a');
|
||||||
|
const prev = computeUpdatedPositions(room, '@me:hs', new Map(), ['@alice:hs']);
|
||||||
|
assert.deepEqual(prev.get('$a'), ['@alice:hs']);
|
||||||
|
|
||||||
|
readUpTo.delete('@alice:hs');
|
||||||
|
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@alice:hs']);
|
||||||
|
assert.equal(next.has('$a'), false);
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Room, RoomEvent, RoomMember, RoomMemberEvent, MatrixEvent } from 'matrix-js-sdk';
|
import { Room, RoomEvent, RoomMember, RoomMemberEvent, MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { ReceiptContent } from 'matrix-js-sdk/lib/@types/read_receipts';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { reactionOrEditEvent } from '../utils/room';
|
import { reactionOrEditEvent } from '../utils/room';
|
||||||
@@ -19,6 +20,18 @@ function nearestRenderableId(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A `m.receipt` event's content names every user whose receipt moved, keyed by
|
||||||
|
// eventId -> receiptType -> userId. Exported for testing.
|
||||||
|
export function getReceiptUserIds(content: ReceiptContent): string[] {
|
||||||
|
const userIds = new Set<string>();
|
||||||
|
Object.values(content).forEach((receiptsByType) => {
|
||||||
|
Object.values(receiptsByType).forEach((receiptsByUser) => {
|
||||||
|
Object.keys(receiptsByUser).forEach((userId) => userIds.add(userId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(userIds);
|
||||||
|
}
|
||||||
|
|
||||||
function computePositions(room: Room, myUserId: string): Map<string, string[]> {
|
function computePositions(room: Room, myUserId: string): Map<string, string[]> {
|
||||||
const map = new Map<string, string[]>();
|
const map = new Map<string, string[]>();
|
||||||
const liveEvents = room.getLiveTimeline().getEvents();
|
const liveEvents = room.getLiveTimeline().getEvents();
|
||||||
@@ -37,6 +50,64 @@ function computePositions(room: Room, myUserId: string): Map<string, string[]> {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recompute positions for only the given users, reusing the previous Map/arrays for
|
||||||
|
// everyone else. Returns the SAME `prevMap` reference when nothing actually changed,
|
||||||
|
// so unaffected `Message`s (the overwhelming majority on every receipt) skip re-render
|
||||||
|
// (Gitea #40).
|
||||||
|
export function computeUpdatedPositions(
|
||||||
|
room: Room,
|
||||||
|
myUserId: string,
|
||||||
|
prevMap: Map<string, string[]>,
|
||||||
|
changedUserIds: Iterable<string>,
|
||||||
|
): Map<string, string[]> {
|
||||||
|
const usersToUpdate = new Set(changedUserIds);
|
||||||
|
usersToUpdate.delete(myUserId);
|
||||||
|
if (usersToUpdate.size === 0) return prevMap;
|
||||||
|
|
||||||
|
const liveEvents = room.getLiveTimeline().getEvents();
|
||||||
|
const eventIndex = new Map<string, number>(liveEvents.map((e, i) => [e.getId() ?? '', i]));
|
||||||
|
|
||||||
|
// Each changed user's current target, so a receipt that didn't actually move
|
||||||
|
// them past the previously-computed nearest renderable event is a true no-op.
|
||||||
|
const oldTargetByUser = new Map<string, string>();
|
||||||
|
prevMap.forEach((users, targetId) => {
|
||||||
|
users.forEach((u) => {
|
||||||
|
if (usersToUpdate.has(u)) oldTargetByUser.set(u, targetId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy lazily: stay on `prevMap` (and its untouched per-event arrays) unless a
|
||||||
|
// user actually moved, so unrelated Messages keep the same array/Map reference.
|
||||||
|
let nextMap = prevMap;
|
||||||
|
const ensureCopy = (): Map<string, string[]> => {
|
||||||
|
if (nextMap === prevMap) nextMap = new Map(prevMap);
|
||||||
|
return nextMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
usersToUpdate.forEach((userId) => {
|
||||||
|
const evtId = room.getEventReadUpTo(userId);
|
||||||
|
const newTargetId = evtId ? nearestRenderableId(liveEvents, eventIndex, evtId) : null;
|
||||||
|
const oldTargetId = oldTargetByUser.get(userId) ?? null;
|
||||||
|
if (newTargetId === oldTargetId) return;
|
||||||
|
|
||||||
|
const map = ensureCopy();
|
||||||
|
if (oldTargetId) {
|
||||||
|
const users = map.get(oldTargetId);
|
||||||
|
if (users) {
|
||||||
|
const filtered = users.filter((u) => u !== userId);
|
||||||
|
if (filtered.length === 0) map.delete(oldTargetId);
|
||||||
|
else map.set(oldTargetId, filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newTargetId) {
|
||||||
|
const users = map.get(newTargetId);
|
||||||
|
map.set(newTargetId, users ? [...users, userId] : [userId]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextMap;
|
||||||
|
}
|
||||||
|
|
||||||
export function useRoomReadPositions(room: Room): Map<string, string[]> {
|
export function useRoomReadPositions(room: Room): Map<string, string[]> {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const myUserId = mx.getUserId() ?? '';
|
const myUserId = mx.getUserId() ?? '';
|
||||||
@@ -45,11 +116,19 @@ export function useRoomReadPositions(room: Room): Map<string, string[]> {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPositions(computePositions(room, myUserId));
|
setPositions(computePositions(room, myUserId));
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const onReceipt = (): void => {
|
// Accumulated across every receipt event that lands during the debounce window so
|
||||||
|
// a burst of receipts still only touches the users actually named in them.
|
||||||
|
const pendingUserIds = new Set<string>();
|
||||||
|
const onReceipt = (event: MatrixEvent): void => {
|
||||||
|
getReceiptUserIds(event.getContent<ReceiptContent>()).forEach((userId) =>
|
||||||
|
pendingUserIds.add(userId),
|
||||||
|
);
|
||||||
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
setPositions(computePositions(room, myUserId));
|
const userIds = Array.from(pendingUserIds);
|
||||||
|
pendingUserIds.clear();
|
||||||
debounceTimer = null;
|
debounceTimer = null;
|
||||||
|
setPositions((prev) => computeUpdatedPositions(room, myUserId, prev, userIds));
|
||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
// RoomMemberEvent.Membership is emitted on the RoomMember (and re-emitted on the
|
// RoomMemberEvent.Membership is emitted on the RoomMember (and re-emitted on the
|
||||||
|
|||||||
Reference in New Issue
Block a user