CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Audit of every rendered time found five families of ad-hoc formatting: the shared Time component + copies of its today/yesterday branch (forwarded header, thread summary, read receipts, device tile, moderation alerts, edit history), locale-default toLocale*String calls that ignored the user's 12/24 h and date-format settings (scheduled tray, reminders, schedule preview, notification snooze, bookmarks, threads list, search cache line, room insights, media gallery), a hard-coded en-US date in the activity log, and three relative-age variants. utils/formatTimestamp.ts now holds the rules — today → time; yesterday / tomorrow → day word + time; last 6 days → weekday + time; older → date + time in dateFormatString — plus autoDate / time / date / dateTime styles, formatDayDivider (full weekday), formatShortAge (room list) and formatRelativeAge (list rows). useTimestampFormatter binds them to the settings. 11 unit tests with an injected 'now'. Visible changes are limited to consistency: 12 h times keep the existing zero-padded hh:mm A; the a11y label and Created-by line use the user's date format instead of a fixed long month. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
526 lines
21 KiB
TypeScript
526 lines
21 KiB
TypeScript
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';
|
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
// 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 (
|
|
<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 { format } = useTimestampFormatter();
|
|
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<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 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;
|
|
|
|
// 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 (
|
|
<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 grow="Yes" 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 {format(stats.oldestTs, 'date')} to {format(stats.newestTs, 'date')}
|
|
</Text>
|
|
)}
|
|
<Text size="T200" priority="300">
|
|
Last updated {format(lastUpdated, 'time')}
|
|
</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>
|
|
</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 = 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 (
|
|
<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>
|
|
);
|
|
}
|