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() {
)
}
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 (