import React, { useMemo } from 'react'; import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Scroll, Text, color, config } from 'folds'; import { EventType } 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, getMemberDisplayName } from '../../utils/room'; import { getMxIdLocalPart, 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', }); } // ── 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 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; const msgEvents = events.filter((ev) => ev.getType() === EventType.RoomMessage); const allTs = msgEvents.map((ev) => ev.getTs()); const oldestTs = allTs.length > 0 ? Math.min(...allTs) : null; const newestTs = allTs.length > 0 ? Math.max(...allTs) : null; return { top5, top5Reactions, mediaCounts, hourBuckets, totalMessages, uniqueParticipants, oldestTs, newestTs, totalCached: events.length, }; }, [room]); 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)} )} {/* ── 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 = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? 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 */} ); }