diff --git a/BOBBIN_XRPCS.md b/BOBBIN_XRPCS.md index 4bd0fff..1e0d707 100644 --- a/BOBBIN_XRPCS.md +++ b/BOBBIN_XRPCS.md @@ -41,7 +41,7 @@ knot-proxied endpoints require `knot`. - [ ] `sh.tangled.repo.countPulls` - [ ] `sh.tangled.repo.listCollaborators` - [ ] `sh.tangled.repo.countCollaborators` -- [ ] `sh.tangled.repo.listRepos` +- [x] `sh.tangled.repo.listRepos` - [ ] `sh.tangled.repo.countRepos` - [ ] `sh.tangled.repo.listArtifacts` - [ ] `sh.tangled.repo.countArtifacts` @@ -67,7 +67,7 @@ knot-proxied endpoints require `knot`. - [ ] `sh.tangled.pipeline.countPipelines` - [ ] `sh.tangled.pipeline.listStatuses` - [ ] `sh.tangled.pipeline.countStatuses` -- [ ] `sh.tangled.string.listStrings` +- [ x `sh.tangled.string.listStrings` - [ ] `sh.tangled.string.countStrings` ## Reverse aggregations diff --git a/src/App.tsx b/src/App.tsx index 9d792bb..c13926a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,9 @@ import { Route, Routes } from 'react-router-dom' +import { PageLayout } from './components/PageLayout' import { HomePage } from './pages/HomePage' import { ProfilePage } from './pages/ProfilePage' import { RepoPage } from './pages/RepoPage' -import { PageLayout } from './components/PageLayout' +import { StringPage } from './pages/StringPage' function App() { return ( @@ -11,6 +12,7 @@ function App() { } /> } /> } /> + } /> ) diff --git a/src/components/ProfileByline.tsx b/src/components/ProfileByline.tsx index 9599d68..87b0c7c 100644 --- a/src/components/ProfileByline.tsx +++ b/src/components/ProfileByline.tsx @@ -2,17 +2,16 @@ import type { AppBskyActorProfile } from '@atcute/bluesky' import type { $output as MiniDoc } from '@atcute/microcosm/types/blue/microcosm/identity/resolveMiniDoc' import { Link } from 'react-router-dom' import { getAvatarUrl } from '../lib/atproto/media' -import type { Profile, Repo } from '../lib/tangled' -import { getRepoName } from '../lib/tangled/repo' +import type { Profile } from '../lib/tangled' type ProfileBylineProps = { miniDoc: MiniDoc profile: Profile bskyProfile?: AppBskyActorProfile.Main | null - repo: Repo + label: string } -export function ProfileByline({ miniDoc, profile, bskyProfile, repo }: ProfileBylineProps) { +export function ProfileByline({ miniDoc, profile, bskyProfile, label }: ProfileBylineProps) { const { value } = profile const avatar = value.avatar ?? bskyProfile?.avatar const avatarUrl = avatar ? getAvatarUrl(miniDoc.did, avatar) : null @@ -44,7 +43,7 @@ export function ProfileByline({ miniDoc, profile, bskyProfile, repo }: ProfileBy / -
  • {getRepoName(repo)}
  • +
  • {label}
  • ) diff --git a/src/components/RepoListItem.tsx b/src/components/RepoListItem.tsx index a3611cd..a30cb56 100644 --- a/src/components/RepoListItem.tsx +++ b/src/components/RepoListItem.tsx @@ -34,7 +34,7 @@ export function RepoListItem({ handle, repo }: RepoListItemProps) { }, [repoDid]) return ( - +
    diff --git a/src/components/StringListItem.tsx b/src/components/StringListItem.tsx new file mode 100644 index 0000000..46ff7c0 --- /dev/null +++ b/src/components/StringListItem.tsx @@ -0,0 +1,37 @@ +import { Link } from 'react-router-dom' +import { parseResourceUri } from '@atcute/lexicons' +import type { StringRecord } from '../lib/tangled' + +type StringListItemProps = { + handle: string + stringRecord: StringRecord +} + +export function StringListItem({ handle, stringRecord }: StringListItemProps) { + const { value } = stringRecord + const { rkey } = parseResourceUri(stringRecord.uri) + + if (rkey === undefined) { + throw new Error(`String record URI has no rkey: ${stringRecord.uri}`) + } + + return ( + +
    +

    + {value.filename} +

    + + {value.description && ( +

    + {value.description} +

    + )} + +

    + {value.contents} +

    +
    + + ) +} diff --git a/src/components/StringView.tsx b/src/components/StringView.tsx new file mode 100644 index 0000000..1f8725f --- /dev/null +++ b/src/components/StringView.tsx @@ -0,0 +1,23 @@ +import type { StringRecord } from '../lib/tangled/index' + +type StringProps = { + string: StringRecord +} + +export function StringView({ string }: StringProps) { + const { value } = string + + return ( + <> + {value.description && ( +

    + {value.description} +

    + )} + +
    +        {value.contents}
    +      
    + + ) +} diff --git a/src/lib/tangled/index.ts b/src/lib/tangled/index.ts index 86cfe94..e9a7805 100644 --- a/src/lib/tangled/index.ts +++ b/src/lib/tangled/index.ts @@ -2,4 +2,6 @@ export { getProfile } from './actor' export type { Profile } from './actor' export { getRepo, getRepoName, getRepoRkey } from './repo' export type { Repo } from './repo' +export { getString, listStrings } from './string' +export type { ListStringsOptions, StringList, StringRecord } from './string' export { BOBBIN_URL, rpc } from './client' diff --git a/src/lib/tangled/string.ts b/src/lib/tangled/string.ts new file mode 100644 index 0000000..388c89c --- /dev/null +++ b/src/lib/tangled/string.ts @@ -0,0 +1,91 @@ +import { ok } from '@atcute/client' +import type { Cid, Did, ResourceUri } from '@atcute/lexicons' +import { safeParse } from '@atcute/lexicons' +import { ShTangledString, ShTangledStringListStrings } from '@atcute/tangled' +import { slingshot } from '../microcosm' +import { rpc } from './client' + +export type StringRecord = { + cid?: Cid + uri: ResourceUri + value: ShTangledString.Main +} + +export type StringList = { + items: StringRecord[] + cursor?: string +} + +export type ListStringsOptions = { + cursor?: string + limit?: number + order?: 'asc' | 'desc' +} + +export async function getString(did: Did, rkey: string): Promise { + const response = await ok( + slingshot.get('com.atproto.repo.getRecord', { + params: { + repo: did, + collection: 'sh.tangled.string', + rkey, + }, + }), + ) + + const validation = safeParse(ShTangledString.mainSchema, response.value) + if (!validation.ok) { + throw new Error(`Slingshot returned an invalid string record: ${validation.message}`) + } + + return { ...response, value: validation.value } +} + +export async function listStrings( + subject: string, + options: ListStringsOptions = {}, +): Promise { + const response = await ok( + rpc.get('sh.tangled.string.listStrings', { + params: { + subject, + cursor: options.cursor, + limit: options.limit, + order: options.order, + }, + }), + ) + + const listValidation = safeParse( + ShTangledStringListStrings.mainSchema.output.schema, + removeNullCursor(response), + ) + if (!listValidation.ok) { + throw new Error(`Bobbin returned an invalid string list: ${listValidation.message}`) + } + + const items = listValidation.value.items.map((item, index) => { + const valueValidation = safeParse(ShTangledString.mainSchema, item.value) + if (!valueValidation.ok) { + throw new Error( + `Bobbin returned an invalid string record at index ${index}: ${valueValidation.message}`, + ) + } + + return { ...item, value: valueValidation.value } + }) + + return { + items, + cursor: listValidation.value.cursor, + } +} + +function removeNullCursor(value: unknown): unknown { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return value + } + + const { cursor, ...rest } = value as Record + return cursor === null ? rest : value +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 8a607c2..12a90ea 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,6 +1,6 @@ +import type { AppBskyActorProfile } from '@atcute/bluesky' import type { Did, Handle } from '@atcute/lexicons' import { isHandle } from '@atcute/lexicons/syntax' -import type { AppBskyActorProfile } from '@atcute/bluesky' import type { $output as MiniDoc } from '@atcute/microcosm/types/blue/microcosm/identity/resolveMiniDoc' import { useEffect, useState } from 'react' import { useParams } from 'react-router-dom' @@ -8,9 +8,10 @@ import { ProfilePageSkeleton } from '../components/PageSkeletons' import { ProfileHeader } from '../components/ProfileHeader' import { ProfileOverview } from '../components/ProfileOverview' import { RepoListItem } from '../components/RepoListItem' +import { StringListItem } from '../components/StringListItem' import { getProfile as getBskyProfile } from '../lib/bsky/actor' import { getMiniDoc } from '../lib/microcosm' -import { getProfile, type Profile } from '../lib/tangled' +import { getProfile, listStrings, type Profile, type StringList } from '../lib/tangled' import { getRepoByRepoDid, listRepos, type Repo, type RepoList } from '../lib/tangled/repo' export function ProfilePage() { @@ -19,6 +20,7 @@ export function ProfilePage() { const [identity, setIdentity] = useState(null) const [profile, setProfile] = useState(null) const [repos, setRepos] = useState(null) + const [strings, setStrings] = useState(null) const [pinnedRepos, setPinnedRepos] = useState(null) const [bskyProfile, setBskyProfile] = useState(null) const [error, setError] = useState(null) @@ -32,9 +34,10 @@ export function ProfilePage() { setError(null) const miniDoc = await getMiniDoc(handle) - const [profile, repos, bskyProfile] = await Promise.all([ + const [profile, repos, strings, bskyProfile] = await Promise.all([ getProfile(miniDoc.did), listRepos(miniDoc.did), + listStrings(miniDoc.did), getBskyProfile(miniDoc).catch(() => null), ]) @@ -52,6 +55,7 @@ export function ProfilePage() { setProfile(profile) setRepos(repos) setPinnedRepos(pinnedRepos) + setStrings(strings) setBskyProfile(bskyProfile) } } catch (caught) { @@ -76,7 +80,7 @@ export function ProfilePage() { return

    Could not load profile: {error.message}

    } - if (identity === null || profile === null || repos === null) { + if (identity === null || profile === null || repos === null || strings === null) { return } @@ -112,6 +116,18 @@ export function ProfilePage() {
    )} + +
    +

    Strings

    + {strings.items.length === 0 &&

    No strings found.

    } + {strings.items.length > 0 && ( +
    + {strings.items.map((string) => ( + + ))} +
    + )} +
    ) } diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx index 601b224..3cc17f1 100644 --- a/src/pages/RepoPage.tsx +++ b/src/pages/RepoPage.tsx @@ -13,7 +13,7 @@ import { RepoWorkspace } from '../components/RepoWorkspace' import { getProfile as getBskyProfile } from '../lib/bsky/actor' import { getMiniDoc } from '../lib/microcosm' import { getProfile, type Profile } from '../lib/tangled' -import { getRepo, type Repo } from '../lib/tangled/repo' +import { getRepo, getRepoName, type Repo } from '../lib/tangled/repo' export function RepoPage() { const { handle: routeHandle, repo: routeRepo } = useParams() @@ -76,7 +76,12 @@ export function RepoPage() { return (
    - +
    diff --git a/src/pages/StringPage.tsx b/src/pages/StringPage.tsx new file mode 100644 index 0000000..8589f88 --- /dev/null +++ b/src/pages/StringPage.tsx @@ -0,0 +1,90 @@ +import type { AppBskyActorProfile } from '@atcute/bluesky' +import type { Handle } from '@atcute/lexicons' +import { isHandle } from '@atcute/lexicons/syntax' +import type { $output as MiniDoc } from '@atcute/microcosm/types/blue/microcosm/identity/resolveMiniDoc' +import { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { ProfilePageSkeleton } from '../components/PageSkeletons' +import { ProfileByline } from '../components/ProfileByline' +import { StringView } from '../components/StringView' +import { getProfile as getBskyProfile } from '../lib/bsky/actor' +import { getMiniDoc } from '../lib/microcosm' +import { getProfile, getString, type Profile, type StringRecord } from '../lib/tangled' + +export function StringPage() { + const { handle: routeHandle, string: routeString } = useParams() + const handle = parseHandle(routeHandle) + const [identity, setIdentity] = useState(null) + const [profile, setProfile] = useState(null) + const [bskyProfile, setBskyProfile] = useState(null) + const [stringRecord, setStringRecord] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + if (handle === null || routeString === undefined) return + let cancelled = false + + async function loadPage(handle: Handle, stringKey: string) { + try { + setError(null) + const miniDoc = await getMiniDoc(handle) + const [profile, stringRecord, bskyProfile] = await Promise.all([ + getProfile(miniDoc.did), + getString(miniDoc.did, stringKey), + getBskyProfile(miniDoc).catch(() => null), + ]) + + if (!cancelled) { + setIdentity(miniDoc) + setProfile(profile) + setBskyProfile(bskyProfile) + setStringRecord(stringRecord) + } + } catch (caught) { + if (!cancelled) { + setError(caught instanceof Error ? caught : new Error('Unable to load string')) + } + } + } + + void loadPage(handle, routeString) + + return () => { + cancelled = true + } + }, [handle, routeString]) + + if (handle === null || routeString === undefined) { + return

    Invalid string route

    + } + + if (error) { + return

    Could not load string: {error.message}

    + } + + if (profile === null || identity === null || stringRecord === null) { + return + } + + return ( +
    + +
    + +
    +
    + ) +} + +function parseHandle(value: string | undefined): Handle | null { + if (value === undefined || !isHandle(value)) { + return null + } + + return value +}