import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Scroll, Text, color, config } from 'folds'; import { EventType, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk'; import { Page, PageContent, PageHeader } from '../../components/page'; import { SequenceCard } from '../../components/sequence-card'; import { useRoom } from '../../hooks/useRoom'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { getMemberAvatarMxc, getMemberName } from '../../utils/room'; import { mxcUrlToHttp } from '../../utils/matrix'; import { UserAvatar } from '../../components/user-avatar'; // ── Helpers ─────────────────────────────────────────────────────────────────── function formatDate(ts: number): string { return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', }); } 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 ──────────────────────────────────────────────────────────── function SectionHeader({ label }: { label: string }) { return ( {label} ); } // ── Stat tile ───────────────────────────────────────────────────────────────── function StatTile({ icon, count, label }: { icon: IconSrc; count: number; label: string }) { return ( {count} {label} ); } // ── Main component ──────────────────────────────────────────────────────────── type RoomInsightsProps = { requestClose: () => void; }; export function RoomInsights({ requestClose }: RoomInsightsProps) { const mx = useMatrixClient(); const room = useRoom(); 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 | 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 events = room.getLiveTimeline().getEvents(); // ── A. Message count by member ────────────────────────────────────────── const msgCounts = new Map(); for (const ev of events) { if (ev.getType() === EventType.RoomMessage && !ev.isDecryptionFailure()) { const sender = ev.getSender(); if (sender) msgCounts.set(sender, (msgCounts.get(sender) ?? 0) + 1); } } const top5 = [...msgCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); // ── B. Top 5 reactions ────────────────────────────────────────────────── const reactionCounts = new Map(); for (const ev of events) { if (ev.getType() === EventType.Reaction) { const key = ev.getContent()['m.relates_to']?.key as string | undefined; if (key) reactionCounts.set(key, (reactionCounts.get(key) ?? 0) + 1); } } const top5Reactions = [...reactionCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); // ── C. Media breakdown ────────────────────────────────────────────────── const mediaCounts = { image: 0, video: 0, audio: 0, file: 0 }; for (const ev of events) { if (ev.getType() !== EventType.RoomMessage) continue; const msgtype = ev.getContent().msgtype as string | undefined; if (msgtype === 'm.image') mediaCounts.image++; else if (msgtype === 'm.video') mediaCounts.video++; else if (msgtype === 'm.audio') mediaCounts.audio++; else if (msgtype === 'm.file') mediaCounts.file++; } // ── D. Activity heatmap — messages per hour ───────────────────────────── const hourBuckets = new Array(24).fill(0); for (const ev of events) { if (ev.getType() === EventType.RoomMessage) { hourBuckets[new Date(ev.getTs()).getHours()]++; } } // ── E. Summary stats ──────────────────────────────────────────────────── const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0); const uniqueParticipants = msgCounts.size; // Single-pass min/max — `Math.min(...allTs)` spreads one arg per message and // overflows the call stack (RangeError) on a large paginated timeline. let oldestTs: number | null = null; let newestTs: number | null = null; for (const ev of events) { if (ev.getType() !== EventType.RoomMessage) continue; const ts = ev.getTs(); if (oldestTs === null || ts < oldestTs) oldestTs = ts; if (newestTs === null || ts > newestTs) newestTs = ts; } return { top5, top5Reactions, mediaCounts, hourBuckets, totalMessages, uniqueParticipants, oldestTs, newestTs, totalCached: events.length, }; // 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 maxMsgCount = stats.top5.length > 0 ? (stats.top5[0]?.[1] ?? 1) : 1; return ( Insights {/* ── Disclaimer banner ── */} Based on {stats.totalMessages} locally cached message {stats.totalMessages !== 1 ? 's' : ''} {stats.oldestTs !== null && stats.newestTs !== null && ( from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)} )} Last updated {formatUpdatedAt(lastUpdated)} {/* ── Summary row ── */} {stats.totalMessages.toLocaleString()} Messages {stats.uniqueParticipants.toLocaleString()} Participants {stats.totalCached.toLocaleString()} Cached events {/* ── Media shared ── */} {/* ── Most active members ── */} {stats.top5.length > 0 && ( {stats.top5.map(([userId, count], index) => { const displayName = getMemberName(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId); const avatarUrl = avatarMxc ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined) : undefined; const barWidth = `${Math.max(4, (count / maxMsgCount) * 100)}%`; return ( {/* Rank */} {index + 1} {/* Avatar */} } /> {/* Name + bar */} {displayName}
{count} ); })} )} {/* ── Top reactions ── */} {stats.top5Reactions.length > 0 && ( {stats.top5Reactions.map(([emoji, count]) => ( {emoji} {count} ))} )} {/* ── Activity by hour ── */} {/* Bars */} {stats.hourBuckets.map((count, h) => (
0 && count === maxHour ? color.Primary.Main : color.SurfaceVariant.Container, borderRadius: `${config.radii.R300} ${config.radii.R300} 0 0`, transition: 'height 0.2s ease', }} /> ))} {/* Hour labels: show 0, 6, 12, 18 */} {stats.hourBuckets.map((_, h) => ( {h % 6 === 0 ? ( {h} ) : null} ))} Hour of day (local time, 0 = midnight) {/* Bottom padding */} ); }