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>
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
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 “<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]);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user