CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Audit of every rendered time found five families of ad-hoc formatting: the shared Time component + copies of its today/yesterday branch (forwarded header, thread summary, read receipts, device tile, moderation alerts, edit history), locale-default toLocale*String calls that ignored the user's 12/24 h and date-format settings (scheduled tray, reminders, schedule preview, notification snooze, bookmarks, threads list, search cache line, room insights, media gallery), a hard-coded en-US date in the activity log, and three relative-age variants. utils/formatTimestamp.ts now holds the rules — today → time; yesterday / tomorrow → day word + time; last 6 days → weekday + time; older → date + time in dateFormatString — plus autoDate / time / date / dateTime styles, formatDayDivider (full weekday), formatShortAge (room list) and formatRelativeAge (list rows). useTimestampFormatter binds them to the settings. 11 unit tests with an injected 'now'. Visible changes are limited to consistency: 12 h times keep the existing zero-padded hh:mm A; the a11y label and Created-by line use the user's date format instead of a fixed long month. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
510 lines
15 KiB
TypeScript
510 lines
15 KiB
TypeScript
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<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);
|
|
// 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: (
|
|
<>
|
|
<strong>{targetName}</strong>'s membership changed to <strong>joined</strong>
|
|
</>
|
|
),
|
|
iconSrc: Icons.User,
|
|
filter: 'members',
|
|
};
|
|
}
|
|
if (
|
|
prevMembership === 'invite' ||
|
|
prevMembership === 'knock' ||
|
|
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 (!hasPrevContent) {
|
|
return {
|
|
text: (
|
|
<>
|
|
<strong>{targetName}</strong>'s membership changed to <strong>left</strong>
|
|
</>
|
|
),
|
|
iconSrc: Icons.User,
|
|
filter: 'members',
|
|
};
|
|
}
|
|
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',
|
|
};
|
|
}
|
|
// 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',
|
|
};
|
|
}
|
|
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) {
|
|
const { prefs } = useTimestampFormatter();
|
|
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">
|
|
{formatRelativeAge(ev.getTs(), prefs)}
|
|
</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);
|
|
|
|
// [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<string>(STATE_EVENT_TYPES);
|
|
return collectTimelineEvents(detached.set.getLiveTimeline())
|
|
.filter((ev) => typeSet.has(ev.getType()) && !ev.isRedacted())
|
|
.reverse();
|
|
}, [detached]);
|
|
|
|
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(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 (
|
|
<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>
|
|
);
|
|
}
|