dcd8201e16
Wave-3 bug-hunt fixes (findings in LOTUS_TODO), reviewed + gate-green: - 🔴 ACL editor [H1–H4]: block saving an empty allow-list (was a one-click federation brick), warn on self-ban (case-insensitive glob match of mx.getDomain() vs allow/deny), accept real globs (1.2.3.*, *.evil.*), and gate Save behind a confirm dialog. - 🔴 [P1] room context menu no longer acts on the wrong room after a live reorder (key by roomId, not list index). 🔴 [P2] status writes no longer force presence to online over Invisible/DND (shared presenceStateFromSetting). - 🟠 [P3] timed mutes restored on boot; [P4] custom-status auto-clear now fires (always-mounted StatusExpiryMonitor); [P5] timezone also PUT to the m.tz profile field so it's visible to others; [H6] RoomInsights single-pass min/max (was Math.min(...spread) stack overflow); [H7/H8] mod-log labels. - 🟡 [P6/P7] favorites collapse+filter, [P8] charCount reset, [P9] DM preview refresh on decrypt; theming [T-P1] lazy decorations, [T-P2] drop the redundant always-on body animation, [T-P4] live useReducedMotion, [T-P5] decoration key. - NATIVE-CINNY LAW: notification presets + Powers permissions use folds icons. DEFERRED: [H5] invite-QR is fetched from api.qrserver.com (third-party leak); local generation needs a bundled QR lib (not added). tsc/eslint/prettier clean, build OK, 677 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
465 lines
19 KiB
TypeScript
465 lines
19 KiB
TypeScript
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;
|
|
|
|
// 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,
|
|
};
|
|
}, [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>
|
|
);
|
|
}
|