Files
cinny/src/app/features/room-settings/RoomActivityLog.tsx
T
jared ee717e8361 feat: PiP mute indicator, export history, activity log, unverified device warning
- PiP call window: mute overlay using MutationObserver on EC iframe's
  [data-testid="incall_mute"] button (data-kind="primary" = muted),
  same pattern as screenshare detection in CallControl.ts

- P2-4 Export Room History: new tab in room settings — Plain Text / JSON /
  HTML formats, optional date range, progress counter, paginated via
  paginateEventTimeline, blob download; E2EE-aware (skips failed decryptions)

- P2-6 Room Activity Log: new tab in room settings — filterable log of
  m.room.member, m.room.power_levels, m.room.name/topic/avatar/server_acl
  events with human-readable descriptions, relative timestamps, Load More
  pagination

- P2-10 Unverified Device Warning: warnOnUnverifiedDevices setting (default
  off); Warning.Container banner above composer in encrypted rooms with
  unverified devices; toggle in Settings → General → Privacy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:13:22 -04:00

458 lines
13 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color, config } from 'folds';
import { MatrixEvent } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
// ── Types ─────────────────────────────────────────────────────────────────────
type ActivityFilter = 'all' | 'members' | 'power' | 'room';
const STATE_EVENT_TYPES = [
'm.room.member',
'm.room.power_levels',
'm.room.name',
'm.room.topic',
'm.room.avatar',
'm.room.server_acl',
] as const;
type StateEventType = (typeof STATE_EVENT_TYPES)[number];
// ── Timestamp formatting ──────────────────────────────────────────────────────
function formatRelativeTs(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
const d = new Date(ts);
const sameYear = d.getFullYear() === new Date().getFullYear();
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
...(sameYear ? {} : { year: 'numeric' }),
});
}
// ── Event description ─────────────────────────────────────────────────────────
function getDisplayName(mx: ReturnType<typeof useMatrixClient>, userId: string): string {
return mx.getUser(userId)?.displayName ?? userId;
}
type EventDesc = {
text: React.ReactNode;
iconSrc: (typeof Icons)[keyof typeof Icons];
filter: ActivityFilter;
};
function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent): EventDesc | null {
const type = ev.getType() as StateEventType;
const sender = ev.getSender() ?? '';
const content = ev.getContent<Record<string, unknown>>();
const prevContent = ev.getPrevContent() as Record<string, unknown>;
const senderName = getDisplayName(mx, sender);
const stateKey = ev.getStateKey() ?? '';
switch (type) {
case 'm.room.member': {
const membership = content.membership as string | undefined;
const prevMembership = prevContent.membership as string | undefined;
const reason = content.reason as string | undefined;
const targetName = getDisplayName(mx, stateKey);
if (membership === 'join') {
if (
prevMembership === 'invite' ||
prevMembership === undefined ||
prevMembership === null
) {
return {
text: (
<>
<strong>{targetName}</strong> joined
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
// prevMembership === 'join' → profile update
return {
text: (
<>
<strong>{targetName}</strong> updated their profile
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if (membership === 'leave') {
if (prevMembership === 'ban') {
return {
text: (
<>
<strong>{targetName}</strong> was unbanned by <strong>{senderName}</strong>
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if (sender === stateKey) {
return {
text: (
<>
<strong>{targetName}</strong> left
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
return {
text: (
<>
<strong>{targetName}</strong> was kicked by <strong>{senderName}</strong>
{reason ? ` (${reason})` : ''}
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if (membership === 'ban') {
return {
text: (
<>
<strong>{targetName}</strong> was banned by <strong>{senderName}</strong>
{reason ? ` (${reason})` : ''}
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if (membership === 'invite') {
return {
text: (
<>
<strong>{targetName}</strong> was invited by <strong>{senderName}</strong>
</>
),
iconSrc: Icons.UserPlus,
filter: 'members',
};
}
return null;
}
case 'm.room.power_levels':
return {
text: (
<>
Power levels updated by <strong>{senderName}</strong>
</>
),
iconSrc: Icons.ShieldUser,
filter: 'power',
};
case 'm.room.name': {
const newName = content.name as string | undefined;
return {
text: (
<>
Room renamed to &ldquo;<strong>{newName ?? ''}</strong>&rdquo; by{' '}
<strong>{senderName}</strong>
</>
),
iconSrc: Icons.Pencil,
filter: 'room',
};
}
case 'm.room.topic':
return {
text: (
<>
Topic updated by <strong>{senderName}</strong>
</>
),
iconSrc: Icons.Pencil,
filter: 'room',
};
case 'm.room.avatar':
return {
text: (
<>
Room avatar changed by <strong>{senderName}</strong>
</>
),
iconSrc: Icons.Photo,
filter: 'room',
};
case 'm.room.server_acl':
return {
text: (
<>
Server ACL updated by <strong>{senderName}</strong>
</>
),
iconSrc: Icons.Shield,
filter: 'room',
};
default:
return null;
}
}
// ── Filter chip ───────────────────────────────────────────────────────────────
type FilterChipProps = {
label: string;
active: boolean;
onClick: () => void;
};
function FilterChip({ label, active, onClick }: FilterChipProps) {
return (
<Button
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Soft' : 'None'}
radii="300"
onClick={onClick}
>
<Text size="B300">{label}</Text>
</Button>
);
}
// ── Log entry ─────────────────────────────────────────────────────────────────
type LogEntryProps = {
ev: MatrixEvent;
desc: EventDesc;
};
function LogEntry({ ev, desc }: LogEntryProps) {
return (
<Box
alignItems="Center"
gap="300"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
style={{
width: 32,
height: 32,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
flexShrink: 0,
}}
>
<Icon src={desc.iconSrc} size="200" />
</Box>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden', gap: 2 }}>
<Text size="T300" style={{ lineHeight: 1.4 }}>
{desc.text}
</Text>
<Text size="T200" priority="300">
{formatRelativeTs(ev.getTs())}
</Text>
</Box>
</Box>
);
}
// ── Main component ────────────────────────────────────────────────────────────
type RoomActivityLogProps = {
requestClose: () => void;
};
export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
const mx = useMatrixClient();
const room = useRoom();
const [filter, setFilter] = useState<ActivityFilter>('all');
const [loading, setLoading] = useState(false);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true);
const getStateEvents = useCallback((): MatrixEvent[] => {
const typeSet = new Set<string>(STATE_EVENT_TYPES);
return room
.getLiveTimeline()
.getEvents()
.filter((ev) => typeSet.has(ev.getType()) && !ev.isRedacted())
.slice()
.reverse();
}, [room]);
const [events, setEvents] = useState<MatrixEvent[]>(() => getStateEvents());
useEffect(() => {
setEvents(getStateEvents());
}, [getStateEvents]);
const handleLoadMore = useCallback(async () => {
if (loading || !canLoadMore) return;
setLoading(true);
try {
const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), {
backwards: true,
limit: 50,
});
setEvents(getStateEvents());
setCanLoadMore(hasMore);
setHasLoadedOnce(true);
} catch {
// silently fail — user can retry
} finally {
setLoading(false);
}
}, [loading, canLoadMore, mx, room, getStateEvents]);
// Build described entries
const entries: Array<{ ev: MatrixEvent; desc: EventDesc }> = [];
for (const ev of events) {
const desc = describeEvent(mx, ev);
if (!desc) continue;
if (filter !== 'all' && desc.filter !== filter) continue;
entries.push({ ev, desc });
}
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text as="h2" size="H3" truncate>
Activity Log
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
{/* Filter chips */}
<Box
shrink="No"
gap="100"
wrap="Wrap"
style={{ padding: `${config.space.S200} ${config.space.S300}` }}
>
{(
[
{ key: 'all', label: 'All' },
{ key: 'members', label: 'Members' },
{ key: 'power', label: 'Power' },
{ key: 'room', label: 'Room changes' },
] as { key: ActivityFilter; label: string }[]
).map(({ key, label }) => (
<FilterChip
key={key}
label={label}
active={filter === key}
onClick={() => setFilter(key)}
/>
))}
</Box>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="200">
{/* Loading skeleton on first load */}
{loading && !hasLoadedOnce && (
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
<Spinner />
</Box>
)}
{/* Empty state */}
{!loading && entries.length === 0 && (
<Box
direction="Column"
alignItems="Center"
gap="200"
style={{ padding: config.space.S600 }}
>
<Icon src={Icons.RecentClock} size="600" />
<Text size="T300" priority="300" align="Center">
No room activity found
</Text>
</Box>
)}
{/* Log entries */}
{entries.map(({ ev, desc }) => (
<LogEntry key={ev.getId()} ev={ev} desc={desc} />
))}
{/* Load more */}
{canLoadMore && !loading && (
<Box justifyContent="Center" style={{ paddingTop: config.space.S200 }}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleLoadMore}
>
<Text size="B300">Load more</Text>
</Button>
</Box>
)}
{/* Inline spinner while paginating */}
{loading && hasLoadedOnce && (
<Box justifyContent="Center" style={{ padding: config.space.S300 }}>
<Spinner />
</Box>
)}
{/* End of history */}
{!canLoadMore && hasLoadedOnce && (
<Text
size="T200"
priority="300"
align="Center"
style={{ padding: `${config.space.S200} 0` }}
>
Beginning of history
</Text>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}