import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileStack, KeyRound, Pencil, Trash2, UserPlus, X } from 'lucide-react';
import { useState } from 'react';
import { AppTile } from '#/components/atoms/app-tile.jsx';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button } from '#/components/atoms/button.jsx';
import { Card } from '#/components/atoms/card.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { ActorAvatar } from '#/components/molecules/actor-avatar.jsx';
import { AtUri } from '#/components/molecules/at-uri.jsx';
import { Copyable } from '#/components/molecules/copyable.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
import { Identity } from '#/components/molecules/identity.jsx';
import { RepoScope } from '#/components/molecules/repo-scope.jsx';
import { SourceDialog } from '#/components/molecules/source-dialog.jsx';
import { TextField } from '#/components/molecules/text-field.jsx';
import {
accessLabel,
ago,
appAccessLabel,
bytesToHex,
collectionsOf,
policyLabel,
spaceParts,
} from '#/lib/format.js';
import { href, useNavigate } from '#/lib/navigation.jsx';
import {
ACCESS_KINDS,
ACCESS_LABELS,
accessKind,
allowedOf,
isPublic,
managingAppOf,
POLICY_KINDS,
POLICY_LABELS,
policyKind,
policyUnion,
publishes,
} from '#/lib/policy.js';
import { useSession } from '#/lib/session.jsx';
import {
deleteSpace,
getLatestCommit,
getSpace,
listAllRecords,
listMembers,
listRepos,
putMember,
removeMember,
updateSpace,
} from '#/lib/spaces.js';
/** A labelled line in a summary card. */
function Fact({ label, children }) {
return (
{label}{children}
);
}
/** One labelled `select`, in the shape of a TextField. */
function Choice({ label, value, onChange, options, labels }) {
return (
);
}
/** A labelled checkbox, for one flag of a policy. */
function Flag({ label, checked, onChange }) {
return (
);
}
/**
* Change a space's policy. The endpoint replaces the policy whole, so a save
* sends every field this form shows. A policy of another kind discards the
* member list.
*/
function ConfigurationEditor({ space, config, onDone }) {
const { session } = useSession();
const [kind, setKind] = useState(() => policyKind(config) ?? 'memberspace');
const [publicRead, setPublicRead] = useState(() => isPublic(config, 'read'));
const [publicWrite, setPublicWrite] = useState(() =>
isPublic(config, 'write'),
);
const [managingApp, setManagingApp] = useState(
() => managingAppOf(config) ?? '',
);
const [access, setAccess] = useState(() => accessKind(config));
const [allowed, setAllowed] = useState(() => allowedOf(config).join('\n'));
const [publishPolicy, setPublishPolicy] = useState(() =>
publishes(config, 'publishPolicy'),
);
const [publishMembers, setPublishMembers] = useState(() =>
publishes(config, 'publishMembers'),
);
const clients = allowed
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean);
const save = useMutation({
mutationFn: () =>
updateSpace(session, {
space,
policy: policyUnion(kind, {
publicRead,
publicWrite,
allowedApps: access === 'allowList' ? clients : null,
managingApp: managingApp.trim() || null,
publishPolicy,
publishMembers,
}),
}),
onSuccess: onDone,
});
return (
{kind !== policyKind(config) && kind !== 'memberspace' && (
Changing the policy discards the member list. Changing back does not
restore it.
)}
{kind === 'memberspace' && (
<>
{access === 'allowList' && (
)}
>
)}
{kind === 'managedspace' && (
setManagingApp(event.target.value)}
/>
)}
{kind !== 'personalspace' && (
)}
{kind === 'memberspace' && (
)}
{save.error && {save.error.message}}
);
}
/** What the authority says about the space, and what the authority may change. */
function Configuration({ space, editable }) {
const { session } = useSession();
const client = useQueryClient();
const [editing, setEditing] = useState(false);
const { data, error, isPending } = useQuery({
queryKey: ['space-config', space],
queryFn: () => getSpace(session, space),
retry: false,
});
if (isPending) return ;
if (error) {
return (
The authority did not describe this space. {error.message}
);
}
const config = data.value;
if (editing) {
return (
{
setEditing(false);
client.invalidateQueries({ queryKey: ['space-config', space] });
}}
/>
);
}
const kind = policyKind(config);
return (
{policyLabel(config)}{accessLabel(config, 'read')}{accessLabel(config, 'write')}
{kind === 'memberspace' && (
{appAccessLabel(config)}
)}
{managingAppOf(config) && (
{managingAppOf(config)}
)}
{(publishes(config, 'publishPolicy') ||
publishes(config, 'publishMembers')) && (
{publishes(config, 'publishMembers')
? 'The policy and the member list'
: 'The policy'}
)}
{editable && (
)}
);
}
/** The commit over this repo's slice of the space. */
function Commit({ space, repo }) {
const { session } = useSession();
const { data, isPending } = useQuery({
queryKey: ['commit', space, repo],
queryFn: () => getLatestCommit(session, { space, repo }),
retry: false,
});
if (isPending) return ;
if (!data) {
return This repo has written nothing to the space yet.;
}
return (
{data.rev}
{bytesToHex(data.data).slice(0, 16)}…
);
}
/** The collections this repo holds in the space, counted from its own records. */
function Collections({ space, repo }) {
const { session } = useSession();
const { data, error, isPending } = useQuery({
queryKey: ['index', space, repo],
queryFn: () => listAllRecords(session, { space, repo }),
retry: false,
});
if (isPending) return ;
if (error) return {error.message};
const collections = collectionsOf(data.records);
if (collections.length === 0) {
return (
}
title="No records"
description="This repo holds nothing in this space."
/>
);
}
return (
{!data.complete && (
Counted the first {data.records.length} records. This repo holds more.
)}
{collections.map((entry) => (
{entry.collection}
{entry.count} record{entry.count === 1 ? '' : 's'}
{entry.lastWriteAt && ` · ${ago(entry.lastWriteAt)}`}
))}
);
}
/** Every repo the authority has heard from, which is the sync boundary. */
function Repos({ space, repo }) {
const { session } = useSession();
const active = repo || session.did;
const { data, error, isPending } = useQuery({
queryKey: ['repos', space],
queryFn: () => listRepos(session, space),
retry: false,
});
if (isPending) return ;
if (error) return {error.message};
const repos = data.repos || [];
return (
Each of these repos is served by its own account's PDS. Opening one asks
your server for a delegation token and the authority for a credential,
then reads that server directly.
);
}
/**
* One of a member's two rights. Read admits them to a credential; write has the
* authority track their repo and forward its notices.
*/
function Right({ label, on, editable, onToggle }) {
const badge = (
{on ? label : `No ${label.toLowerCase()}`}
);
if (!editable) return badge;
return (
);
}
/**
* Delete the space at the authority that governs it.
*
* The records survive it. Each member's repo holds its own, and the proposal
* has a repo host keep them, so what a delete ends is the authority: it stops
* issuing credentials and tells every syncer the space is gone.
*/
function DeleteSpace({ space }) {
const { session } = useSession();
const navigate = useNavigate();
const [confirming, setConfirming] = useState(false);
const remove = useMutation({
mutationFn: () => deleteSpace(session, { space }),
onSuccess: () => navigate('/'),
});
if (!confirming) {
return (
);
}
return (
Deleting stops this authority issuing credentials for the space and
tells every registered syncer it is gone. The records stay where they
are. Each member's repo keeps its own, and this account's copy still
lists the space.