import React, { useCallback, useEffect, useMemo, 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'; import { collectTimelineEvents, createDetachedTimelineSet, createTypesFilter, } from '../../utils/detachedTimeline'; import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; import { formatRelativeAge } from '../../utils/formatTimestamp'; // ── 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 ────────────────────────────────────────────────────── // ── 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); // getPrevContent() falls back to {} when unsigned.prev_content is missing, // which is common for state events fetched via back-pagination (homeservers // don't always include it in /messages). That makes prevMembership // indistinguishable from "no prior membership" (fresh join). Check the raw // unsigned field so we only apply the join/leave-transition assumptions // below to events where the SDK actually gave us prior state. const hasPrevContent = ev.getUnsigned().prev_content !== undefined; if (membership === 'join') { if (!hasPrevContent) { return { text: ( <> {targetName}'s membership changed to joined ), iconSrc: Icons.User, filter: 'members', }; } if ( prevMembership === 'invite' || prevMembership === 'knock' || 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 (!hasPrevContent) { return { text: ( <> {targetName}'s membership changed to left ), iconSrc: Icons.User, filter: 'members', }; } 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', }; } // 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: ( <> {senderName} withdrew the invite to {targetName} ), 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) { const { prefs } = useTimestampFormatter(); return ( {desc.text} {formatRelativeAge(ev.getTs(), prefs)} ); } // ── 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); // [Gitea #163] Page through a detached, type-filtered timeline set: the // live timeline (rendered by index behind this modal) must not be mutated. const detached = useMemo( () => createDetachedTimelineSet(mx, room, { filter: createTypesFilter(mx.getSafeUserId(), STATE_EVENT_TYPES, 'io.lotus.activity'), filterSafeWhenEncrypted: true, }), [mx, room], ); const getStateEvents = useCallback((): MatrixEvent[] => { const typeSet = new Set(STATE_EVENT_TYPES); return collectTimelineEvents(detached.set.getLiveTimeline()) .filter((ev) => typeSet.has(ev.getType()) && !ev.isRedacted()) .reverse(); }, [detached]); const [events, setEvents] = useState(() => getStateEvents()); useEffect(() => { setEvents(getStateEvents()); }, [getStateEvents]); const handleLoadMore = useCallback(async () => { if (loading || !canLoadMore) return; setLoading(true); try { const hasMore = await mx.paginateEventTimeline(detached.set.getLiveTimeline(), { backwards: true, limit: 50, }); setEvents(getStateEvents()); setCanLoadMore(hasMore); setHasLoadedOnce(true); } catch { // silently fail — user can retry } finally { setLoading(false); } }, [loading, canLoadMore, mx, detached, 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 }, []); // 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 )} ); }