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, 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, ev: MatrixEvent): EventDesc | null { const type = ev.getType() as StateEventType; const sender = ev.getSender() ?? ''; const content = ev.getContent>(); const prevContent = ev.getPrevContent() as Record; 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: ( <> {targetName} joined ), iconSrc: Icons.User, filter: 'members', }; } // prevMembership === 'join' → profile update return { text: ( <> {targetName} updated their profile ), iconSrc: Icons.User, filter: 'members', }; } if (membership === 'leave') { if (prevMembership === 'ban') { return { text: ( <> {targetName} was unbanned by {senderName} ), iconSrc: Icons.User, filter: 'members', }; } if (sender === stateKey) { return { text: ( <> {targetName} left ), iconSrc: Icons.User, filter: 'members', }; } return { text: ( <> {targetName} was kicked by {senderName} {reason ? ` (${reason})` : ''} ), iconSrc: Icons.User, filter: 'members', }; } if (membership === 'ban') { return { text: ( <> {targetName} was banned by {senderName} {reason ? ` (${reason})` : ''} ), iconSrc: Icons.User, filter: 'members', }; } if (membership === 'invite') { return { text: ( <> {targetName} was invited by {senderName} ), iconSrc: Icons.UserPlus, filter: 'members', }; } return null; } case 'm.room.power_levels': return { text: ( <> Power levels updated by {senderName} ), iconSrc: Icons.ShieldUser, filter: 'power', }; case 'm.room.name': { const newName = content.name as string | undefined; return { text: ( <> Room renamed to “{newName ?? ''}” by{' '} {senderName} ), iconSrc: Icons.Pencil, filter: 'room', }; } case 'm.room.topic': return { text: ( <> Topic updated by {senderName} ), iconSrc: Icons.Pencil, filter: 'room', }; case 'm.room.avatar': return { text: ( <> Room avatar changed by {senderName} ), iconSrc: Icons.Photo, filter: 'room', }; case 'm.room.server_acl': return { text: ( <> Server ACL updated by {senderName} ), 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 ( ); } // ── Log entry ───────────────────────────────────────────────────────────────── type LogEntryProps = { ev: MatrixEvent; desc: EventDesc; }; function LogEntry({ ev, desc }: LogEntryProps) { return ( {desc.text} {formatRelativeTs(ev.getTs())} ); } // ── Main component ──────────────────────────────────────────────────────────── type RoomActivityLogProps = { requestClose: () => void; }; export function RoomActivityLog({ requestClose }: RoomActivityLogProps) { const mx = useMatrixClient(); const room = useRoom(); const [filter, setFilter] = useState('all'); const [loading, setLoading] = useState(false); const [hasLoadedOnce, setHasLoadedOnce] = useState(false); const [canLoadMore, setCanLoadMore] = useState(true); const getStateEvents = useCallback((): MatrixEvent[] => { const typeSet = new Set(STATE_EVENT_TYPES); return room .getLiveTimeline() .getEvents() .filter((ev) => typeSet.has(ev.getType()) && !ev.isRedacted()) .slice() .reverse(); }, [room]); const [events, setEvents] = useState(() => getStateEvents()); useEffect(() => { setEvents(getStateEvents()); }, [getStateEvents]); // 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 }, []); 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 ( Activity Log {/* Filter chips */} {( [ { 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 }) => ( setFilter(key)} /> ))} {/* Loading skeleton on first load */} {loading && !hasLoadedOnce && ( )} {/* Empty state */} {!loading && entries.length === 0 && ( No room activity found )} {/* Log entries */} {entries.map(({ ev, desc }) => ( ))} {/* Load more */} {canLoadMore && !loading && ( )} {/* Inline spinner while paginating */} {loading && hasLoadedOnce && ( )} {/* End of history */} {!canLoadMore && hasLoadedOnce && ( Beginning of history )} ); }