2026-06-03 22:13:22 -04:00
|
|
|
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' ||
|
2026-07-02 21:40:07 -04:00
|
|
|
prevMembership === 'knock' ||
|
2026-06-03 22:13:22 -04:00
|
|
|
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',
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-07-02 21:40:07 -04:00
|
|
|
// sender !== stateKey and the target was only invited → the inviter (or a
|
|
|
|
|
// moderator) retracted the invite; this is not a kick.
|
|
|
|
|
if (prevMembership === 'invite') {
|
|
|
|
|
return {
|
|
|
|
|
text: (
|
|
|
|
|
<>
|
|
|
|
|
<strong>{senderName}</strong> withdrew the invite to <strong>{targetName}</strong>
|
|
|
|
|
</>
|
|
|
|
|
),
|
|
|
|
|
iconSrc: Icons.User,
|
|
|
|
|
filter: 'members',
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-06-03 22:13:22 -04:00
|
|
|
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 “<strong>{newName ?? ''}</strong>” 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]);
|
|
|
|
|
|
2026-06-04 15:51:18 -04:00
|
|
|
// Auto-paginate on mount — state events are rarely in the initial sync
|
|
|
|
|
// window, so we immediately fetch backwards to populate the log.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
handleLoadMore();
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-06-03 22:13:22 -04:00
|
|
|
// 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>
|
|
|
|
|
);
|
|
|
|
|
}
|