diff --git a/src/components/profile/ProfileTabs.tsx b/src/components/profile/ProfileTabs.tsx index 1665ef2..47ca775 100644 --- a/src/components/profile/ProfileTabs.tsx +++ b/src/components/profile/ProfileTabs.tsx @@ -1,10 +1,9 @@ import type { Handle } from '@atcute/lexicons' -import { IconArticle, IconNote, IconNotebook, IconStar } from '@tabler/icons-react' +import { IconArticle, IconNote, IconNotebook, IconStar, IconThumbUp } from '@tabler/icons-react' import { useSearchParams } from 'react-router-dom' +import { parseProfileSection } from '../../lib/profile' import { Tabs } from '../shared/Tabs' -type ProfileSection = 'overview' | 'repos' | 'strings' | 'stars' - type ProfileTabsProps = { handle: Handle } @@ -14,6 +13,7 @@ const PROFILE_TABS = [ { label: 'Repositories', section: 'repos', Icon: IconNotebook }, { label: 'Strings', section: 'strings', Icon: IconNote }, { label: 'Stars', section: 'stars', Icon: IconStar }, + { label: 'Vouches', section: 'vouches', Icon: IconThumbUp }, ] as const export function ProfileTabs({ handle }: ProfileTabsProps) { @@ -39,11 +39,3 @@ export function ProfileTabs({ handle }: ProfileTabsProps) { ) } - -function parseProfileSection(value: string | null): ProfileSection { - if (value === 'repos' || value === 'strings' || value === 'stars') { - return value - } - - return 'overview' -} diff --git a/src/components/vouch/VouchList.tsx b/src/components/vouch/VouchList.tsx new file mode 100644 index 0000000..8a3228d --- /dev/null +++ b/src/components/vouch/VouchList.tsx @@ -0,0 +1,88 @@ +import { parseResourceUri, type Did } from '@atcute/lexicons' +import { isDid } from '@atcute/lexicons/syntax' +import { useEffect, useState } from 'react' +import { getProfile as getBskyProfile } from '../../lib/bsky/actor' +import { getMiniDoc } from '../../lib/microcosm' +import { getProfile } from '../../lib/tangled' +import type { VouchRecord } from '../../lib/tangled/graph' +import { VouchListItem, type VouchAuthor } from './VouchListItem' + +type VouchListProps = { + vouches: VouchRecord[] +} + +export function VouchList({ vouches }: VouchListProps) { + const [authors, setAuthors] = useState>(new Map()) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + let cancelled = false + + async function loadAuthors() { + setIsLoading(true) + + const authorDids = [ + ...new Set(vouches.map(getVouchAuthorDid).filter((did): did is Did => did !== null)), + ] + + const resolvedAuthors = await Promise.allSettled( + authorDids.map(async (did): Promise<[string, VouchAuthor]> => { + const [miniDoc, profile] = await Promise.all([getMiniDoc(did), getProfile(did)]) + const bskyProfile = await getBskyProfile(miniDoc).catch(() => null) + + return [did, { miniDoc, profile, bskyProfile }] + }), + ) + + if (cancelled) return + + setAuthors( + new Map( + resolvedAuthors.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [], + ), + ), + ) + setIsLoading(false) + } + + void loadAuthors() + + return () => { + cancelled = true + } + }, [vouches]) + + if (isLoading) { + return

Loading vouches...

+ } + + const visibleVouches = vouches.filter((vouch) => { + const authorDid = getVouchAuthorDid(vouch) + return authorDid !== null && authors.has(authorDid) + }) + + if (visibleVouches.length === 0) { + return

Vouches could not be resolved.

+ } + + return ( +
+ {visibleVouches.map((vouch) => { + const authorDid = getVouchAuthorDid(vouch) + if (authorDid === null) return null + + const author = authors.get(authorDid) + + if (author === undefined) return null + + return + })} +
+ ) +} + +function getVouchAuthorDid(vouch: VouchRecord): Did | null { + const { repo } = parseResourceUri(vouch.uri) + return isDid(repo) ? repo : null +} diff --git a/src/components/vouch/VouchListItem.tsx b/src/components/vouch/VouchListItem.tsx new file mode 100644 index 0000000..83b1423 --- /dev/null +++ b/src/components/vouch/VouchListItem.tsx @@ -0,0 +1,68 @@ +import type { AppBskyActorProfile } from '@atcute/bluesky' +import type { $output as MiniDoc } from '@atcute/microcosm/types/blue/microcosm/identity/resolveMiniDoc' +import { IconThumbDown, IconThumbUp } from '@tabler/icons-react' +import { Link } from 'react-router-dom' +import type { Profile } from '../../lib/tangled' +import { ProfileAvatar } from '../profile/ProfileAvatar' +import { SurfaceCard } from '../shared/SurfaceCard' +import type { VouchRecord } from '../../lib/tangled/graph' + +type VouchListItemProps = { + vouchRecord: VouchRecord + author: VouchAuthor +} + +export type VouchAuthor = { + miniDoc: MiniDoc + profile: Profile + bskyProfile: AppBskyActorProfile.Main | null +} + +export function VouchListItem({ vouchRecord, author }: VouchListItemProps) { + const { value } = vouchRecord + const isDenouncement = value.kind === 'denounce' + const actionLabel = isDenouncement ? 'Denounces' : 'Vouches' + const actionColor = isDenouncement ? 'text-ctp-red' : 'text-ctp-green' + const borderColor = isDenouncement ? 'border-l-ctp-red' : 'border-l-ctp-green' + const ActionIcon = isDenouncement ? IconThumbDown : IconThumbUp + + return ( + +
+ + + + + {author.miniDoc.handle} + + + + + + + +
+ + {value.reason && ( +

+ {value.reason} +

+ )} +
+ ) +} diff --git a/src/components/vouch/VouchView.tsx b/src/components/vouch/VouchView.tsx new file mode 100644 index 0000000..ecfe116 --- /dev/null +++ b/src/components/vouch/VouchView.tsx @@ -0,0 +1,32 @@ +import type { VouchRecord } from '../../lib/tangled/graph' + +type VouchProps = { + vouchRecord: VouchRecord +} + +export function VouchView({ vouchRecord }: VouchProps) { + const { value } = vouchRecord + + return ( + <> + {value.reason && ( +

+ {value.reason} +

+ )} + +
+
+
Kind
+
{value.kind ?? 'vouch'}
+
+
+
Created
+
+ +
+
+
+ + ) +} diff --git a/src/lib/profile.ts b/src/lib/profile.ts new file mode 100644 index 0000000..a018be8 --- /dev/null +++ b/src/lib/profile.ts @@ -0,0 +1,9 @@ +export type ProfileSection = 'overview' | 'repos' | 'strings' | 'stars' | 'vouches' + +export function parseProfileSection(value: string | null): ProfileSection { + if (value === 'repos' || value === 'strings' || value === 'stars' || value === 'vouches') { + return value + } + + return 'overview' +} diff --git a/src/lib/tangled/feed.ts b/src/lib/tangled/feed.ts index 87971f6..dabefba 100644 --- a/src/lib/tangled/feed.ts +++ b/src/lib/tangled/feed.ts @@ -5,6 +5,7 @@ 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' +import { removeNullCursor } from './utils' export type StarRecord = { cid?: Cid @@ -96,12 +97,3 @@ export async function listStarsBy(did: Did, options: ListStarsOptions = {}): Pro 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/lib/tangled/graph.ts b/src/lib/tangled/graph.ts index 860e3fa..673e0dd 100644 --- a/src/lib/tangled/graph.ts +++ b/src/lib/tangled/graph.ts @@ -1,9 +1,28 @@ -// import { is } from '@atcute/lexicons'; import { ok } from '@atcute/client' -import type { Did } from '@atcute/lexicons' -// import { mainSchema as repoSchema } from '@atcute/tangled/types/repo'; -// import { mainSchema as reposListSchema } from '@atcute/tangled/types/repo/listRepos'; +import type { Cid, Did, ResourceUri } from '@atcute/lexicons' +import { safeParse } from '@atcute/lexicons' +import type { Main as TangledVouch } from '@atcute/tangled/types/graph/vouch' +import { mainSchema as vouchSchema } from '@atcute/tangled/types/graph/vouch' +import { mainSchema as listVouchesSchema } from '@atcute/tangled/types/graph/listVouches' import { rpc } from './client' +import { removeNullCursor } from './utils' + +export type VouchRecord = { + cid?: Cid + uri: ResourceUri + value: TangledVouch +} + +export type VouchList = { + items: VouchRecord[] + cursor?: string +} + +export type ListVouchesOptions = { + cursor?: string + limit?: number + order?: 'asc' | 'desc' +} export async function countFollows(did: Did): Promise { const count = await ok( @@ -32,3 +51,37 @@ export async function countFollowsBy(did: Did): Promise { return count.count } + +export async function listVouches(did: Did, options: ListVouchesOptions = {}): Promise { + const response = await ok( + rpc.get('sh.tangled.graph.listVouches', { + params: { + subject: did, + limit: options.limit, + cursor: options.cursor, + order: options.order, + }, + }), + ) + + const listValidation = safeParse(listVouchesSchema.output.schema, removeNullCursor(response)) + if (!listValidation.ok) { + throw new Error(`Bobbin returned an invalid vouch list: ${listValidation.message}`) + } + + const items = listValidation.value.items.map((item, index) => { + const valueValidation = safeParse(vouchSchema, item.value) + if (!valueValidation.ok) { + throw new Error( + `Bobbin returned an invalid vouch record at index ${index}: ${valueValidation.message}`, + ) + } + + return { ...item, value: valueValidation.value } + }) + + return { + items, + cursor: listValidation.value.cursor, + } +} diff --git a/src/lib/tangled/repo.ts b/src/lib/tangled/repo.ts index fedf2f7..93d1fc8 100644 --- a/src/lib/tangled/repo.ts +++ b/src/lib/tangled/repo.ts @@ -10,6 +10,7 @@ import { mainSchema as reposListSchema } from '@atcute/tangled/types/repo/listRe import type { $output as RepoTree } from '@atcute/tangled/types/repo/tree' import { mainSchema as treeSchema } from '@atcute/tangled/types/repo/tree' import { rpc } from './client' +import { removeNullCursor } from './utils' export type Repo = { cid?: Cid @@ -302,12 +303,3 @@ function normalizeCommit(value: unknown): unknown { return commit } - -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/lib/tangled/string.ts b/src/lib/tangled/string.ts index 388c89c..a6f02d6 100644 --- a/src/lib/tangled/string.ts +++ b/src/lib/tangled/string.ts @@ -4,6 +4,7 @@ import { safeParse } from '@atcute/lexicons' import { ShTangledString, ShTangledStringListStrings } from '@atcute/tangled' import { slingshot } from '../microcosm' import { rpc } from './client' +import { removeNullCursor } from './utils' export type StringRecord = { cid?: Cid @@ -80,12 +81,3 @@ export async function listStrings( 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/lib/tangled/utils.ts b/src/lib/tangled/utils.ts new file mode 100644 index 0000000..dac40b9 --- /dev/null +++ b/src/lib/tangled/utils.ts @@ -0,0 +1,8 @@ +export 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 641cbb1..8ac4e63 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -2,7 +2,7 @@ import type { AppBskyActorProfile } from '@atcute/bluesky' 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' +import { IconPin, IconThumbUp } from '@tabler/icons-react' import { type ReactNode, useEffect, useState } from 'react' import { useParams, useSearchParams } from 'react-router-dom' import { ProfilePageSkeleton } from '../components/shared/PageSkeletons' @@ -11,11 +11,16 @@ import { ProfileHeader } from '../components/profile/ProfileHeader' import { ProfileTabs } from '../components/profile/ProfileTabs' import { RepoListItem } from '../components/repo/RepoListItem' import { StringListItem } from '../components/string/StringListItem' +import { VouchList } from '../components/vouch/VouchList' import { getProfile as getBskyProfile } from '../lib/bsky/actor' import { getMiniDoc } from '../lib/microcosm' +import { parseProfileSection } from '../lib/profile' 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' +import { listVouches, type VouchList as VouchListData } from '../lib/tangled/graph' + +const MAX_RECENT_VOUCHES = 4 export function ProfilePage() { const { handle: routeHandle } = useParams() @@ -27,6 +32,7 @@ export function ProfilePage() { const [strings, setStrings] = useState(null) const [pinnedRepos, setPinnedRepos] = useState(null) const [starredRepos, setStarredRepos] = useState(null) + const [vouches, setVouches] = useState(null) const [bskyProfile, setBskyProfile] = useState(null) const [error, setError] = useState(null) @@ -41,9 +47,10 @@ export function ProfilePage() { const stars = await listStarsBy(miniDoc.did) const repoDids = getRepoDidsFromStars(stars) - const [profile, repos, strings, bskyProfile] = await Promise.all([ + const [profile, repos, vouchList, strings, bskyProfile] = await Promise.all([ getProfile(miniDoc.did), listRepos(miniDoc.did), + listVouches(miniDoc.did), listStrings(miniDoc.did), getBskyProfile(miniDoc).catch(() => null), ]) @@ -85,6 +92,7 @@ export function ProfilePage() { setRepos(repos) setPinnedRepos(pinnedRepos) setStarredRepos(starredRepos) + setVouches(vouchList) setStrings(strings) setBskyProfile(bskyProfile) } @@ -115,12 +123,14 @@ export function ProfilePage() { profile === null || repos === null || strings === null || - starredRepos === null + starredRepos === null || + vouches === null ) { return } const activeSection = parseProfileSection(searchParams.get('view')) + const recentVouches = vouches.items.slice(0, MAX_RECENT_VOUCHES) return (
@@ -130,23 +140,37 @@ export function ProfilePage() {
{activeSection === 'overview' && ( -
-

-

- {pinnedRepos?.length === 0 &&

No repos found.

} - {pinnedRepos && pinnedRepos.length > 0 && ( -
- {pinnedRepos.map((repo) => ( - - ))} -
- )} -
+
+
+

+

+ {pinnedRepos?.length === 0 &&

No repos found.

} + {pinnedRepos && pinnedRepos.length > 0 && ( +
+ {pinnedRepos.map((repo) => ( + + ))} +
+ )} +
+ +
+

+

+ {recentVouches.length === 0 &&

No vouches yet.

} + {recentVouches.length > 0 && } +
+
)} {activeSection === 'repos' && ( @@ -192,6 +216,13 @@ export function ProfilePage() { )} )} + + {activeSection === 'vouches' && ( + + {vouches.items.length === 0 &&

No vouches yet.

} + {vouches.items.length > 0 && } +
+ )}
@@ -221,14 +252,6 @@ function ProfileCollection({ title, children }: ProfileCollectionProps) { ) } -function parseProfileSection(value: string | null): 'overview' | 'repos' | 'strings' | 'stars' { - if (value === 'repos' || value === 'strings' || value === 'stars') { - return value - } - - return 'overview' -} - function parseHandle(value: string | undefined): Handle | null { if (value === undefined || !isHandle(value)) { return null