import { useQuery } from '@tanstack/react-query'; import { ArrowLeftIcon, ChevronRightIcon, ExternalLinkIcon, } from 'lucide-react'; import { useState } from 'react'; import { AppTile } from '#/components/atoms/app-tile.jsx'; import { Badge } from '#/components/atoms/badge.jsx'; import { Button, buttonVariants } from '#/components/atoms/button.jsx'; import { Heading } from '#/components/atoms/heading.jsx'; import { Link } from '#/components/atoms/link.jsx'; import { Panel } from '#/components/atoms/panel.jsx'; import { ErrorNotice } from '#/components/molecules/error-notice.jsx'; import { ListRow } from '#/components/molecules/list-row.jsx'; import { PublishedCheck } from '#/components/molecules/published-check.jsx'; import { ScopeList } from '#/components/molecules/scope-list.jsx'; import { SearchField } from '#/components/molecules/search-field.jsx'; import { ShowMore } from '#/components/molecules/show-more.jsx'; import { TextLink } from '#/components/molecules/text-link.jsx'; import { get, post } from '#/lib/api.js'; import { clientHost, day, groupByApp, num, spacesForApp, timeAgo, } from '#/lib/format.js'; import { queryKeys, useApiMutation } from '#/lib/query.js'; import { formatUserAgent } from '#/lib/userAgent.js'; import { cn } from '#/lib/utils.js'; const RECORD_TYPES_SHOWN = 8; const SESSIONS_SHOWN = 4; const COLLECTION_SEARCH_THRESHOLD = 6; export function AppPage({ data, domain, collectionHref, onNavigate, onBack }) { const { data: appsData, error } = useQuery({ queryKey: queryKeys.apps, queryFn: () => get('/apps'), }); const spacesEnabled = Boolean(data.features?.spaces); const { data: spacesData } = useQuery({ queryKey: queryKeys.spaces, queryFn: () => get('/spaces'), enabled: spacesEnabled, }); const action = useApiMutation([ queryKeys.apps, queryKeys.sessions, queryKeys.overview, ]); // A row the user folded into another resolves to the row that absorbed it, // so a stale link or Home card still lands on the combined entry. const aliases = appsData?.aliases || {}; const shown = aliases[domain] || domain; const folded = Object.keys(aliases).filter((from) => aliases[from] === shown); const groups = groupByApp(data.collections); const own = groups.find((g) => g.domain === shown); const foldedGroups = groups.filter((g) => folded.includes(g.domain)); // Folded domains that brought no records are client hosts (a subdomain // sign-in folded into the apex it serves). const foldedClients = folded.filter( (d) => !foldedGroups.some((g) => g.domain === d), ); const collections = [ ...(own?.collections || []), ...foldedGroups.flatMap((g) => g.collections), ].sort((a, b) => b.count - a.count); const total = (own?.count || 0) + foldedGroups.reduce((sum, g) => sum + g.count, 0); // Which of these collections' lexicons are published (resolvable) via the // protocol. Loaded separately from the overview so the list renders instantly // and the checks fill in as resolution completes. const collectionNames = collections.map((c) => c.name); const { data: lexiconStatus } = useQuery({ queryKey: queryKeys.lexiconStatus(collectionNames), queryFn: () => get( `/lexicon-status?collections=${encodeURIComponent(collectionNames.join(','))}`, ), enabled: collectionNames.length > 0, staleTime: 5 * 60_000, }); const published = lexiconStatus?.published || {}; const rowDomains = [shown, ...folded]; const sessions = appsData?.apps.filter((app) => rowDomains.includes(clientHost(app.clientId)), ) || []; const spaces = spacesForApp(spacesData?.spaces, rowDomains); const [showAllRecords, setShowAllRecords] = useState(false); const [showAllSessions, setShowAllSessions] = useState(false); const [collectionQuery, setCollectionQuery] = useState(''); const needle = collectionQuery.trim().toLowerCase(); const matchedCollections = needle ? collections.filter((collection) => collection.name.toLowerCase().includes(needle), ) : collections; const shownCollections = showAllRecords ? matchedCollections : matchedCollections.slice(0, RECORD_TYPES_SHOWN); const shownSessions = showAllSessions ? sessions : sessions.slice(0, SESSIONS_SHOWN); const revoke = (sessionId) => action.run(async () => { await post('/apps/revoke', { session: sessionId }); }); const revokeAll = () => action.run(async () => { for (const session of sessions) { if (session.sessionId) { await post('/apps/revoke', { session: session.sessionId }); } } }); const separate = () => action.run(async () => { for (const from of folded) { await post('/apps/alias', { action: 'separate', from }); } }); return (
Signed in on {sessions.length} session {sessions.length === 1 ? '' : 's'} right now.
{sessions.length > 1 && (No app is signed in under this name right now. Its records stay in your account whether or not it has access.
)}No collections match "{collectionQuery.trim()}".
) : ( <>This app holds access but has not stored anything yet.
)}Permissioned data this app manages. The counts are what you have written. Other members' records live on their own servers.