diff --git a/web/src/lib/api/descriptors.ts b/web/src/lib/api/descriptors.ts index 9655c8cc7..3520ed417 100644 --- a/web/src/lib/api/descriptors.ts +++ b/web/src/lib/api/descriptors.ts @@ -1,9 +1,11 @@ import { TYPE_COUNT, TYPE_MINIDOC, + TYPE_REPO_VIEW, TYPE_VIEWER, type CountDescriptor, type DocDescriptor, + type RepoViewDescriptor, type ViewerDescriptor } from "$lib/api/enrich"; @@ -29,6 +31,9 @@ export const REACTION_AUTHOR_DOCS: DocDescriptor = { type: TYPE_MINIDOC }; +// without a viewer the view has no star state, so cards can't tell starred from unknown +export const REPO_VIEW: RepoViewDescriptor = { source: "sh.tangled.repo:.", type: TYPE_REPO_VIEW }; + export const REPO_COUNT: CountDescriptor = { source: "sh.tangled.repo:subject", type: TYPE_COUNT }; export const STRING_COUNT: CountDescriptor = { source: "sh.tangled.string:subject", diff --git a/web/src/lib/api/enrich.ts b/web/src/lib/api/enrich.ts index 7e6bb8b06..2f0391f5c 100644 --- a/web/src/lib/api/enrich.ts +++ b/web/src/lib/api/enrich.ts @@ -3,13 +3,16 @@ import type { Nsid } from "@atcute/lexicons/syntax"; import { INVALID_HANDLE, type MiniDoc } from "$lib/api/identity"; import { jsonPost } from "$lib/api/_request"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; +import type * as ShTangledRepoDefs from "$lib/api/lexicons/types/sh/tangled/repo/defs"; // payload types, also the keys payloads land under in the data sidecar export const TYPE_COUNT = "sh.tangled.query.enrichResponse#count"; export const TYPE_VIEWER = "sh.tangled.query.enrichResponse#viewer"; export const TYPE_MINIDOC = "blue.microcosm.identity.resolveMiniDoc"; +export const TYPE_REPO_VIEW = "sh.tangled.repo.defs#repoViewBasic"; -export type PayloadType = typeof TYPE_COUNT | typeof TYPE_VIEWER | typeof TYPE_MINIDOC; +export type PayloadType = + typeof TYPE_COUNT | typeof TYPE_VIEWER | typeof TYPE_MINIDOC | typeof TYPE_REPO_VIEW; export type RecordPath = string & {}; export type EnvelopePath = ".repo" | ".collection" | ".rkey" | "."; @@ -26,6 +29,7 @@ export interface LinkDescriptor { export type CountDescriptor = LinkDescriptor; export type ViewerDescriptor = LinkDescriptor; export type DocDescriptor = LinkDescriptor; +export type RepoViewDescriptor = LinkDescriptor; // data[ref][source][type] = payload, payload shape depends on the type export type Sidecar = Record>>; @@ -102,6 +106,13 @@ export const miniDocOf = ( of: DocDescriptor ): MiniDoc | undefined => payloadOf(data, did, of) as MiniDoc | undefined; +export const repoViewOf = ( + data: Sidecar, + ref: string | undefined, + of: RepoViewDescriptor +): ShTangledRepoDefs.RepoViewBasic | undefined => + payloadOf(data, ref, of) as ShTangledRepoDefs.RepoViewBasic | undefined; + export const handleOf = (data: Sidecar, did: string | undefined, of: DocDescriptor): string => miniDocOf(data, did, of)?.handle ?? INVALID_HANDLE; diff --git a/web/src/lib/components/profile/pages.test.ts b/web/src/lib/components/profile/pages.test.ts index cfb68b707..cfcf227f9 100644 --- a/web/src/lib/components/profile/pages.test.ts +++ b/web/src/lib/components/profile/pages.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { createBobbinClient } from "$lib/api/client"; -import { TYPE_MINIDOC } from "$lib/api/enrich"; +import { TYPE_MINIDOC, TYPE_REPO_VIEW } from "$lib/api/enrich"; import { fetchPeoplePage, fetchPinned, @@ -34,7 +34,7 @@ describe("fetchStarredPage", () => { expect(body.enrich[0].targets).toEqual(["items[].value.subject.uri"]); }); - it("targets repo stats and owner minidocs independently", async () => { + it("targets repo views and owner minidocs independently", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -61,8 +61,8 @@ describe("fetchStarredPage", () => { const body = requestBody(fetchMock, 1); expect(body.enrich).toEqual([ { - source: "sh.tangled.feed.star:subject", - type: "sh.tangled.query.enrichResponse#count", + source: "sh.tangled.repo:.", + type: TYPE_REPO_VIEW, targets: ["items[].value.repoDid"] }, { @@ -76,7 +76,7 @@ describe("fetchStarredPage", () => { it.each([ [undefined, "items[].value.repoDid"], ["needle", "hits[].value.repoDid"] - ])("targets repo stats for list/search results", async (q, expectedTarget) => { + ])("targets repo views for list/search results", async (q, expectedTarget) => { const fetchMock = vi.fn().mockResolvedValue(enriched()); const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); @@ -88,10 +88,62 @@ describe("fetchStarredPage", () => { }); const body = requestBody(fetchMock); - expect(body.enrich).toHaveLength(2); - expect(body.enrich.every((descriptor) => descriptor.targets?.[0] === expectedTarget)).toBe( - true + expect(body.enrich).toEqual([ + { source: "sh.tangled.repo:.", type: TYPE_REPO_VIEW, targets: [expectedTarget] } + ]); + }); + + it.each([ + [{ star: "at://did:plc:viewer/sh.tangled.feed.star/abc" }, "abc"], + [{}, null], + [undefined, undefined] + ])("fills repo cards from hydrated views", async (viewer, viewerStarRkey) => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + output: { + items: [ + { + uri: "at://did:plc:alice/sh.tangled.repo/reef", + value: { + repoDid: "did:plc:reef", + name: "reef", + createdAt: "2026-08-01T00:00:00Z" + } + } + ] + }, + data: { + "did:plc:reef": { + "sh.tangled.repo:.": { + [TYPE_REPO_VIEW]: { + did: "did:plc:reef", + slug: "reef", + owner: { did: "did:plc:alice", handle: "handle.invalid" }, + createdAt: "2026-08-01T00:00:00Z", + starCount: 4, + forkCount: 2, + openIssueCount: 3, + openPullCount: 1, + viewer + } + } + } + } + }) ); + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); + + const page = await fetchReposPage(ctx, { did: "did:plc:alice", handle: "alice.test" }); + + expect(page.items[0]).toMatchObject({ + name: "reef", + ownerHandle: "alice.test", + stars: 4, + forks: 2, + issues: 3, + pulls: 1 + }); + expect(page.items[0].viewerStarRkey).toBe(viewerStarRkey); }); it.each([ diff --git a/web/src/lib/components/profile/pages.ts b/web/src/lib/components/profile/pages.ts index 88e1f2e41..5a95eb19e 100644 --- a/web/src/lib/components/profile/pages.ts +++ b/web/src/lib/components/profile/pages.ts @@ -5,6 +5,7 @@ import { authorOf, countOf, handleOf, + repoViewOf, target, targetAll, viewerRkeyOf, @@ -19,9 +20,8 @@ import { FOLLOWING_DOCS, FOLLOW_VIEWER, REPO_OWNER_DOCS, - STAR_COUNT, - STAR_SUBJECT_DOCS, - STAR_VIEWER + REPO_VIEW, + STAR_SUBJECT_DOCS } from "$lib/api/descriptors"; import { fetchPage } from "$lib/api/pagination"; import { IdentityCache, INVALID_HANDLE, type MiniDoc } from "$lib/api/identity"; @@ -53,6 +53,7 @@ import type { ProfileActivityMonth } from "$lib/components/profile/types"; import { repoKey } from "$lib/components/profile/types"; +import { toRepoCard as viewToRepoCard } from "$lib/components/timeline/mappers"; export const PROFILE_PAGE_LIMIT = 30; export interface ListPage { @@ -70,7 +71,6 @@ type RecordPage = { cursor?: string; }; -const REPO_STATS = [STAR_COUNT, STAR_VIEWER]; const FOLLOW_STATS = [FOLLOWER_COUNT, FOLLOWING_COUNT, FOLLOW_VIEWER]; const emptyItems = (): Enriched<{ items: T[] }> => ({ output: { items: [] }, data: {} }); @@ -86,13 +86,18 @@ const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { }; }; +// no view means a legacy repo without a repoDid, or one bobbin couldn't build const resolveRepoCard = (item: ListItem, ownerHandle: string, data: Sidecar): RepoCardData => { const repo = toRepoCard(item, ownerHandle); - if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; + const view = repoViewOf(data, repo.repoDid || undefined, REPO_VIEW); + if (!view) return { ...repo, stars: 0, viewerStarRkey: null }; + const card = viewToRepoCard(view); + // a view without viewer state was built for nobody, so the star is unknown, not absent return { ...repo, - stars: countOf(data, repo.repoDid, STAR_COUNT), - viewerStarRkey: viewerRkeyOf(data, repo.repoDid, STAR_VIEWER) + ...card, + ownerHandle, + viewerStarRkey: view.viewer ? card.viewerStarRkey : undefined }; }; @@ -176,7 +181,7 @@ const resolveStars = async ( xrpc: "sh.tangled.repo.getReposByRepoDids", params: { dids: repoDids }, enrich: [ - ...targetAll(REPO_STATS, ["items[].value.repoDid"]), + target(REPO_VIEW, ["items[].value.repoDid"]), target(REPO_OWNER_DOCS, ["items[].uri"]) ], viewer: viewerDid @@ -231,7 +236,7 @@ export const fetchReposPage = async ( const enriched = await enrich>(ctx, { xrpc: "sh.tangled.repo.listRepos", params: { subject: did, limit, offset }, - enrich: targetAll(REPO_STATS, ["items[].value.repoDid"]), + enrich: [target(REPO_VIEW, ["items[].value.repoDid"])], viewer: viewerDid }); const cards = new Map(); @@ -247,7 +252,7 @@ export const fetchReposPage = async ( const enriched = await enrich(ctx, { xrpc: "sh.tangled.search.query", params: { q, nsid: "sh.tangled.repo", author: did, limit, offset }, - enrich: targetAll(REPO_STATS, ["hits[].value.repoDid"]), + enrich: [target(REPO_VIEW, ["hits[].value.repoDid"])], viewer: viewerDid }); return { @@ -412,7 +417,7 @@ export const fetchPinned = async ( xrpc: "sh.tangled.repo.getReposByRepoDids", params: { dids }, enrich: [ - ...targetAll(REPO_STATS, ["items[].value.repoDid"]), + target(REPO_VIEW, ["items[].value.repoDid"]), target(REPO_OWNER_DOCS, ["items[].uri"]) ], viewer: viewerDid @@ -423,7 +428,7 @@ export const fetchPinned = async ( xrpc: "sh.tangled.repo.getRepos", params: { repos: uris }, enrich: [ - ...targetAll(REPO_STATS, ["items[].value.repoDid"]), + target(REPO_VIEW, ["items[].value.repoDid"]), target(REPO_OWNER_DOCS, ["items[].uri"]) ], viewer: viewerDid diff --git a/web/src/lib/components/timeline/mappers.ts b/web/src/lib/components/timeline/mappers.ts index ead8b47d4..c0aced85b 100644 --- a/web/src/lib/components/timeline/mappers.ts +++ b/web/src/lib/components/timeline/mappers.ts @@ -11,6 +11,9 @@ export const toRepoCard = (repo: ShTangledRepoDefs.RepoViewBasic): RepoCardData description: repo.description, createdAt: repo.createdAt, stars: repo.starCount, + forks: repo.forkCount, + issues: repo.openIssueCount, + pulls: repo.openPullCount, viewerStarRkey: repo.viewer?.star ? rkeyFromUri(repo.viewer.star) : null });