fix(rooms): Room Insights refreshes on new timeline events (throttled) with a Refresh button

Fixes #83

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 20:28:42 -04:00
co-authored by Claude Opus 5
parent ff575212ee
commit d0c13b1a49
@@ -1,6 +1,6 @@
import React, { useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Scroll, Text, color, config } from 'folds'; import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Scroll, Text, color, config } from 'folds';
import { EventType } from 'matrix-js-sdk'; import { EventType, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page'; import { Page, PageContent, PageHeader } from '../../components/page';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
@@ -20,6 +20,15 @@ function formatDate(ts: number): string {
}); });
} }
function formatUpdatedAt(ts: number): string {
return new Date(ts).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
// Throttle window for re-computing stats on new timeline events - avoids
// re-running every heatmap/list computation on every single incoming message
// during a burst.
const RECOMPUTE_THROTTLE_MS = 2000;
// ── Section header ──────────────────────────────────────────────────────────── // ── Section header ────────────────────────────────────────────────────────────
function SectionHeader({ label }: { label: string }) { function SectionHeader({ label }: { label: string }) {
@@ -69,6 +78,52 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
const room = useRoom(); const room = useRoom();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [lastUpdated, setLastUpdated] = useState(() => Date.now());
// Bumped to force the stats useMemo below to re-run; the value itself is unused.
const [recomputeTick, setRecomputeTick] = useState(0);
const recomputeNow = useCallback(() => {
setRecomputeTick((n) => n + 1);
setLastUpdated(Date.now());
}, []);
// Stats were previously computed once (keyed only on `room`, whose reference
// never changes) and never reflected new activity while the panel stayed
// open. Re-run on new timeline events for this room, throttled so a burst of
// messages doesn't recompute on every single event.
useEffect(() => {
let throttleTimer: ReturnType<typeof setTimeout> | undefined;
let pending = false;
const scheduleTrailing = () => {
throttleTimer = setTimeout(() => {
if (pending) {
pending = false;
recomputeNow();
scheduleTrailing();
} else {
throttleTimer = undefined;
}
}, RECOMPUTE_THROTTLE_MS);
};
const handleTimeline = (_event: MatrixEvent, eventRoom: Room | undefined) => {
if (eventRoom?.roomId !== room.roomId) return;
if (throttleTimer) {
pending = true;
return;
}
recomputeNow();
scheduleTrailing();
};
mx.on(RoomEvent.Timeline, handleTimeline);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimeline);
if (throttleTimer) clearTimeout(throttleTimer);
};
}, [mx, room, recomputeNow]);
const stats = useMemo(() => { const stats = useMemo(() => {
const events = room.getLiveTimeline().getEvents(); const events = room.getLiveTimeline().getEvents();
@@ -137,7 +192,10 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
newestTs, newestTs,
totalCached: events.length, totalCached: events.length,
}; };
}, [room]); // recomputeTick is intentionally in the deps (unused in the body) - it's the
// signal bumped by the timeline listener above to force this to re-run.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, recomputeTick]);
const maxHour = Math.max(...stats.hourBuckets, 1); const maxHour = Math.max(...stats.hourBuckets, 1);
const maxMsgCount = stats.top5.length > 0 ? (stats.top5[0]?.[1] ?? 1) : 1; const maxMsgCount = stats.top5.length > 0 ? (stats.top5[0]?.[1] ?? 1) : 1;
@@ -167,7 +225,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
{/* ── Disclaimer banner ── */} {/* ── Disclaimer banner ── */}
<SequenceCard variant="SurfaceVariant" gap="200" alignItems="Center"> <SequenceCard variant="SurfaceVariant" gap="200" alignItems="Center">
<Icon src={Icons.Warning} size="200" style={{ color: color.Warning.Main }} /> <Icon src={Icons.Warning} size="200" style={{ color: color.Warning.Main }} />
<Box direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
<Text size="T300"> <Text size="T300">
<strong> <strong>
Based on {stats.totalMessages} locally cached message Based on {stats.totalMessages} locally cached message
@@ -179,6 +237,20 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)} from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
</Text> </Text>
)} )}
<Text size="T200" priority="300">
Last updated {formatUpdatedAt(lastUpdated)}
</Text>
</Box>
<Box shrink="No">
<IconButton
onClick={recomputeNow}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Refresh insights"
>
<Icon src={Icons.Reload} size="100" />
</IconButton>
</Box> </Box>
</SequenceCard> </SequenceCard>