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 (
Your apps
{shown}
{num(total)} record{total === 1 ? '' : 's'} across{' '} {collections.length} collection{collections.length === 1 ? '' : 's'}
{folded.length > 0 && (
{[ // A folded record authority reads out as its NSID prefix; a // folded client host is named as the sign-in it brought. foldedGroups.length > 0 && `Stores records as ${foldedGroups .map((g) => `${g.domain.split('.').reverse().join('.')}.*`) .join(', ')}`, foldedClients.length > 0 && `Signs in from ${foldedClients.join(', ')}`, ] .filter(Boolean) .join(' · ')}{' '} ·{' '} Separate
)}
Visit
Access {sessions.length ? ( <>

Signed in on {sessions.length} session {sessions.length === 1 ? '' : 's'} right now.

{sessions.length > 1 && ( Revoke all )}
{shownSessions.map((session, index) => (
{formatUserAgent(session.userAgent) && (
{formatUserAgent(session.userAgent)}
)}
{session.authorizedAt ? `Authorized ${day(session.authorizedAt)}` : 'Authorized'} {session.lastAccessedAt && ( Last active {timeAgo(session.lastAccessedAt)} )}
{session.sessionId && ( )}
))}
setShowAllSessions((v) => !v)} noun="sessions" /> ) : (

No app is signed in under this name right now. Its records stay in your account whether or not it has access.

)} Records {collections.length ? ( <> {collections.length > COLLECTION_SEARCH_THRESHOLD && ( )} {matchedCollections.length === 0 ? (

No collections match "{collectionQuery.trim()}".

) : ( <>
{shownCollections.map((collection) => ( {collection.name} {/* Always a boolean: an undefined prop would make each row resolve its own NSID, stampeding the batch request for the same answers. */} } description={`${num(collection.count)} record${collection.count === 1 ? '' : 's'}`} actions={ } /> ))}
setShowAllRecords((v) => !v)} noun="collections" /> )} ) : (

This app holds access but has not stored anything yet.

)}
{spaces.length > 0 && ( Shared spaces

Permissioned data this app manages. The counts are what you have written. Other members' records live on their own servers.

{spaces.map((space) => (
{space.spaceType} {space.isOwner && yours} {space.deleted && deleted}
{space.members} member{space.members === 1 ? '' : 's'} ·{' '} {space.policy}
{space.collections.map((collection) => ( {collection.collection} ({num(collection.count)}) ))}
))}
)}
); }