diff --git a/src/components/profile/ProfileTabs.tsx b/src/components/profile/ProfileTabs.tsx index 52a52cb..1665ef2 100644 --- a/src/components/profile/ProfileTabs.tsx +++ b/src/components/profile/ProfileTabs.tsx @@ -1,9 +1,9 @@ import type { Handle } from '@atcute/lexicons' -import { IconArticle, IconNote, IconNotebook } from '@tabler/icons-react' +import { IconArticle, IconNote, IconNotebook, IconStar } from '@tabler/icons-react' import { useSearchParams } from 'react-router-dom' import { Tabs } from '../shared/Tabs' -type ProfileSection = 'overview' | 'repos' | 'strings' +type ProfileSection = 'overview' | 'repos' | 'strings' | 'stars' type ProfileTabsProps = { handle: Handle @@ -13,6 +13,7 @@ const PROFILE_TABS = [ { label: 'Overview', section: 'overview', Icon: IconArticle }, { label: 'Repositories', section: 'repos', Icon: IconNotebook }, { label: 'Strings', section: 'strings', Icon: IconNote }, + { label: 'Stars', section: 'stars', Icon: IconStar }, ] as const export function ProfileTabs({ handle }: ProfileTabsProps) { @@ -40,7 +41,7 @@ export function ProfileTabs({ handle }: ProfileTabsProps) { } function parseProfileSection(value: string | null): ProfileSection { - if (value === 'repos' || value === 'strings') { + if (value === 'repos' || value === 'strings' || value === 'stars') { return value } diff --git a/src/lib/tangled/feed.ts b/src/lib/tangled/feed.ts index a441674..87971f6 100644 --- a/src/lib/tangled/feed.ts +++ b/src/lib/tangled/feed.ts @@ -1,7 +1,40 @@ import { ok } from '@atcute/client' -import type { Did } from '@atcute/lexicons' +import type { Cid, Did, ResourceUri } from '@atcute/lexicons' +import { safeParse } from '@atcute/lexicons' +import type { Main as TangledStar } from '@atcute/tangled/types/feed/star' +import { mainSchema as starSchema } from '@atcute/tangled/types/feed/star' +import { mainSchema as starsBySchema } from '@atcute/tangled/types/feed/listStarsBy' import { rpc } from './client' +export type StarRecord = { + cid?: Cid + uri: ResourceUri + value: TangledStar +} + +export type StarList = { + items: StarRecord[] + cursor?: string +} + +export type ListStarsOptions = { + cursor?: string + limit?: number + order?: 'asc' | 'desc' +} + +export function getRepoDidsFromStars(stars: StarList): Did[] { + return stars.items.flatMap(({ value }) => { + const subject = value.subject + + if (subject.$type !== 'sh.tangled.feed.star#repo') { + return [] + } + + return [subject.did] + }) +} + export async function countStars(repoDid: Did): Promise { const response = await ok( rpc.get('sh.tangled.feed.countStars', { @@ -15,3 +48,60 @@ export async function countStars(repoDid: Did): Promise { return response.count } + +export async function countStarsBy(did: Did): Promise { + const response = await ok( + rpc.get('sh.tangled.feed.countStarsBy', { + params: { subject: did }, + }), + ) + + if (typeof response.count !== 'number' || !Number.isFinite(response.count)) { + throw new Error('Bobbin returned an invalid star count') + } + + return response.count +} + +export async function listStarsBy(did: Did, options: ListStarsOptions = {}): Promise { + const response = await ok( + rpc.get('sh.tangled.feed.listStarsBy', { + params: { + subject: did, + cursor: options.cursor, + limit: options.limit, + order: options.order, + }, + }), + ) + + const listValidation = safeParse(starsBySchema.output.schema, removeNullCursor(response)) + if (!listValidation.ok) { + throw new Error(`Bobbin returned an invalid star list: ${listValidation.message}`) + } + + const items = listValidation.value.items.map((item, index) => { + const valueValidation = safeParse(starSchema, item.value) + if (!valueValidation.ok) { + throw new Error( + `Bobbin returned an invalid star 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 5a98931..ae11cb7 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,5 +1,5 @@ import type { AppBskyActorProfile } from '@atcute/bluesky' -import type { Did, Handle } from '@atcute/lexicons' +import { parseResourceUri, type Did, 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 { IconPin } from '@tabler/icons-react' @@ -15,6 +15,7 @@ import { getProfile as getBskyProfile } from '../lib/bsky/actor' import { getMiniDoc } from '../lib/microcosm' import { getProfile, listStrings, type Profile, type StringList } from '../lib/tangled' import { getRepoByRepoDid, listRepos, type Repo, type RepoList } from '../lib/tangled/repo' +import { getRepoDidsFromStars, listStarsBy } from '../lib/tangled/feed' export function ProfilePage() { const { handle: routeHandle } = useParams() @@ -25,6 +26,7 @@ export function ProfilePage() { const [repos, setRepos] = useState(null) const [strings, setStrings] = useState(null) const [pinnedRepos, setPinnedRepos] = useState(null) + const [starredRepos, setStarredRepos] = useState(null) const [bskyProfile, setBskyProfile] = useState(null) const [error, setError] = useState(null) @@ -36,6 +38,8 @@ export function ProfilePage() { try { setError(null) const miniDoc = await getMiniDoc(handle) + const stars = await listStarsBy(miniDoc.did) + const repoDids = getRepoDidsFromStars(stars) const [profile, repos, strings, bskyProfile] = await Promise.all([ getProfile(miniDoc.did), @@ -44,11 +48,33 @@ export function ProfilePage() { getBskyProfile(miniDoc).catch(() => null), ]) + const ownerDocs = new Map>() + const getOwner = (identifier: string) => { + const existing = ownerDocs.get(identifier) + if (existing !== undefined) return existing + + const request = getMiniDoc(identifier) + ownerDocs.set(identifier, request) + return request + } + + const starredRepos = ( + await Promise.allSettled( + repoDids.map(async (repoDid): Promise => { + const repo = await getRepoByRepoDid(repoDid) + const owner = await getOwner(parseResourceUri(repo.uri).repo) + + return { repo, handle: owner.handle } + }), + ) + ).flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])) + const pinnedResults = await Promise.allSettled( (profile.value.pinnedRepositories ?? []).map((repoDid) => getRepoByRepoDid(repoDid as Did), ), ) + const pinnedRepos = pinnedResults.flatMap((result) => result.status === 'fulfilled' ? [result.value] : [], ) @@ -58,6 +84,7 @@ export function ProfilePage() { setProfile(profile) setRepos(repos) setPinnedRepos(pinnedRepos) + setStarredRepos(starredRepos) setStrings(strings) setBskyProfile(bskyProfile) } @@ -83,7 +110,13 @@ export function ProfilePage() { return

Could not load profile: {error.message}

} - if (identity === null || profile === null || repos === null || strings === null) { + if ( + identity === null || + profile === null || + repos === null || + strings === null || + starredRepos === null + ) { return } @@ -140,6 +173,20 @@ export function ProfilePage() { )} )} + + {activeSection === 'stars' && ( + + {starredRepos.length === 0 &&

No stars found.

} + + {starredRepos.length > 0 && ( +
+ {starredRepos.map(({ repo, handle }) => ( + + ))} +
+ )} +
+ )} ) @@ -150,6 +197,11 @@ type ProfileCollectionProps = { children: ReactNode } +type StarredRepo = { + repo: Repo + handle: Handle +} + function ProfileCollection({ title, children }: ProfileCollectionProps) { const headingId = `${title.toLowerCase()}-heading` @@ -163,8 +215,8 @@ function ProfileCollection({ title, children }: ProfileCollectionProps) { ) } -function parseProfileSection(value: string | null): 'overview' | 'repos' | 'strings' { - if (value === 'repos' || value === 'strings') { +function parseProfileSection(value: string | null): 'overview' | 'repos' | 'strings' | 'stars' { + if (value === 'repos' || value === 'strings' || value === 'stars') { return value }