Files
cinny/src/app/features/room-settings/RoomInsights.tsx
T

459 lines
19 KiB
TypeScript
Raw Normal View History

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 (
<Text size="L400" priority="300">
{label}
</Text>
);
}
// ── Stat tile ─────────────────────────────────────────────────────────────────
function StatTile({ icon, count, label }: { icon: IconSrc; count: number; label: string }) {
return (
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{
flex: 1,
minWidth: 64,
padding: `${config.space.S300} ${config.space.S200}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
<Icon src={icon} size="300" />
<Text size="H4" style={{ fontWeight: 700 }}>
{count}
</Text>
<Text size="T200" priority="300" align="Center">
{label}
</Text>
</Box>
);
}
// ── 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<string, number>();
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<string, number>();
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<number>(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 (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Info} size="200" />
<Text as="h2" size="H3" truncate>
Insights
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="500">
{/* ── Disclaimer banner ── */}
<SequenceCard variant="SurfaceVariant" gap="200" alignItems="Center">
<Icon src={Icons.Warning} size="200" style={{ color: color.Warning.Main }} />
<Box direction="Column" gap="100">
<Text size="T300">
<strong>
Based on {stats.totalMessages} locally cached message
{stats.totalMessages !== 1 ? 's' : ''}
</strong>
</Text>
{stats.oldestTs !== null && stats.newestTs !== null && (
<Text size="T200" priority="300">
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
</Text>
)}
</Box>
</SequenceCard>
{/* ── Summary row ── */}
<Box direction="Column" gap="200">
<SectionHeader label="Summary" />
<Box gap="200" wrap="Wrap">
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{
flex: 1,
minWidth: 80,
padding: `${config.space.S300} ${config.space.S200}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
<Text
size="H5"
style={{
fontWeight: 700,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{stats.totalMessages.toLocaleString()}
</Text>
<Text size="T200" priority="300" align="Center">
Messages
</Text>
</Box>
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{
flex: 1,
minWidth: 80,
padding: `${config.space.S300} ${config.space.S200}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
<Text
size="H5"
style={{
fontWeight: 700,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{stats.uniqueParticipants.toLocaleString()}
</Text>
<Text size="T200" priority="300" align="Center">
Participants
</Text>
</Box>
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{
flex: 1,
minWidth: 80,
padding: `${config.space.S300} ${config.space.S200}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
<Text
size="H5"
style={{
fontWeight: 700,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{stats.totalCached.toLocaleString()}
</Text>
<Text size="T200" priority="300" align="Center">
Cached events
</Text>
</Box>
</Box>
</Box>
{/* ── Media shared ── */}
<Box direction="Column" gap="200">
<SectionHeader label="Media Shared" />
<Box gap="200" wrap="Wrap">
<StatTile icon={Icons.Photo} count={stats.mediaCounts.image} label="Images" />
<StatTile
icon={Icons.VideoCamera}
count={stats.mediaCounts.video}
label="Videos"
/>
<StatTile icon={Icons.Headphone} count={stats.mediaCounts.audio} label="Audio" />
<StatTile icon={Icons.File} count={stats.mediaCounts.file} label="Files" />
</Box>
</Box>
{/* ── Most active members ── */}
{stats.top5.length > 0 && (
<Box direction="Column" gap="200">
<SectionHeader label="Most Active Members" />
<Box direction="Column" gap="200">
{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 (
<Box key={userId} alignItems="Center" gap="200">
{/* Rank */}
<Text
size="T300"
priority="300"
style={{ width: 16, flexShrink: 0, textAlign: 'center' }}
>
{index + 1}
</Text>
{/* Avatar */}
<Box shrink="No">
<Avatar size="200" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={displayName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</Box>
{/* Name + bar */}
<Box
grow="Yes"
direction="Column"
gap="100"
style={{ overflow: 'hidden' }}
>
<Text size="T300" truncate style={{ lineHeight: 1 }}>
{displayName}
</Text>
<Box alignItems="Center" gap="200">
<div
style={{
height: 6,
width: barWidth,
background: color.Primary.Main,
borderRadius: config.radii.R300,
transition: 'width 0.3s ease',
flexShrink: 0,
}}
/>
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{count}
</Text>
</Box>
</Box>
</Box>
);
})}
</Box>
</Box>
)}
{/* ── Top reactions ── */}
{stats.top5Reactions.length > 0 && (
<Box direction="Column" gap="200">
<SectionHeader label="Top Reactions" />
<Box gap="200" wrap="Wrap">
{stats.top5Reactions.map(([emoji, count]) => (
<Box
key={emoji}
alignItems="Center"
gap="100"
style={{
padding: `${config.space.S100} ${config.space.S200}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
flexShrink: 0,
}}
>
<Text size="H5">{emoji}</Text>
<Text size="T300" priority="300">
{count}
</Text>
</Box>
))}
</Box>
</Box>
)}
{/* ── Activity by hour ── */}
<Box direction="Column" gap="200">
<SectionHeader label="Activity by Hour" />
<Box
direction="Column"
gap="100"
style={{
padding: config.space.S300,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
{/* Bars */}
<Box
alignItems="End"
style={{
height: 60,
gap: 2,
}}
>
{stats.hourBuckets.map((count, h) => (
<Box
key={h}
direction="Column"
alignItems="Center"
style={{ flex: 1, height: '100%', justifyContent: 'flex-end' }}
>
<div
title={`${h}:00 — ${count} message${count !== 1 ? 's' : ''}`}
style={{
width: '100%',
height: `${Math.max(2, (count / maxHour) * 48)}px`,
background:
count > 0 && count === maxHour
? color.Primary.Main
: color.SurfaceVariant.Container,
borderRadius: `${config.radii.R300} ${config.radii.R300} 0 0`,
transition: 'height 0.2s ease',
}}
/>
</Box>
))}
</Box>
{/* Hour labels: show 0, 6, 12, 18 */}
<Box style={{ gap: 0 }}>
{stats.hourBuckets.map((_, h) => (
<Box key={h} justifyContent="Center" style={{ flex: 1 }}>
{h % 6 === 0 ? (
<Text size="T200" priority="300" align="Center">
{h}
</Text>
) : null}
</Box>
))}
</Box>
</Box>
<Text size="T200" priority="300">
Hour of day (local time, 0 = midnight)
</Text>
</Box>
{/* Bottom padding */}
<Box style={{ height: config.space.S200 }} />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}