diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 8412d0c48..cfef9b71a 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -41,6 +41,7 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { useMatrixClient } from '../hooks/useMatrixClient'; import CallSound from '../../../public/sound/call.ogg'; import { useCallMembersChange, useCallSession } from '../hooks/useCall'; +import { useRemoteAllMuted } from '../hooks/useCallSpeakers'; import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta'; import { mDirectAtom } from '../state/mDirectList'; import { useMediaAuthentication } from '../hooks/useMediaAuthentication'; @@ -402,6 +403,37 @@ function CallUtils({ embed }: { embed: CallEmbed }) { return null; } +/** Shown inside the PiP window when the local microphone is muted. */ +function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) { + const allMuted = useRemoteAllMuted(callEmbed); + if (!allMuted) return null; + return ( +
+ +
+ ); +} + type CallEmbedProviderProps = { children?: ReactNode; }; @@ -803,6 +835,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { ↗ Return to call + {(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => { const s = corner.includes('s'); const e2 = corner.includes('e'); diff --git a/src/app/features/room-settings/ExportRoomHistory.tsx b/src/app/features/room-settings/ExportRoomHistory.tsx new file mode 100644 index 000000000..2fcebda70 --- /dev/null +++ b/src/app/features/room-settings/ExportRoomHistory.tsx @@ -0,0 +1,329 @@ +import React, { useCallback, useState } from 'react'; +import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, config, color } from 'folds'; +import { EventType } from 'matrix-js-sdk'; +import { Page, PageContent, PageHeader } from '../../components/page'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useRoom } from '../../hooks/useRoom'; +import { useRoomName } from '../../hooks/useRoomMeta'; +import { SequenceCard } from '../../components/sequence-card'; +import { SequenceCardStyle } from '../common-settings/styles.css'; + +type ExportFormat = 'txt' | 'json' | 'html'; + +const FORMAT_LABELS: Record = { + txt: 'Plain Text', + json: 'JSON', + html: 'HTML', +}; + +type ExportRoomHistoryProps = { + requestClose: () => void; +}; + +export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const roomName = useRoomName(room); + + const [format, setFormat] = useState('txt'); + const [fromDate, setFromDate] = useState(''); + const [toDate, setToDate] = useState(''); + const [exporting, setExporting] = useState(false); + const [exportCount, setExportCount] = useState(0); + + const handleExport = useCallback(async () => { + if (exporting) return; + setExporting(true); + setExportCount(0); + + try { + const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null; + const toTs = toDate ? new Date(`${toDate}T23:59:59`).getTime() : null; + + type MsgRecord = { + ts: number; + sender: string; + body: string; + eventId: string; + msgtype: string; + }; + + const collected: MsgRecord[] = []; + const timeline = room.getLiveTimeline(); + let canLoadMore = true; + + // Collect events already in the live timeline + const addEvents = (events: ReturnType) => { + for (const ev of events) { + if (ev.getType() !== EventType.RoomMessage) continue; + if (ev.isDecryptionFailure()) continue; + const ts = ev.getTs(); + if (fromTs !== null && ts < fromTs) continue; + if (toTs !== null && ts > toTs) continue; + const content = ev.getContent(); + const body: string = content.body ?? ''; + const msgtype: string = content.msgtype ?? ''; + if (!body) continue; + collected.push({ + ts, + sender: ev.getSender() ?? '', + body, + eventId: ev.getId() ?? '', + msgtype, + }); + } + setExportCount(collected.length); + }; + + addEvents(timeline.getEvents()); + + // Paginate backwards until start or date range exceeded + while (canLoadMore) { + // If we have a fromTs, check whether the oldest collected event is already + // before it — if so we don't need to paginate further. + if (fromTs !== null && collected.length > 0) { + const oldestTs = Math.min(...collected.map((r) => r.ts)); + if (oldestTs < fromTs) break; + } + + // eslint-disable-next-line no-await-in-loop + canLoadMore = await mx.paginateEventTimeline(timeline, { + backwards: true, + limit: 100, + }); + + addEvents(timeline.getEvents()); + } + + // Sort chronologically (oldest first) + collected.sort((a, b) => a.ts - b.ts); + + const exportedAt = new Date().toISOString(); + const dateStr = exportedAt.slice(0, 10); + const safeRoomName = roomName.replace(/[^a-z0-9_-]/gi, '_').toLowerCase(); + + let content = ''; + let mimeType = 'text/plain'; + let ext: string = format; + + if (format === 'txt') { + const lines: string[] = [ + `# Export: ${roomName}`, + `# Exported: ${exportedAt}`, + `# Messages: ${collected.length}`, + '', + ]; + for (const msg of collected) { + const d = new Date(msg.ts); + const pad = (n: number) => String(n).padStart(2, '0'); + const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + lines.push(`[${dateLabel}] ${msg.sender}: ${msg.body}`); + } + content = lines.join('\n'); + mimeType = 'text/plain'; + ext = 'txt'; + } else if (format === 'json') { + const payload = { + room: roomName, + exportedAt, + messages: collected.map((m) => ({ + ts: m.ts, + sender: m.sender, + body: m.body, + eventId: m.eventId, + type: m.msgtype, + })), + }; + content = JSON.stringify(payload, null, 2); + mimeType = 'application/json'; + ext = 'json'; + } else { + // HTML + const esc = (s: string) => + s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + const msgRows = collected + .map((msg) => { + const d = new Date(msg.ts); + const pad = (n: number) => String(n).padStart(2, '0'); + const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + return `
[${esc(dateLabel)}] ${esc(msg.sender)}: ${esc(msg.body)}
`; + }) + .join('\n'); + + content = ` +Export: ${esc(roomName)} + +

Room: ${esc(roomName)}

+

Exported ${esc(exportedAt)} — ${collected.length} messages

+
+${msgRows} +
`; + mimeType = 'text/html'; + ext = 'html'; + } + + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `export-${safeRoomName}-${dateStr}.${ext}`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setExporting(false); + } + }, [exporting, format, fromDate, toDate, mx, room, roomName]); + + return ( + + + + + + Export + + + + + + + + + + + + + + {/* Format */} + + Format + + + {(Object.keys(FORMAT_LABELS) as ExportFormat[]).map((f) => ( + + ))} + + + + + {/* Date range */} + + Date Range (optional) + + + + From + setFromDate(e.target.value)} + style={{ + background: color.Surface.Container, + color: color.Surface.OnContainer, + border: `1px solid ${color.Surface.ContainerLine}`, + borderRadius: config.radii.R300, + padding: `${config.space.S200} ${config.space.S300}`, + fontSize: 'inherit', + fontFamily: 'inherit', + width: '100%', + boxSizing: 'border-box', + }} + /> + + + To + setToDate(e.target.value)} + style={{ + background: color.Surface.Container, + color: color.Surface.OnContainer, + border: `1px solid ${color.Surface.ContainerLine}`, + borderRadius: config.radii.R300, + padding: `${config.space.S200} ${config.space.S300}`, + fontSize: 'inherit', + fontFamily: 'inherit', + width: '100%', + boxSizing: 'border-box', + }} + /> + + + + Leave blank to export all available history. + + + + + {/* Export */} + + Download + + + + {exporting + ? `Exporting… ${exportCount} messages` + : 'Export will download automatically.'} + + + + + + + + + + + ); +} diff --git a/src/app/features/room-settings/RoomActivityLog.tsx b/src/app/features/room-settings/RoomActivityLog.tsx new file mode 100644 index 000000000..70a342777 --- /dev/null +++ b/src/app/features/room-settings/RoomActivityLog.tsx @@ -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, 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]); + + 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 + + )} + + + + + + ); +} diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index 7a0b452f7..5b98bf033 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -17,6 +17,8 @@ import { Permissions } from './permissions'; import { RoomSettingsPage } from '../../state/roomSettings'; import { useRoom } from '../../hooks/useRoom'; import { DeveloperTools } from '../common-settings/developer-tools'; +import { ExportRoomHistory } from './ExportRoomHistory'; +import { RoomActivityLog } from './RoomActivityLog'; type RoomSettingsMenuItem = { page: RoomSettingsPage; @@ -52,6 +54,16 @@ const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] => name: 'Developer Tools', icon: Icons.Terminal, }, + { + page: RoomSettingsPage.ExportPage, + name: 'Export', + icon: Icons.Download, + }, + { + page: RoomSettingsPage.ActivityLogPage, + name: 'Activity', + icon: Icons.RecentClock, + }, ], [], ); @@ -172,6 +184,12 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) { {activePage === RoomSettingsPage.DeveloperToolsPage && ( )} + {activePage === RoomSettingsPage.ExportPage && ( + + )} + {activePage === RoomSettingsPage.ActivityLogPage && ( + + )} ); } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 142945555..56a445725 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -26,6 +26,7 @@ import { Scroll, Spinner, Text, + color, config, toRem, } from 'folds'; @@ -122,6 +123,7 @@ import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder'; import { PollCreator } from './PollCreator'; +import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus'; const GifPicker = React.lazy(() => import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })), @@ -144,6 +146,15 @@ export const RoomInput = forwardRef( const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); + const [warnOnUnverifiedDevices] = useSetting(settingsAtom, 'warnOnUnverifiedDevices'); + const crypto = mx.getCrypto(); + const roomUnverifiedDeviceCount = useRoomUnverifiedDeviceCount(crypto, room); + const isEncrypted = room.hasEncryptionStateEvent(); + const showUnverifiedWarning = + warnOnUnverifiedDevices && + isEncrypted && + roomUnverifiedDeviceCount !== undefined && + roomUnverifiedDeviceCount > 0; const direct = useIsDirectRoom(); const commands = useCommands(mx, room); const emojiBtnRef = useRef(null); @@ -718,6 +729,27 @@ export const RoomInput = forwardRef( requestClose={handleCloseAutocomplete} /> )} + {showUnverifiedWarning && ( + + + + {roomUnverifiedDeviceCount}{' '} + {roomUnverifiedDeviceCount === 1 ? 'unverified device' : 'unverified devices'} in this + room + + + )} @@ -896,6 +900,19 @@ function Privacy() { after={} /> + + + } + /> + ); } diff --git a/src/app/hooks/useCallSpeakers.ts b/src/app/hooks/useCallSpeakers.ts index 6360087a0..013e5f9de 100644 --- a/src/app/hooks/useCallSpeakers.ts +++ b/src/app/hooks/useCallSpeakers.ts @@ -58,3 +58,80 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { return speakers; }; + +/** + * Returns true when the local user's microphone is muted in the Element Call + * iframe. The state is read directly from the EC mute button DOM using the + * same MutationObserver / `data-kind` pattern that CallControl.ts uses for the + * screenshare button: + * + * [data-testid="incall_mute"][data-kind="primary"] → mic is muted + * [data-testid="incall_mute"][data-kind="secondary"] → mic is active + * + * This is used by the PiP overlay so the viewer can see at a glance whether + * their microphone is muted while navigated away from the call room. + */ +export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => { + const [muted, setMuted] = useState(false); + + useEffect(() => { + if (!callEmbed) return undefined; + + const getDoc = (): Document | undefined => + callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined; + + const getMuteBtn = (): HTMLElement | null => + getDoc()?.querySelector('[data-testid="incall_mute"]') ?? null; + + /** Read the current button state and update React state. */ + const syncState = (): void => { + const btn = getMuteBtn(); + // data-kind="primary" means the button is in its "active/on" primary style, + // which EC uses for muted state (consistent with screenshare active = primary). + setMuted(btn?.getAttribute('data-kind') === 'primary'); + }; + + let observer: MutationObserver | undefined; + + const attachObserver = (): void => { + const btn = getMuteBtn(); + if (!btn) return; + + observer?.disconnect(); + observer = new MutationObserver(syncState); + observer.observe(btn, { + attributes: true, + attributeFilter: ['data-kind'], + }); + // Read the current state immediately once we have a button to observe. + syncState(); + }; + + // If the button is already present, start observing immediately. + attachObserver(); + + // If not yet present (iframe still loading), watch the document body for + // the button to appear — mirrors the styleRetryObserver pattern in CallEmbed. + let bodyObserver: MutationObserver | undefined; + if (!getMuteBtn()) { + const doc = getDoc(); + if (doc) { + bodyObserver = new MutationObserver(() => { + if (getMuteBtn()) { + bodyObserver?.disconnect(); + bodyObserver = undefined; + attachObserver(); + } + }); + bodyObserver.observe(doc.body, { childList: true, subtree: true }); + } + } + + return () => { + observer?.disconnect(); + bodyObserver?.disconnect(); + }; + }, [callEmbed]); + + return muted; +}; diff --git a/src/app/hooks/useDeviceVerificationStatus.ts b/src/app/hooks/useDeviceVerificationStatus.ts index 01011b4f6..c17e395fa 100644 --- a/src/app/hooks/useDeviceVerificationStatus.ts +++ b/src/app/hooks/useDeviceVerificationStatus.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Room } from 'matrix-js-sdk'; import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api'; import { verifiedDevice } from '../utils/matrix-crypto'; import { useAlive } from './useAlive'; @@ -104,3 +105,62 @@ export const useUnverifiedDeviceCount = ( return unverifiedCount; }; + +export const useRoomUnverifiedDeviceCount = ( + crypto: CryptoApi | undefined, + room: Room, +): number | undefined => { + const [unverifiedCount, setUnverifiedCount] = useState(); + const alive = useAlive(); + + const memberIds = useMemo( + () => room.getJoinedMembers().map((m) => m.userId), + // eslint-disable-next-line react-hooks/exhaustive-deps + [room.roomId], + ); + + const updateCount = useCallback(async () => { + if (!crypto) return; + + const deviceMap = await crypto.getUserDeviceInfo(memberIds); + let count = 0; + + const allChecks: Promise[] = []; + + deviceMap.forEach((devices, userId) => { + devices.forEach((_device, deviceId) => { + allChecks.push(verifiedDevice(crypto, userId, deviceId)); + }); + }); + + const results = await Promise.allSettled(allChecks); + const settled = fulfilledPromiseSettledResult(results); + settled.forEach((status) => { + if (status === false) { + count += 1; + } + }); + + if (alive()) { + setUnverifiedCount(count); + } + }, [crypto, memberIds, alive]); + + useDeviceListChange( + useCallback( + (userIds) => { + const affected = userIds.some((uid) => memberIds.includes(uid)); + if (affected) { + updateCount(); + } + }, + [memberIds, updateCount], + ), + ); + + useEffect(() => { + updateCount(); + }, [updateCount]); + + return unverifiedCount; +}; diff --git a/src/app/state/roomSettings.ts b/src/app/state/roomSettings.ts index 327db596e..0afbf1e2f 100644 --- a/src/app/state/roomSettings.ts +++ b/src/app/state/roomSettings.ts @@ -6,6 +6,8 @@ export enum RoomSettingsPage { PermissionsPage, EmojisStickersPage, DeveloperToolsPage, + ExportPage, + ActivityLogPage, } export type RoomSettingsState = { diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index ff8d8ae6e..0017f525b 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -90,6 +90,8 @@ export interface Settings { nightLightOpacity: number; deafenKey: string; + + warnOnUnverifiedDevices: boolean; } const defaultSettings: Settings = { @@ -149,6 +151,8 @@ const defaultSettings: Settings = { nightLightOpacity: 30, deafenKey: 'KeyM', + + warnOnUnverifiedDevices: false, }; export const getSettings = (): Settings => {