diff --git a/web/src/lib/components/profile/pages.ts b/web/src/lib/components/profile/pages.ts new file mode 100644 index 00000000..e96c771c --- /dev/null +++ b/web/src/lib/components/profile/pages.ts @@ -0,0 +1,410 @@ +// page fetchers for the profile tabs, shared between the route load (first +// page) and the tab components (load more). pass a shared IdentityCache to +// dedupe across pages + +import type { BobbinContext } from "$lib/api/client"; +import type { Did } from "@atcute/lexicons/syntax"; +import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich"; +import { fetchPage } from "$lib/api/pagination"; +import { IdentityCache } from "$lib/api/identity"; +import type { RecordView, RepoRecord } from "$lib/api/records"; +import type { SearchPage } from "$lib/api/search"; +import { didFromUri, rkeyFromUri } from "$lib/api/uri"; +import type { VouchRecord } from "$lib/api/graph"; +import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; +import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; +import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; +import type { RepoCardData, StringCardData, PersonData, VouchData, StarData } from "./types"; + +// same page size as the appview's lists +export const PROFILE_PAGE_LIMIT = 30; + +export interface ListPage { + items: T[]; + cursor?: string; +} + +interface ListItem { + uri: string; + value: unknown; +} + +// the hand-rolled RecordList omits the cursor the wire output carries +type RecordPage = { + items: RecordView[]; + cursor?: string; +}; + +const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" }; +const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" }; +const FOLLOW_STATS: LinkDescriptor[] = [ + { source: "sh.tangled.graph.follow:subject", type: "count" }, + { source: "sh.tangled.graph.follow:.repo", type: "count" } +]; +const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" }; + +const starDescriptors = (viewerDid: string | undefined) => + viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; + +const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { + const value = item.value as RepoRecord; + return { + rkey: rkeyFromUri(item.uri), + name: value.name ?? rkeyFromUri(item.uri), + repoDid: value.repoDid ?? "", + ownerHandle, + description: value.description, + knot: value.knot, + createdAt: value.createdAt + }; +}; + +const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => { + const repo = toRepoCard(item, ownerHandle); + if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; + const stars = countOf(stats, repo.repoDid, STAR_COUNT.source); + const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source); + return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; +}; + +const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { + const value = item.value as ShTangledString.Main; + return { + rkey: rkeyFromUri(item.uri), + ownerHandle, + filename: value.filename, + description: value.description, + createdAt: value.createdAt, + lines: value.contents?.split("\n").length ?? 1 + }; +}; + +// the stats sidecar already carries follower counts and viewer status, the +// only extra cost is one miniDoc per did +const resolvePeople = async ( + dids: string[], + stats: Stats, + cache: IdentityCache, + viewerDid?: string +): Promise => { + const unique = [...new Set(dids)]; + + const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); + + const byDid = new Map(); + unique.forEach((did, index) => { + const doc = docs[index]; + const followers = countOf(stats, did, "sh.tangled.graph.follow:subject"); + const following = countOf(stats, did, "sh.tangled.graph.follow:.repo"); + const isSelf = viewerDid === did; + const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source); + const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; + byDid.set( + did, + doc + ? { + did: doc.did, + handle: doc.handle, + followers, + following, + isSelf, + viewerFollowRkey + } + : { did, handle: did, followers, following, isSelf, viewerFollowRkey } + ); + }); + return unique.map((did) => byDid.get(did) as PersonData); +}; + +const resolveVouches = async ( + items: ListItem[], + direction: "incoming" | "outgoing", + cache: IdentityCache +): Promise => { + return Promise.all( + items.map(async (item): Promise => { + const value = item.value as VouchRecord; + const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); + const doc = await cache.resolve(otherDid).catch(() => null); + return { + uri: item.uri, + did: otherDid, + handle: doc?.handle ?? otherDid, + kind: value.kind === "denounce" ? "denounce" : "vouch", + direction, + reason: value.reason, + createdAt: value.createdAt + }; + }) + ); +}; + +const resolveStars = async ( + ctx: BobbinContext, + items: ListItem[], + cache: IdentityCache, + viewerDid?: string +): Promise => { + const repoDids = [ + ...new Set( + items + .map((item) => (item.value as ShTangledFeedStar.Main).subject) + .flatMap((s) => (s && "did" in s && s.did ? [s.did] : [])) + ) + ]; + const enriched = + repoDids.length > 0 + ? await enrich<{ items: ListItem[] }>(ctx, { + xrpc: "sh.tangled.repo.getReposByRepoDids", + params: { dids: repoDids }, + enrich: starDescriptors(viewerDid), + ...(viewerDid ? { viewer: viewerDid } : {}) + }) + : { output: { items: [] }, stats: {} as Stats }; + const reposByDid = new Map( + enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) + ); + const resolved = await Promise.all( + items.map(async (item): Promise => { + const value = item.value as ShTangledFeedStar.Main; + const subject = value.subject; + if (subject && "did" in subject && subject.did) { + const repo = reposByDid.get(subject.did); + if (!repo) return null; + const ownerDid = didFromUri(repo.uri); + const owner = await cache.resolve(ownerDid).catch(() => null); + return { + kind: "repo", + uri: item.uri, + createdAt: value.createdAt, + repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats) + }; + } + if (subject && "uri" in subject && subject.uri) { + const ownerDid = didFromUri(subject.uri); + const owner = await cache.resolve(ownerDid).catch(() => null); + return { + kind: "string", + uri: item.uri, + createdAt: value.createdAt, + ownerHandle: owner?.handle ?? ownerDid, + rkey: rkeyFromUri(subject.uri) + }; + } + return null; + }) + ); + return resolved.filter((star): star is StarData => star !== null); +}; + +export interface ReposPageOptions { + did: string; + handle: string; + viewerDid?: string; + q?: string; + cursor?: string; + limit?: number; +} + +export const fetchReposPage = async ( + ctx: BobbinContext, + { did, handle, viewerDid, q, cursor, limit = PROFILE_PAGE_LIMIT }: ReposPageOptions +): Promise> => { + const descriptors = starDescriptors(viewerDid); + if (!q) { + const enriched = await enrich>(ctx, { + xrpc: "sh.tangled.repo.listRepos", + params: { subject: did, limit, cursor }, + enrich: descriptors, + ...(viewerDid ? { viewer: viewerDid } : {}) + }); + return { + items: enriched.output.items.map((item) => resolveRepoCard(item, handle, enriched.stats)), + cursor: enriched.output.cursor + }; + } + const enriched = await enrich(ctx, { + xrpc: "sh.tangled.search.query", + params: { q, nsid: "sh.tangled.repo", author: did, limit, cursor }, + enrich: descriptors, + ...(viewerDid ? { viewer: viewerDid } : {}) + }); + return { + items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)), + cursor: enriched.output.cursor ?? undefined + }; +}; + +export interface StringsPageOptions { + did: string; + handle: string; + cursor?: string; + limit?: number; +} + +export const fetchStringsPage = async ( + ctx: BobbinContext, + { did, handle, cursor, limit = PROFILE_PAGE_LIMIT }: StringsPageOptions +): Promise> => { + const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { + subject: did as Did, + limit, + cursor + }); + return { items: page.items.map((item) => toStringCard(item, handle)), cursor: page.cursor }; +}; + +export interface StarredPageOptions { + did: string; + viewerDid?: string; + cursor?: string; + cache?: IdentityCache; + limit?: number; +} + +export const fetchStarredPage = async ( + ctx: BobbinContext, + { did, viewerDid, cursor, cache, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions +): Promise> => { + const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", { + subject: did as Did, + limit, + cursor + }); + return { + items: await resolveStars(ctx, page.items, cache ?? new IdentityCache(ctx), viewerDid), + cursor: page.cursor + }; +}; + +export interface PeoplePageOptions { + did: string; + viewerDid?: string; + direction: "followers" | "following"; + cursor?: string; + cache?: IdentityCache; + limit?: number; +} + +export const fetchPeoplePage = async ( + ctx: BobbinContext, + { did, viewerDid, direction, cursor, cache, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions +): Promise> => { + const enriched = await enrich>(ctx, { + xrpc: + direction === "followers" ? "sh.tangled.graph.listFollows" : "sh.tangled.graph.listFollowsBy", + params: { subject: did, limit, cursor }, + enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, + ...(viewerDid ? { viewer: viewerDid } : {}) + }); + const dids = + direction === "followers" + ? enriched.output.items.map((item) => didFromUri(item.uri)) + : enriched.output.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); + return { + items: await resolvePeople(dids, enriched.stats, cache ?? new IdentityCache(ctx), viewerDid), + cursor: enriched.output.cursor + }; +}; + +// null marks an exhausted direction, absent means not started yet +export interface VouchCursors { + incoming?: string | null; + outgoing?: string | null; +} + +export interface VouchesPage { + items: VouchData[]; + cursors: VouchCursors; +} + +export interface VouchesPageOptions { + did: string; + cursors?: VouchCursors; + cache?: IdentityCache; + limit?: number; +} + +export const fetchVouchesPage = async ( + ctx: BobbinContext, + { did, cursors = {}, cache, limit = PROFILE_PAGE_LIMIT }: VouchesPageOptions +): Promise => { + const identity = cache ?? new IdentityCache(ctx); + const [incomingPage, outgoingPage] = await Promise.all([ + cursors.incoming === null + ? { items: [], cursor: undefined } + : fetchPage(ctx, "sh.tangled.graph.listVouches", { + subject: did as Did, + limit, + cursor: cursors.incoming + }), + cursors.outgoing === null + ? { items: [], cursor: undefined } + : fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { + subject: did as Did, + limit, + cursor: cursors.outgoing + }) + ]); + const [incoming, outgoing] = await Promise.all([ + resolveVouches(incomingPage.items, "incoming", identity), + resolveVouches(outgoingPage.items, "outgoing", identity) + ]); + return { + items: [...incoming, ...outgoing].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ), + cursors: { + incoming: incomingPage.cursor ?? null, + outgoing: outgoingPage.cursor ?? null + } + }; +}; + +export interface PinnedOptions { + keys: readonly string[]; + handle: string; + viewerDid?: string; +} + +// pinned keys are repoDids or record uris, fetch them directly instead of +// paging the owner's whole repo list. bobbin drops records it can no longer +// hydrate +export const fetchPinned = async ( + ctx: BobbinContext, + { keys, handle, viewerDid }: PinnedOptions +): Promise => { + const dids = keys.filter((key) => key.startsWith("did:")); + const uris = keys.filter((key) => key.startsWith("at://")); + const descriptors = starDescriptors(viewerDid); + const empty = { output: { items: [] as ListItem[] }, stats: {} as Stats }; + const [byDid, byUri] = await Promise.all([ + dids.length > 0 + ? enrich<{ items: ListItem[] }>(ctx, { + xrpc: "sh.tangled.repo.getReposByRepoDids", + params: { dids }, + enrich: descriptors, + ...(viewerDid ? { viewer: viewerDid } : {}) + }) + : empty, + uris.length > 0 + ? enrich<{ items: ListItem[] }>(ctx, { + xrpc: "sh.tangled.repo.getRepos", + params: { repos: uris }, + enrich: descriptors, + ...(viewerDid ? { viewer: viewerDid } : {}) + }) + : empty + ]); + const cards = new Map(); + for (const item of byDid.output.items) { + const repoDid = (item.value as RepoRecord).repoDid; + if (repoDid) cards.set(repoDid, resolveRepoCard(item, handle, byDid.stats)); + } + for (const item of byUri.output.items) { + cards.set(item.uri, resolveRepoCard(item, handle, byUri.stats)); + } + return keys + .map((key) => cards.get(key)) + .filter((card): card is RepoCardData => card !== undefined); +}; diff --git a/web/src/lib/components/profile/tabs/PeopleTab.svelte b/web/src/lib/components/profile/tabs/PeopleTab.svelte index 6dabe276..2ce617c8 100644 --- a/web/src/lib/components/profile/tabs/PeopleTab.svelte +++ b/web/src/lib/components/profile/tabs/PeopleTab.svelte @@ -1,19 +1,65 @@
{#each people as person (person.did)} {/each} + {#if cursor} + + {/if}
diff --git a/web/src/lib/components/profile/tabs/RepoListTab.svelte b/web/src/lib/components/profile/tabs/RepoListTab.svelte index 2ecea30d..ceebf3b9 100644 --- a/web/src/lib/components/profile/tabs/RepoListTab.svelte +++ b/web/src/lib/components/profile/tabs/RepoListTab.svelte @@ -1,14 +1,57 @@
{/each} + {#if cursor} + + {/if}
diff --git a/web/src/lib/components/profile/tabs/StarredTab.svelte b/web/src/lib/components/profile/tabs/StarredTab.svelte index 01c9f3c3..d5096904 100644 --- a/web/src/lib/components/profile/tabs/StarredTab.svelte +++ b/web/src/lib/components/profile/tabs/StarredTab.svelte @@ -1,11 +1,56 @@
@@ -23,4 +68,7 @@ {/if} {/each} + {#if cursor} + + {/if}
diff --git a/web/src/lib/components/profile/tabs/StringListTab.svelte b/web/src/lib/components/profile/tabs/StringListTab.svelte index 80e79c9e..62fc458e 100644 --- a/web/src/lib/components/profile/tabs/StringListTab.svelte +++ b/web/src/lib/components/profile/tabs/StringListTab.svelte @@ -1,13 +1,53 @@
{#each strings as entry (entry.rkey)} {/each} + {#if cursor} + + {/if}
diff --git a/web/src/lib/components/profile/tabs/VouchTab.svelte b/web/src/lib/components/profile/tabs/VouchTab.svelte index 281951f5..edf62d18 100644 --- a/web/src/lib/components/profile/tabs/VouchTab.svelte +++ b/web/src/lib/components/profile/tabs/VouchTab.svelte @@ -1,15 +1,58 @@
{/each} + {#if !exhausted} + + {/if}
diff --git a/web/src/lib/components/ui/LoadMore.stories.svelte b/web/src/lib/components/ui/LoadMore.stories.svelte new file mode 100644 index 00000000..dca9c2fc --- /dev/null +++ b/web/src/lib/components/ui/LoadMore.stories.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/web/src/lib/components/ui/LoadMore.svelte b/web/src/lib/components/ui/LoadMore.svelte new file mode 100644 index 00000000..31f5bbd3 --- /dev/null +++ b/web/src/lib/components/ui/LoadMore.svelte @@ -0,0 +1,18 @@ + + +
+ +
diff --git a/web/src/routes/[handle]/+page.svelte b/web/src/routes/[handle]/+page.svelte index 9b5db8a7..6c0d52ec 100644 --- a/web/src/routes/[handle]/+page.svelte +++ b/web/src/routes/[handle]/+page.svelte @@ -12,15 +12,57 @@ {#if data.tab === "overview"} {:else if data.tab === "repos"} - + {#key data.repos} + + {/key} {:else if data.tab === "starred"} - + {#key data.stars} + + {/key} {:else if data.tab === "strings"} - + {#key data.strings} + + {/key} {:else if data.tab === "followers"} - + {#key data.people} + + {/key} {:else if data.tab === "following"} - + {#key data.people} + + {/key} {:else if data.tab === "vouches"} - + {#key data.vouches} + + {/key} {/if} diff --git a/web/src/routes/[handle]/+page.ts b/web/src/routes/[handle]/+page.ts index 47db1098..dc761a8d 100644 --- a/web/src/routes/[handle]/+page.ts +++ b/web/src/routes/[handle]/+page.ts @@ -1,36 +1,16 @@ import type { Did } from "@atcute/lexicons/syntax"; import { createBobbinClient } from "$lib/api/client"; -import { fetchPage } from "$lib/api/pagination"; -import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich"; -import { type RepoRecord, type RecordList } from "$lib/api/records"; -import { IdentityCache } from "$lib/api/identity"; -import { didFromUri, rkeyFromUri } from "$lib/api/uri"; import { toHttpError } from "$lib/api/load"; -import type { SearchPage } from "$lib/api/search"; -import type { BobbinContext } from "$lib/api/client"; -import { type VouchRecord } from "$lib/api/graph"; -import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; -import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; -import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; -import type { - RepoCardData, - StringCardData, - PersonData, - VouchData, - StarData -} from "$lib/components/profile/types"; +import { + fetchReposPage, + fetchStringsPage, + fetchStarredPage, + fetchPeoplePage, + fetchVouchesPage, + fetchPinned +} from "$lib/components/profile/pages"; import type { PageLoad } from "./$types"; -const PAGE_LIMIT = 50; - -const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" }; -const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" }; -const FOLLOW_STATS: LinkDescriptor[] = [ - { source: "sh.tangled.graph.follow:subject", type: "count" }, - { source: "sh.tangled.graph.follow:.repo", type: "count" } -]; -const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" }; - const TABS = [ "overview", "repos", @@ -45,164 +25,6 @@ type Tab = (typeof TABS)[number]; const normalizeTab = (raw: string | null): Tab => TABS.includes(raw as Tab) ? (raw as Tab) : "overview"; -interface ListItem { - uri: string; - value: unknown; -} - -const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { - const value = item.value as RepoRecord; - return { - rkey: rkeyFromUri(item.uri), - name: value.name ?? rkeyFromUri(item.uri), - repoDid: value.repoDid ?? "", - ownerHandle, - description: value.description, - knot: value.knot, - createdAt: value.createdAt - }; -}; - -const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => { - const repo = toRepoCard(item, ownerHandle); - if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; - const stars = countOf(stats, repo.repoDid, STAR_COUNT.source); - const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source); - return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; -}; - -const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { - const value = item.value as ShTangledString.Main; - return { - rkey: rkeyFromUri(item.uri), - ownerHandle, - filename: value.filename, - description: value.description, - createdAt: value.createdAt, - lines: value.contents?.split("\n").length ?? 1 - }; -}; - -// the sidecar already carries follower counts and viewer status, so this costs -// no extra requests -const resolvePeople = async ( - ctx: BobbinContext, - dids: string[], - stats: Stats, - viewerDid?: string -): Promise => { - const cache = new IdentityCache(ctx); - const unique = [...new Set(dids)]; - - const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); - - const byDid = new Map(); - unique.forEach((did, index) => { - const doc = docs[index]; - const followers = countOf(stats, did, "sh.tangled.graph.follow:subject"); - const following = countOf(stats, did, "sh.tangled.graph.follow:.repo"); - const isSelf = viewerDid === did; - const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source); - const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; - byDid.set( - did, - doc - ? { - did: doc.did, - handle: doc.handle, - followers, - following, - isSelf, - viewerFollowRkey - } - : { did, handle: did, followers, following, isSelf, viewerFollowRkey } - ); - }); - return unique.map((did) => byDid.get(did) as PersonData); -}; - -const resolveVouches = async ( - ctx: BobbinContext, - items: ListItem[], - direction: "incoming" | "outgoing" -): Promise => { - const cache = new IdentityCache(ctx); - return Promise.all( - items.map(async (item): Promise => { - const value = item.value as VouchRecord; - const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); - const doc = await cache.resolve(otherDid).catch(() => null); - return { - uri: item.uri, - did: otherDid, - handle: doc?.handle ?? otherDid, - kind: value.kind === "denounce" ? "denounce" : "vouch", - direction, - reason: value.reason, - createdAt: value.createdAt - }; - }) - ); -}; - -const resolveStars = async ( - ctx: BobbinContext, - items: ListItem[], - viewerDid?: string -): Promise => { - const cache = new IdentityCache(ctx); - const repoDids = [ - ...new Set( - items - .map((item) => (item.value as ShTangledFeedStar.Main).subject) - .flatMap((s) => (s && "did" in s && s.did ? [s.did] : [])) - ) - ]; - const enriched = - repoDids.length > 0 - ? await enrich>(ctx, { - xrpc: "sh.tangled.repo.getReposByRepoDids", - params: { dids: repoDids }, - enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], - ...(viewerDid ? { viewer: viewerDid } : {}) - }) - : { output: { items: [] }, stats: {} as Stats }; - const reposByDid = new Map( - enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) - ); - const resolved = await Promise.all( - items.map(async (item): Promise => { - const value = item.value as ShTangledFeedStar.Main; - const subject = value.subject; - if (subject && "did" in subject && subject.did) { - const repo = reposByDid.get(subject.did); - if (!repo) return null; - const ownerDid = didFromUri(repo.uri); - const owner = await cache.resolve(ownerDid).catch(() => null); - return { - kind: "repo", - uri: item.uri, - createdAt: value.createdAt, - repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats) - }; - } - if (subject && "uri" in subject && subject.uri) { - const ownerDid = didFromUri(subject.uri); - const owner = await cache.resolve(ownerDid).catch(() => null); - return { - kind: "string", - uri: item.uri, - createdAt: value.createdAt, - ownerHandle: owner?.handle ?? ownerDid, - rkey: rkeyFromUri(subject.uri) - }; - } - return null; - }) - ); - return resolved.filter((star): star is StarData => star !== null); -}; - export const load: PageLoad = async (event) => { const parent = await event.parent(); const tab = normalizeTab(event.url.searchParams.get("tab")); @@ -212,126 +34,45 @@ export const load: PageLoad = async (event) => { const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); const did = parent.identity.did as Did; const handle = parent.identity.handle; + const viewerDid = parent.auth?.did; try { switch (tab) { case "repos": { const q = event.url.searchParams.get("q")?.trim(); - const viewerDid = parent.auth?.did; - const viewerEnrich = viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; - if (!q) { - const enriched = await enrich>(ctx, { - xrpc: "sh.tangled.repo.listRepos", - params: { subject: did, limit: PAGE_LIMIT }, - enrich: viewerEnrich, - ...(viewerDid ? { viewer: viewerDid } : {}) - }); - return { - tab, - repos: enriched.output.items.map((item) => - resolveRepoCard(item, handle, enriched.stats) - ) - }; - } - const enriched = await enrich(ctx, { - xrpc: "sh.tangled.search.query", - params: { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }, - enrich: viewerEnrich, - ...(viewerDid ? { viewer: viewerDid } : {}) - }); - return { - tab, - repos: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)) - }; + const page = await fetchReposPage(ctx, { did, handle, viewerDid, q: q || undefined }); + return { tab: "repos" as const, repos: page.items, cursor: page.cursor }; } case "strings": { - const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { - subject: did, - limit: PAGE_LIMIT - }); - return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; - } - case "followers": { - const viewerDid = parent.auth?.did; - const enriched = await enrich>(ctx, { - xrpc: "sh.tangled.graph.listFollows", - params: { subject: did, limit: PAGE_LIMIT }, - enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, - ...(viewerDid ? { viewer: viewerDid } : {}) - }); - const dids = enriched.output.items.map((item) => didFromUri(item.uri)); - return { - tab, - people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) - }; + const page = await fetchStringsPage(ctx, { did, handle }); + return { tab: "strings" as const, strings: page.items, cursor: page.cursor }; } + case "followers": case "following": { - const viewerDid = parent.auth?.did; - const enriched = await enrich>(ctx, { - xrpc: "sh.tangled.graph.listFollowsBy", - params: { subject: did, limit: PAGE_LIMIT }, - enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, - ...(viewerDid ? { viewer: viewerDid } : {}) - }); - const dids = enriched.output.items.map( - (item) => (item.value as ShTangledGraphFollow.Main).subject - ); - return { - tab, - people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) - }; + const page = await fetchPeoplePage(ctx, { did, viewerDid, direction: tab }); + return { tab, people: page.items, cursor: page.cursor } as const; } case "vouches": { - const [incomingPage, outgoingPage] = await Promise.all([ - fetchPage(ctx, "sh.tangled.graph.listVouches", { subject: did, limit: PAGE_LIMIT }), - fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { subject: did, limit: PAGE_LIMIT }) - ]); - const [incoming, outgoing] = await Promise.all([ - resolveVouches(ctx, incomingPage.items, "incoming"), - resolveVouches(ctx, outgoingPage.items, "outgoing") - ]); - const vouches = [...incoming, ...outgoing].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); + const page = await fetchVouchesPage(ctx, { did }); return { - tab, - vouches, - isSelf: parent.auth?.did === did, + tab: "vouches" as const, + vouches: page.items, + cursors: page.cursors, + isSelf: viewerDid === did, profileHandle: handle }; } case "starred": { - const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", { - subject: did, - limit: PAGE_LIMIT - }); - return { - tab, - stars: await resolveStars(ctx, page.items, parent.auth?.did) - }; + const page = await fetchStarredPage(ctx, { did, viewerDid }); + return { tab: "starred" as const, stars: page.items, cursor: page.cursor }; } case "overview": default: { - const viewerDid = parent.auth?.did; - const enriched = await enrich>(ctx, { - xrpc: "sh.tangled.repo.listRepos", - params: { subject: did, limit: PAGE_LIMIT }, - enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], - ...(viewerDid ? { viewer: viewerDid } : {}) + const pinned = await fetchPinned(ctx, { + keys: parent.profile?.pinnedRepositories ?? [], + handle, + viewerDid }); - - const pinnedKeys = parent.profile?.pinnedRepositories ?? []; - const byKey = new Map(); - for (const item of enriched.output.items) { - const value = item.value as RepoRecord; - if (value.repoDid) byKey.set(value.repoDid, item); - byKey.set(item.uri, item); - } - const pinnedItems = pinnedKeys - .map((key) => byKey.get(key)) - .filter((item): item is ListItem => item !== undefined); - const pinned = pinnedItems.map((item) => resolveRepoCard(item, handle, enriched.stats)); - return { tab: "overview" as const, overview: { pinned } }; } }