This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web: pagination for profile lists

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Aug 1, 2026, 4:47 AM +0300) commit c1722e12 parent 45f54a2c change-id vqrnpkto
+760 -302
+410
web/src/lib/components/profile/pages.ts
··· 1 + // page fetchers for the profile tabs, shared between the route load (first 2 + // page) and the tab components (load more). pass a shared IdentityCache to 3 + // dedupe across pages 4 + 5 + import type { BobbinContext } from "$lib/api/client"; 6 + import type { Did } from "@atcute/lexicons/syntax"; 7 + import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich"; 8 + import { fetchPage } from "$lib/api/pagination"; 9 + import { IdentityCache } from "$lib/api/identity"; 10 + import type { RecordView, RepoRecord } from "$lib/api/records"; 11 + import type { SearchPage } from "$lib/api/search"; 12 + import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 13 + import type { VouchRecord } from "$lib/api/graph"; 14 + import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; 15 + import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; 16 + import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; 17 + import type { RepoCardData, StringCardData, PersonData, VouchData, StarData } from "./types"; 18 + 19 + // same page size as the appview's lists 20 + export const PROFILE_PAGE_LIMIT = 30; 21 + 22 + export interface ListPage<T> { 23 + items: T[]; 24 + cursor?: string; 25 + } 26 + 27 + interface ListItem { 28 + uri: string; 29 + value: unknown; 30 + } 31 + 32 + // the hand-rolled RecordList omits the cursor the wire output carries 33 + type RecordPage<V> = { 34 + items: RecordView<V>[]; 35 + cursor?: string; 36 + }; 37 + 38 + const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" }; 39 + const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" }; 40 + const FOLLOW_STATS: LinkDescriptor[] = [ 41 + { source: "sh.tangled.graph.follow:subject", type: "count" }, 42 + { source: "sh.tangled.graph.follow:.repo", type: "count" } 43 + ]; 44 + const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" }; 45 + 46 + const starDescriptors = (viewerDid: string | undefined) => 47 + viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; 48 + 49 + const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { 50 + const value = item.value as RepoRecord; 51 + return { 52 + rkey: rkeyFromUri(item.uri), 53 + name: value.name ?? rkeyFromUri(item.uri), 54 + repoDid: value.repoDid ?? "", 55 + ownerHandle, 56 + description: value.description, 57 + knot: value.knot, 58 + createdAt: value.createdAt 59 + }; 60 + }; 61 + 62 + const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => { 63 + const repo = toRepoCard(item, ownerHandle); 64 + if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 65 + const stars = countOf(stats, repo.repoDid, STAR_COUNT.source); 66 + const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source); 67 + return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; 68 + }; 69 + 70 + const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { 71 + const value = item.value as ShTangledString.Main; 72 + return { 73 + rkey: rkeyFromUri(item.uri), 74 + ownerHandle, 75 + filename: value.filename, 76 + description: value.description, 77 + createdAt: value.createdAt, 78 + lines: value.contents?.split("\n").length ?? 1 79 + }; 80 + }; 81 + 82 + // the stats sidecar already carries follower counts and viewer status, the 83 + // only extra cost is one miniDoc per did 84 + const resolvePeople = async ( 85 + dids: string[], 86 + stats: Stats, 87 + cache: IdentityCache, 88 + viewerDid?: string 89 + ): Promise<PersonData[]> => { 90 + const unique = [...new Set(dids)]; 91 + 92 + const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); 93 + 94 + const byDid = new Map<string, PersonData>(); 95 + unique.forEach((did, index) => { 96 + const doc = docs[index]; 97 + const followers = countOf(stats, did, "sh.tangled.graph.follow:subject"); 98 + const following = countOf(stats, did, "sh.tangled.graph.follow:.repo"); 99 + const isSelf = viewerDid === did; 100 + const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source); 101 + const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; 102 + byDid.set( 103 + did, 104 + doc 105 + ? { 106 + did: doc.did, 107 + handle: doc.handle, 108 + followers, 109 + following, 110 + isSelf, 111 + viewerFollowRkey 112 + } 113 + : { did, handle: did, followers, following, isSelf, viewerFollowRkey } 114 + ); 115 + }); 116 + return unique.map((did) => byDid.get(did) as PersonData); 117 + }; 118 + 119 + const resolveVouches = async ( 120 + items: ListItem[], 121 + direction: "incoming" | "outgoing", 122 + cache: IdentityCache 123 + ): Promise<VouchData[]> => { 124 + return Promise.all( 125 + items.map(async (item): Promise<VouchData> => { 126 + const value = item.value as VouchRecord; 127 + const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); 128 + const doc = await cache.resolve(otherDid).catch(() => null); 129 + return { 130 + uri: item.uri, 131 + did: otherDid, 132 + handle: doc?.handle ?? otherDid, 133 + kind: value.kind === "denounce" ? "denounce" : "vouch", 134 + direction, 135 + reason: value.reason, 136 + createdAt: value.createdAt 137 + }; 138 + }) 139 + ); 140 + }; 141 + 142 + const resolveStars = async ( 143 + ctx: BobbinContext, 144 + items: ListItem[], 145 + cache: IdentityCache, 146 + viewerDid?: string 147 + ): Promise<StarData[]> => { 148 + const repoDids = [ 149 + ...new Set( 150 + items 151 + .map((item) => (item.value as ShTangledFeedStar.Main).subject) 152 + .flatMap((s) => (s && "did" in s && s.did ? [s.did] : [])) 153 + ) 154 + ]; 155 + const enriched = 156 + repoDids.length > 0 157 + ? await enrich<{ items: ListItem[] }>(ctx, { 158 + xrpc: "sh.tangled.repo.getReposByRepoDids", 159 + params: { dids: repoDids }, 160 + enrich: starDescriptors(viewerDid), 161 + ...(viewerDid ? { viewer: viewerDid } : {}) 162 + }) 163 + : { output: { items: [] }, stats: {} as Stats }; 164 + const reposByDid = new Map( 165 + enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) 166 + ); 167 + const resolved = await Promise.all( 168 + items.map(async (item): Promise<StarData | null> => { 169 + const value = item.value as ShTangledFeedStar.Main; 170 + const subject = value.subject; 171 + if (subject && "did" in subject && subject.did) { 172 + const repo = reposByDid.get(subject.did); 173 + if (!repo) return null; 174 + const ownerDid = didFromUri(repo.uri); 175 + const owner = await cache.resolve(ownerDid).catch(() => null); 176 + return { 177 + kind: "repo", 178 + uri: item.uri, 179 + createdAt: value.createdAt, 180 + repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats) 181 + }; 182 + } 183 + if (subject && "uri" in subject && subject.uri) { 184 + const ownerDid = didFromUri(subject.uri); 185 + const owner = await cache.resolve(ownerDid).catch(() => null); 186 + return { 187 + kind: "string", 188 + uri: item.uri, 189 + createdAt: value.createdAt, 190 + ownerHandle: owner?.handle ?? ownerDid, 191 + rkey: rkeyFromUri(subject.uri) 192 + }; 193 + } 194 + return null; 195 + }) 196 + ); 197 + return resolved.filter((star): star is StarData => star !== null); 198 + }; 199 + 200 + export interface ReposPageOptions { 201 + did: string; 202 + handle: string; 203 + viewerDid?: string; 204 + q?: string; 205 + cursor?: string; 206 + limit?: number; 207 + } 208 + 209 + export const fetchReposPage = async ( 210 + ctx: BobbinContext, 211 + { did, handle, viewerDid, q, cursor, limit = PROFILE_PAGE_LIMIT }: ReposPageOptions 212 + ): Promise<ListPage<RepoCardData>> => { 213 + const descriptors = starDescriptors(viewerDid); 214 + if (!q) { 215 + const enriched = await enrich<RecordPage<RepoRecord>>(ctx, { 216 + xrpc: "sh.tangled.repo.listRepos", 217 + params: { subject: did, limit, cursor }, 218 + enrich: descriptors, 219 + ...(viewerDid ? { viewer: viewerDid } : {}) 220 + }); 221 + return { 222 + items: enriched.output.items.map((item) => resolveRepoCard(item, handle, enriched.stats)), 223 + cursor: enriched.output.cursor 224 + }; 225 + } 226 + const enriched = await enrich<SearchPage>(ctx, { 227 + xrpc: "sh.tangled.search.query", 228 + params: { q, nsid: "sh.tangled.repo", author: did, limit, cursor }, 229 + enrich: descriptors, 230 + ...(viewerDid ? { viewer: viewerDid } : {}) 231 + }); 232 + return { 233 + items: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)), 234 + cursor: enriched.output.cursor ?? undefined 235 + }; 236 + }; 237 + 238 + export interface StringsPageOptions { 239 + did: string; 240 + handle: string; 241 + cursor?: string; 242 + limit?: number; 243 + } 244 + 245 + export const fetchStringsPage = async ( 246 + ctx: BobbinContext, 247 + { did, handle, cursor, limit = PROFILE_PAGE_LIMIT }: StringsPageOptions 248 + ): Promise<ListPage<StringCardData>> => { 249 + const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { 250 + subject: did as Did, 251 + limit, 252 + cursor 253 + }); 254 + return { items: page.items.map((item) => toStringCard(item, handle)), cursor: page.cursor }; 255 + }; 256 + 257 + export interface StarredPageOptions { 258 + did: string; 259 + viewerDid?: string; 260 + cursor?: string; 261 + cache?: IdentityCache; 262 + limit?: number; 263 + } 264 + 265 + export const fetchStarredPage = async ( 266 + ctx: BobbinContext, 267 + { did, viewerDid, cursor, cache, limit = PROFILE_PAGE_LIMIT }: StarredPageOptions 268 + ): Promise<ListPage<StarData>> => { 269 + const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", { 270 + subject: did as Did, 271 + limit, 272 + cursor 273 + }); 274 + return { 275 + items: await resolveStars(ctx, page.items, cache ?? new IdentityCache(ctx), viewerDid), 276 + cursor: page.cursor 277 + }; 278 + }; 279 + 280 + export interface PeoplePageOptions { 281 + did: string; 282 + viewerDid?: string; 283 + direction: "followers" | "following"; 284 + cursor?: string; 285 + cache?: IdentityCache; 286 + limit?: number; 287 + } 288 + 289 + export const fetchPeoplePage = async ( 290 + ctx: BobbinContext, 291 + { did, viewerDid, direction, cursor, cache, limit = PROFILE_PAGE_LIMIT }: PeoplePageOptions 292 + ): Promise<ListPage<PersonData>> => { 293 + const enriched = await enrich<RecordPage<ShTangledGraphFollow.Main>>(ctx, { 294 + xrpc: 295 + direction === "followers" ? "sh.tangled.graph.listFollows" : "sh.tangled.graph.listFollowsBy", 296 + params: { subject: did, limit, cursor }, 297 + enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, 298 + ...(viewerDid ? { viewer: viewerDid } : {}) 299 + }); 300 + const dids = 301 + direction === "followers" 302 + ? enriched.output.items.map((item) => didFromUri(item.uri)) 303 + : enriched.output.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); 304 + return { 305 + items: await resolvePeople(dids, enriched.stats, cache ?? new IdentityCache(ctx), viewerDid), 306 + cursor: enriched.output.cursor 307 + }; 308 + }; 309 + 310 + // null marks an exhausted direction, absent means not started yet 311 + export interface VouchCursors { 312 + incoming?: string | null; 313 + outgoing?: string | null; 314 + } 315 + 316 + export interface VouchesPage { 317 + items: VouchData[]; 318 + cursors: VouchCursors; 319 + } 320 + 321 + export interface VouchesPageOptions { 322 + did: string; 323 + cursors?: VouchCursors; 324 + cache?: IdentityCache; 325 + limit?: number; 326 + } 327 + 328 + export const fetchVouchesPage = async ( 329 + ctx: BobbinContext, 330 + { did, cursors = {}, cache, limit = PROFILE_PAGE_LIMIT }: VouchesPageOptions 331 + ): Promise<VouchesPage> => { 332 + const identity = cache ?? new IdentityCache(ctx); 333 + const [incomingPage, outgoingPage] = await Promise.all([ 334 + cursors.incoming === null 335 + ? { items: [], cursor: undefined } 336 + : fetchPage(ctx, "sh.tangled.graph.listVouches", { 337 + subject: did as Did, 338 + limit, 339 + cursor: cursors.incoming 340 + }), 341 + cursors.outgoing === null 342 + ? { items: [], cursor: undefined } 343 + : fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { 344 + subject: did as Did, 345 + limit, 346 + cursor: cursors.outgoing 347 + }) 348 + ]); 349 + const [incoming, outgoing] = await Promise.all([ 350 + resolveVouches(incomingPage.items, "incoming", identity), 351 + resolveVouches(outgoingPage.items, "outgoing", identity) 352 + ]); 353 + return { 354 + items: [...incoming, ...outgoing].sort( 355 + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 356 + ), 357 + cursors: { 358 + incoming: incomingPage.cursor ?? null, 359 + outgoing: outgoingPage.cursor ?? null 360 + } 361 + }; 362 + }; 363 + 364 + export interface PinnedOptions { 365 + keys: readonly string[]; 366 + handle: string; 367 + viewerDid?: string; 368 + } 369 + 370 + // pinned keys are repoDids or record uris, fetch them directly instead of 371 + // paging the owner's whole repo list. bobbin drops records it can no longer 372 + // hydrate 373 + export const fetchPinned = async ( 374 + ctx: BobbinContext, 375 + { keys, handle, viewerDid }: PinnedOptions 376 + ): Promise<RepoCardData[]> => { 377 + const dids = keys.filter((key) => key.startsWith("did:")); 378 + const uris = keys.filter((key) => key.startsWith("at://")); 379 + const descriptors = starDescriptors(viewerDid); 380 + const empty = { output: { items: [] as ListItem[] }, stats: {} as Stats }; 381 + const [byDid, byUri] = await Promise.all([ 382 + dids.length > 0 383 + ? enrich<{ items: ListItem[] }>(ctx, { 384 + xrpc: "sh.tangled.repo.getReposByRepoDids", 385 + params: { dids }, 386 + enrich: descriptors, 387 + ...(viewerDid ? { viewer: viewerDid } : {}) 388 + }) 389 + : empty, 390 + uris.length > 0 391 + ? enrich<{ items: ListItem[] }>(ctx, { 392 + xrpc: "sh.tangled.repo.getRepos", 393 + params: { repos: uris }, 394 + enrich: descriptors, 395 + ...(viewerDid ? { viewer: viewerDid } : {}) 396 + }) 397 + : empty 398 + ]); 399 + const cards = new Map<string, RepoCardData>(); 400 + for (const item of byDid.output.items) { 401 + const repoDid = (item.value as RepoRecord).repoDid; 402 + if (repoDid) cards.set(repoDid, resolveRepoCard(item, handle, byDid.stats)); 403 + } 404 + for (const item of byUri.output.items) { 405 + cards.set(item.uri, resolveRepoCard(item, handle, byUri.stats)); 406 + } 407 + return keys 408 + .map((key) => cards.get(key)) 409 + .filter((card): card is RepoCardData => card !== undefined); 410 + };
+48 -2
web/src/lib/components/profile/tabs/PeopleTab.svelte
··· 1 1 <script lang="ts"> 2 + import { untrack } from "svelte"; 3 + import { getAuth } from "$lib/auth.svelte"; 4 + import { createBobbinClient } from "$lib/api/client"; 5 + import { IdentityCache } from "$lib/api/identity"; 6 + import { fetchPeoplePage } from "../pages"; 2 7 import FollowCard from "../FollowCard.svelte"; 3 8 import Section from "$lib/components/ui/Section.svelte"; 9 + import LoadMore from "$lib/components/ui/LoadMore.svelte"; 4 10 import type { PersonData } from "../types"; 5 11 6 12 interface Props { 7 - people: PersonData[]; 13 + initial: PersonData[]; 14 + cursor?: string; 15 + did: string; 16 + direction: "followers" | "following"; 8 17 title: string; 9 18 emptyMessage: string; 10 19 } 11 20 12 - let { people, title, emptyMessage }: Props = $props(); 21 + let { initial, cursor: initialCursor, did, direction, title, emptyMessage }: Props = $props(); 22 + 23 + const auth = getAuth(); 24 + 25 + let extra = $state<PersonData[]>([]); 26 + let cursor = $state(untrack(() => initialCursor)); 27 + let loading = $state(false); 28 + let failed = $state(false); 29 + // shared across pages so repeat dids only resolve once 30 + let identityCache: IdentityCache | undefined; 31 + 32 + const people = $derived([...initial, ...extra]); 33 + 34 + const loadMore = async () => { 35 + if (loading || !cursor) return; 36 + loading = true; 37 + failed = false; 38 + try { 39 + const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 40 + identityCache ??= new IdentityCache(ctx); 41 + const next = await fetchPeoplePage(ctx, { 42 + did, 43 + viewerDid: auth.currentDid ?? undefined, 44 + direction, 45 + cursor, 46 + cache: identityCache 47 + }); 48 + extra = [...extra, ...next.items]; 49 + cursor = next.cursor; 50 + } catch { 51 + failed = true; 52 + } finally { 53 + loading = false; 54 + } 55 + }; 13 56 </script> 14 57 15 58 <Section {title} empty={people.length === 0} {emptyMessage} listClass="flex flex-col gap-8"> 16 59 {#each people as person (person.did)} 17 60 <FollowCard {person} /> 18 61 {/each} 62 + {#if cursor} 63 + <LoadMore {loading} {failed} onclick={loadMore} /> 64 + {/if} 19 65 </Section>
+47 -1
web/src/lib/components/profile/tabs/RepoListTab.svelte
··· 1 1 <script lang="ts"> 2 + import { untrack } from "svelte"; 2 3 import { page } from "$app/state"; 3 4 import { resolve } from "$app/paths"; 5 + import { getAuth } from "$lib/auth.svelte"; 6 + import { createBobbinClient } from "$lib/api/client"; 7 + import { fetchReposPage } from "../pages"; 4 8 import RepoCard from "$lib/components/repo/RepoCard.svelte"; 5 9 import Section from "$lib/components/ui/Section.svelte"; 10 + import LoadMore from "$lib/components/ui/LoadMore.svelte"; 6 11 import { repoKey, type RepoCardData } from "../types"; 7 12 import Search from "$icon/search"; 8 13 import X from "$icon/x"; 9 14 10 - let { repos }: { repos: RepoCardData[] } = $props(); 15 + interface Props { 16 + initial: RepoCardData[]; 17 + cursor?: string; 18 + did: string; 19 + handle: string; 20 + } 21 + 22 + let { initial, cursor: initialCursor, did, handle }: Props = $props(); 23 + 24 + const auth = getAuth(); 25 + 26 + let extra = $state<RepoCardData[]>([]); 27 + let cursor = $state(untrack(() => initialCursor)); 28 + let loading = $state(false); 29 + let failed = $state(false); 30 + 31 + const repos = $derived([...initial, ...extra]); 11 32 let searchQuery = $derived(page.url.searchParams.get("q") ?? ""); 33 + 34 + const loadMore = async () => { 35 + if (loading || !cursor) return; 36 + loading = true; 37 + failed = false; 38 + try { 39 + const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 40 + const next = await fetchReposPage(ctx, { 41 + did, 42 + handle, 43 + viewerDid: auth.currentDid ?? undefined, 44 + q: searchQuery || undefined, 45 + cursor 46 + }); 47 + extra = [...extra, ...next.items]; 48 + cursor = next.cursor; 49 + } catch { 50 + failed = true; 51 + } finally { 52 + loading = false; 53 + } 54 + }; 12 55 </script> 13 56 14 57 <Section ··· 46 89 {#each repos as repo (repoKey(repo))} 47 90 <RepoCard {repo} showOwner={false} /> 48 91 {/each} 92 + {#if cursor} 93 + <LoadMore {loading} {failed} onclick={loadMore} /> 94 + {/if} 49 95 </Section>
+49 -1
web/src/lib/components/profile/tabs/StarredTab.svelte
··· 1 1 <script lang="ts"> 2 + import { untrack } from "svelte"; 2 3 import { resolve } from "$app/paths"; 4 + import { getAuth } from "$lib/auth.svelte"; 5 + import { createBobbinClient } from "$lib/api/client"; 6 + import { IdentityCache } from "$lib/api/identity"; 7 + import { fetchStarredPage } from "../pages"; 3 8 import RepoCard from "$lib/components/repo/RepoCard.svelte"; 4 9 import Card from "$lib/components/ui/Card.svelte"; 5 10 import Section from "$lib/components/ui/Section.svelte"; 11 + import LoadMore from "$lib/components/ui/LoadMore.svelte"; 6 12 import type { StarData } from "../types"; 7 13 8 - let { stars }: { stars: StarData[] } = $props(); 14 + interface Props { 15 + initial: StarData[]; 16 + cursor?: string; 17 + did: string; 18 + } 19 + 20 + let { initial, cursor: initialCursor, did }: Props = $props(); 21 + 22 + const auth = getAuth(); 23 + 24 + let extra = $state<StarData[]>([]); 25 + let cursor = $state(untrack(() => initialCursor)); 26 + let loading = $state(false); 27 + let failed = $state(false); 28 + // shared across pages so repeat repo owners only resolve once 29 + let identityCache: IdentityCache | undefined; 30 + 31 + const stars = $derived([...initial, ...extra]); 32 + 33 + const loadMore = async () => { 34 + if (loading || !cursor) return; 35 + loading = true; 36 + failed = false; 37 + try { 38 + const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 39 + identityCache ??= new IdentityCache(ctx); 40 + const next = await fetchStarredPage(ctx, { 41 + did, 42 + viewerDid: auth.currentDid ?? undefined, 43 + cursor, 44 + cache: identityCache 45 + }); 46 + extra = [...extra, ...next.items]; 47 + cursor = next.cursor; 48 + } catch { 49 + failed = true; 50 + } finally { 51 + loading = false; 52 + } 53 + }; 9 54 </script> 10 55 11 56 <Section title="Starred" empty={stars.length === 0} emptyMessage="No stars yet."> ··· 23 68 </Card> 24 69 {/if} 25 70 {/each} 71 + {#if cursor} 72 + <LoadMore {loading} {failed} onclick={loadMore} /> 73 + {/if} 26 74 </Section>
+41 -1
web/src/lib/components/profile/tabs/StringListTab.svelte
··· 1 1 <script lang="ts"> 2 + import { untrack } from "svelte"; 3 + import { getAuth } from "$lib/auth.svelte"; 4 + import { createBobbinClient } from "$lib/api/client"; 5 + import { fetchStringsPage } from "../pages"; 2 6 import StringCard from "../StringCard.svelte"; 3 7 import Section from "$lib/components/ui/Section.svelte"; 8 + import LoadMore from "$lib/components/ui/LoadMore.svelte"; 4 9 import type { StringCardData } from "../types"; 5 10 6 - let { strings }: { strings: StringCardData[] } = $props(); 11 + interface Props { 12 + initial: StringCardData[]; 13 + cursor?: string; 14 + did: string; 15 + handle: string; 16 + } 17 + 18 + let { initial, cursor: initialCursor, did, handle }: Props = $props(); 19 + 20 + const auth = getAuth(); 21 + 22 + let extra = $state<StringCardData[]>([]); 23 + let cursor = $state(untrack(() => initialCursor)); 24 + let loading = $state(false); 25 + let failed = $state(false); 26 + 27 + const strings = $derived([...initial, ...extra]); 28 + 29 + const loadMore = async () => { 30 + if (loading || !cursor) return; 31 + loading = true; 32 + failed = false; 33 + try { 34 + const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 35 + const next = await fetchStringsPage(ctx, { did, handle, cursor }); 36 + extra = [...extra, ...next.items]; 37 + cursor = next.cursor; 38 + } catch { 39 + failed = true; 40 + } finally { 41 + loading = false; 42 + } 43 + }; 7 44 </script> 8 45 9 46 <Section title="Strings" empty={strings.length === 0} emptyMessage="No strings yet."> 10 47 {#each strings as entry (entry.rkey)} 11 48 <StringCard {entry} /> 12 49 {/each} 50 + {#if cursor} 51 + <LoadMore {loading} {failed} onclick={loadMore} /> 52 + {/if} 13 53 </Section>
+51 -5
web/src/lib/components/profile/tabs/VouchTab.svelte
··· 1 1 <script lang="ts"> 2 + import { untrack } from "svelte"; 3 + import { getAuth } from "$lib/auth.svelte"; 4 + import { createBobbinClient } from "$lib/api/client"; 5 + import { IdentityCache } from "$lib/api/identity"; 6 + import { fetchVouchesPage, type VouchCursors } from "../pages"; 2 7 import VouchCard from "../VouchCard.svelte"; 3 8 import Section from "$lib/components/ui/Section.svelte"; 9 + import LoadMore from "$lib/components/ui/LoadMore.svelte"; 4 10 import type { VouchData } from "../types"; 5 11 6 - let { 7 - vouches, 8 - isSelf, 9 - profileHandle 10 - }: { vouches: VouchData[]; isSelf: boolean; profileHandle: string } = $props(); 12 + interface Props { 13 + initial: VouchData[]; 14 + cursors: VouchCursors; 15 + did: string; 16 + isSelf: boolean; 17 + profileHandle: string; 18 + } 19 + 20 + let { initial, cursors: initialCursors, did, isSelf, profileHandle }: Props = $props(); 21 + 22 + const auth = getAuth(); 23 + 24 + let extra = $state<VouchData[]>([]); 25 + let cursors = $state(untrack(() => initialCursors)); 26 + let loading = $state(false); 27 + let failed = $state(false); 28 + // shared across pages so repeat dids only resolve once 29 + let identityCache: IdentityCache | undefined; 11 30 12 31 const profileLabel = $derived(isSelf ? "you" : profileHandle); 32 + // incoming and outgoing pages interleave, so the merged list re-sorts on append 33 + const vouches = $derived( 34 + [...initial, ...extra].sort( 35 + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 36 + ) 37 + ); 38 + const exhausted = $derived(cursors.incoming === null && cursors.outgoing === null); 39 + 40 + const loadMore = async () => { 41 + if (loading || exhausted) return; 42 + loading = true; 43 + failed = false; 44 + try { 45 + const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); 46 + identityCache ??= new IdentityCache(ctx); 47 + const next = await fetchVouchesPage(ctx, { did, cursors, cache: identityCache }); 48 + extra = [...extra, ...next.items]; 49 + cursors = next.cursors; 50 + } catch { 51 + failed = true; 52 + } finally { 53 + loading = false; 54 + } 55 + }; 13 56 </script> 14 57 15 58 <Section ··· 22 65 {#each vouches as vouch (vouch.uri)} 23 66 <VouchCard {vouch} {profileLabel} /> 24 67 {/each} 68 + {#if !exhausted} 69 + <LoadMore {loading} {failed} onclick={loadMore} /> 70 + {/if} 25 71 </Section>
+21
web/src/lib/components/ui/LoadMore.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import LoadMore from "./LoadMore.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/LoadMore", 7 + component: LoadMore, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + loading: { control: { type: "boolean" } }, 11 + failed: { control: { type: "boolean" } } 12 + }, 13 + args: { 14 + onclick: () => {} 15 + } 16 + }); 17 + </script> 18 + 19 + <Story name="Default" /> 20 + <Story name="Loading" args={{ loading: true }} /> 21 + <Story name="Failed" args={{ failed: true }} />
+18
web/src/lib/components/ui/LoadMore.svelte
··· 1 + <script lang="ts"> 2 + import Button from "./Button.svelte"; 3 + 4 + interface Props { 5 + loading?: boolean; 6 + failed?: boolean; 7 + onclick: () => void; 8 + class?: string; 9 + } 10 + 11 + let { loading = false, failed = false, onclick, class: className }: Props = $props(); 12 + </script> 13 + 14 + <div class="flex justify-center pt-4 {className ?? ''}"> 15 + <Button variant="ghost" {loading} {onclick}> 16 + {failed ? "couldn't load more, retry?" : "load more"} 17 + </Button> 18 + </div>
+48 -6
web/src/routes/[handle]/+page.svelte
··· 12 12 {#if data.tab === "overview"} 13 13 <OverviewTab pinned={data.overview.pinned} /> 14 14 {:else if data.tab === "repos"} 15 - <RepoListTab repos={data.repos} /> 15 + {#key data.repos} 16 + <RepoListTab 17 + initial={data.repos} 18 + cursor={data.cursor} 19 + did={data.identity.did} 20 + handle={data.identity.handle} 21 + /> 22 + {/key} 16 23 {:else if data.tab === "starred"} 17 - <StarredTab stars={data.stars} /> 24 + {#key data.stars} 25 + <StarredTab initial={data.stars} cursor={data.cursor} did={data.identity.did} /> 26 + {/key} 18 27 {:else if data.tab === "strings"} 19 - <StringListTab strings={data.strings} /> 28 + {#key data.strings} 29 + <StringListTab 30 + initial={data.strings} 31 + cursor={data.cursor} 32 + did={data.identity.did} 33 + handle={data.identity.handle} 34 + /> 35 + {/key} 20 36 {:else if data.tab === "followers"} 21 - <PeopleTab people={data.people} title="Followers" emptyMessage="No followers yet." /> 37 + {#key data.people} 38 + <PeopleTab 39 + initial={data.people} 40 + cursor={data.cursor} 41 + did={data.identity.did} 42 + direction="followers" 43 + title="Followers" 44 + emptyMessage="No followers yet." 45 + /> 46 + {/key} 22 47 {:else if data.tab === "following"} 23 - <PeopleTab people={data.people} title="Following" emptyMessage="Not following anyone yet." /> 48 + {#key data.people} 49 + <PeopleTab 50 + initial={data.people} 51 + cursor={data.cursor} 52 + did={data.identity.did} 53 + direction="following" 54 + title="Following" 55 + emptyMessage="Not following anyone yet." 56 + /> 57 + {/key} 24 58 {:else if data.tab === "vouches"} 25 - <VouchTab vouches={data.vouches} isSelf={data.isSelf} profileHandle={data.profileHandle} /> 59 + {#key data.vouches} 60 + <VouchTab 61 + initial={data.vouches} 62 + cursors={data.cursors} 63 + did={data.identity.did} 64 + isSelf={data.isSelf} 65 + profileHandle={data.profileHandle} 66 + /> 67 + {/key} 26 68 {/if}
+27 -286
web/src/routes/[handle]/+page.ts
··· 1 1 import type { Did } from "@atcute/lexicons/syntax"; 2 2 import { createBobbinClient } from "$lib/api/client"; 3 - import { fetchPage } from "$lib/api/pagination"; 4 - import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich"; 5 - import { type RepoRecord, type RecordList } from "$lib/api/records"; 6 - import { IdentityCache } from "$lib/api/identity"; 7 - import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 8 3 import { toHttpError } from "$lib/api/load"; 9 - import type { SearchPage } from "$lib/api/search"; 10 - import type { BobbinContext } from "$lib/api/client"; 11 - import { type VouchRecord } from "$lib/api/graph"; 12 - import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; 13 - import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; 14 - import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; 15 - import type { 16 - RepoCardData, 17 - StringCardData, 18 - PersonData, 19 - VouchData, 20 - StarData 21 - } from "$lib/components/profile/types"; 4 + import { 5 + fetchReposPage, 6 + fetchStringsPage, 7 + fetchStarredPage, 8 + fetchPeoplePage, 9 + fetchVouchesPage, 10 + fetchPinned 11 + } from "$lib/components/profile/pages"; 22 12 import type { PageLoad } from "./$types"; 23 - 24 - const PAGE_LIMIT = 50; 25 - 26 - const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" }; 27 - const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" }; 28 - const FOLLOW_STATS: LinkDescriptor[] = [ 29 - { source: "sh.tangled.graph.follow:subject", type: "count" }, 30 - { source: "sh.tangled.graph.follow:.repo", type: "count" } 31 - ]; 32 - const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" }; 33 13 34 14 const TABS = [ 35 15 "overview", ··· 45 25 const normalizeTab = (raw: string | null): Tab => 46 26 TABS.includes(raw as Tab) ? (raw as Tab) : "overview"; 47 27 48 - interface ListItem { 49 - uri: string; 50 - value: unknown; 51 - } 52 - 53 - const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { 54 - const value = item.value as RepoRecord; 55 - return { 56 - rkey: rkeyFromUri(item.uri), 57 - name: value.name ?? rkeyFromUri(item.uri), 58 - repoDid: value.repoDid ?? "", 59 - ownerHandle, 60 - description: value.description, 61 - knot: value.knot, 62 - createdAt: value.createdAt 63 - }; 64 - }; 65 - 66 - const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => { 67 - const repo = toRepoCard(item, ownerHandle); 68 - if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 69 - const stars = countOf(stats, repo.repoDid, STAR_COUNT.source); 70 - const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source); 71 - return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; 72 - }; 73 - 74 - const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { 75 - const value = item.value as ShTangledString.Main; 76 - return { 77 - rkey: rkeyFromUri(item.uri), 78 - ownerHandle, 79 - filename: value.filename, 80 - description: value.description, 81 - createdAt: value.createdAt, 82 - lines: value.contents?.split("\n").length ?? 1 83 - }; 84 - }; 85 - 86 - // the sidecar already carries follower counts and viewer status, so this costs 87 - // no extra requests 88 - const resolvePeople = async ( 89 - ctx: BobbinContext, 90 - dids: string[], 91 - stats: Stats, 92 - viewerDid?: string 93 - ): Promise<PersonData[]> => { 94 - const cache = new IdentityCache(ctx); 95 - const unique = [...new Set(dids)]; 96 - 97 - const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); 98 - 99 - const byDid = new Map<string, PersonData>(); 100 - unique.forEach((did, index) => { 101 - const doc = docs[index]; 102 - const followers = countOf(stats, did, "sh.tangled.graph.follow:subject"); 103 - const following = countOf(stats, did, "sh.tangled.graph.follow:.repo"); 104 - const isSelf = viewerDid === did; 105 - const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source); 106 - const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; 107 - byDid.set( 108 - did, 109 - doc 110 - ? { 111 - did: doc.did, 112 - handle: doc.handle, 113 - followers, 114 - following, 115 - isSelf, 116 - viewerFollowRkey 117 - } 118 - : { did, handle: did, followers, following, isSelf, viewerFollowRkey } 119 - ); 120 - }); 121 - return unique.map((did) => byDid.get(did) as PersonData); 122 - }; 123 - 124 - const resolveVouches = async ( 125 - ctx: BobbinContext, 126 - items: ListItem[], 127 - direction: "incoming" | "outgoing" 128 - ): Promise<VouchData[]> => { 129 - const cache = new IdentityCache(ctx); 130 - return Promise.all( 131 - items.map(async (item): Promise<VouchData> => { 132 - const value = item.value as VouchRecord; 133 - const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); 134 - const doc = await cache.resolve(otherDid).catch(() => null); 135 - return { 136 - uri: item.uri, 137 - did: otherDid, 138 - handle: doc?.handle ?? otherDid, 139 - kind: value.kind === "denounce" ? "denounce" : "vouch", 140 - direction, 141 - reason: value.reason, 142 - createdAt: value.createdAt 143 - }; 144 - }) 145 - ); 146 - }; 147 - 148 - const resolveStars = async ( 149 - ctx: BobbinContext, 150 - items: ListItem[], 151 - viewerDid?: string 152 - ): Promise<StarData[]> => { 153 - const cache = new IdentityCache(ctx); 154 - const repoDids = [ 155 - ...new Set( 156 - items 157 - .map((item) => (item.value as ShTangledFeedStar.Main).subject) 158 - .flatMap((s) => (s && "did" in s && s.did ? [s.did] : [])) 159 - ) 160 - ]; 161 - const enriched = 162 - repoDids.length > 0 163 - ? await enrich<RecordList<RepoRecord>>(ctx, { 164 - xrpc: "sh.tangled.repo.getReposByRepoDids", 165 - params: { dids: repoDids }, 166 - enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], 167 - ...(viewerDid ? { viewer: viewerDid } : {}) 168 - }) 169 - : { output: { items: [] }, stats: {} as Stats }; 170 - const reposByDid = new Map( 171 - enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) 172 - ); 173 - const resolved = await Promise.all( 174 - items.map(async (item): Promise<StarData | null> => { 175 - const value = item.value as ShTangledFeedStar.Main; 176 - const subject = value.subject; 177 - if (subject && "did" in subject && subject.did) { 178 - const repo = reposByDid.get(subject.did); 179 - if (!repo) return null; 180 - const ownerDid = didFromUri(repo.uri); 181 - const owner = await cache.resolve(ownerDid).catch(() => null); 182 - return { 183 - kind: "repo", 184 - uri: item.uri, 185 - createdAt: value.createdAt, 186 - repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats) 187 - }; 188 - } 189 - if (subject && "uri" in subject && subject.uri) { 190 - const ownerDid = didFromUri(subject.uri); 191 - const owner = await cache.resolve(ownerDid).catch(() => null); 192 - return { 193 - kind: "string", 194 - uri: item.uri, 195 - createdAt: value.createdAt, 196 - ownerHandle: owner?.handle ?? ownerDid, 197 - rkey: rkeyFromUri(subject.uri) 198 - }; 199 - } 200 - return null; 201 - }) 202 - ); 203 - return resolved.filter((star): star is StarData => star !== null); 204 - }; 205 - 206 28 export const load: PageLoad = async (event) => { 207 29 const parent = await event.parent(); 208 30 const tab = normalizeTab(event.url.searchParams.get("tab")); ··· 212 34 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 213 35 const did = parent.identity.did as Did; 214 36 const handle = parent.identity.handle; 37 + const viewerDid = parent.auth?.did; 215 38 216 39 try { 217 40 switch (tab) { 218 41 case "repos": { 219 42 const q = event.url.searchParams.get("q")?.trim(); 220 - const viewerDid = parent.auth?.did; 221 - const viewerEnrich = viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; 222 - if (!q) { 223 - const enriched = await enrich<RecordList<RepoRecord>>(ctx, { 224 - xrpc: "sh.tangled.repo.listRepos", 225 - params: { subject: did, limit: PAGE_LIMIT }, 226 - enrich: viewerEnrich, 227 - ...(viewerDid ? { viewer: viewerDid } : {}) 228 - }); 229 - return { 230 - tab, 231 - repos: enriched.output.items.map((item) => 232 - resolveRepoCard(item, handle, enriched.stats) 233 - ) 234 - }; 235 - } 236 - const enriched = await enrich<SearchPage>(ctx, { 237 - xrpc: "sh.tangled.search.query", 238 - params: { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }, 239 - enrich: viewerEnrich, 240 - ...(viewerDid ? { viewer: viewerDid } : {}) 241 - }); 242 - return { 243 - tab, 244 - repos: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)) 245 - }; 43 + const page = await fetchReposPage(ctx, { did, handle, viewerDid, q: q || undefined }); 44 + return { tab: "repos" as const, repos: page.items, cursor: page.cursor }; 246 45 } 247 46 case "strings": { 248 - const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { 249 - subject: did, 250 - limit: PAGE_LIMIT 251 - }); 252 - return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; 253 - } 254 - case "followers": { 255 - const viewerDid = parent.auth?.did; 256 - const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, { 257 - xrpc: "sh.tangled.graph.listFollows", 258 - params: { subject: did, limit: PAGE_LIMIT }, 259 - enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, 260 - ...(viewerDid ? { viewer: viewerDid } : {}) 261 - }); 262 - const dids = enriched.output.items.map((item) => didFromUri(item.uri)); 263 - return { 264 - tab, 265 - people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) 266 - }; 47 + const page = await fetchStringsPage(ctx, { did, handle }); 48 + return { tab: "strings" as const, strings: page.items, cursor: page.cursor }; 267 49 } 50 + case "followers": 268 51 case "following": { 269 - const viewerDid = parent.auth?.did; 270 - const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, { 271 - xrpc: "sh.tangled.graph.listFollowsBy", 272 - params: { subject: did, limit: PAGE_LIMIT }, 273 - enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, 274 - ...(viewerDid ? { viewer: viewerDid } : {}) 275 - }); 276 - const dids = enriched.output.items.map( 277 - (item) => (item.value as ShTangledGraphFollow.Main).subject 278 - ); 279 - return { 280 - tab, 281 - people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) 282 - }; 52 + const page = await fetchPeoplePage(ctx, { did, viewerDid, direction: tab }); 53 + return { tab, people: page.items, cursor: page.cursor } as const; 283 54 } 284 55 case "vouches": { 285 - const [incomingPage, outgoingPage] = await Promise.all([ 286 - fetchPage(ctx, "sh.tangled.graph.listVouches", { subject: did, limit: PAGE_LIMIT }), 287 - fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { subject: did, limit: PAGE_LIMIT }) 288 - ]); 289 - const [incoming, outgoing] = await Promise.all([ 290 - resolveVouches(ctx, incomingPage.items, "incoming"), 291 - resolveVouches(ctx, outgoingPage.items, "outgoing") 292 - ]); 293 - const vouches = [...incoming, ...outgoing].sort( 294 - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 295 - ); 56 + const page = await fetchVouchesPage(ctx, { did }); 296 57 return { 297 - tab, 298 - vouches, 299 - isSelf: parent.auth?.did === did, 58 + tab: "vouches" as const, 59 + vouches: page.items, 60 + cursors: page.cursors, 61 + isSelf: viewerDid === did, 300 62 profileHandle: handle 301 63 }; 302 64 } 303 65 case "starred": { 304 - const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", { 305 - subject: did, 306 - limit: PAGE_LIMIT 307 - }); 308 - return { 309 - tab, 310 - stars: await resolveStars(ctx, page.items, parent.auth?.did) 311 - }; 66 + const page = await fetchStarredPage(ctx, { did, viewerDid }); 67 + return { tab: "starred" as const, stars: page.items, cursor: page.cursor }; 312 68 } 313 69 case "overview": 314 70 default: { 315 - const viewerDid = parent.auth?.did; 316 - const enriched = await enrich<RecordList<RepoRecord>>(ctx, { 317 - xrpc: "sh.tangled.repo.listRepos", 318 - params: { subject: did, limit: PAGE_LIMIT }, 319 - enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], 320 - ...(viewerDid ? { viewer: viewerDid } : {}) 71 + const pinned = await fetchPinned(ctx, { 72 + keys: parent.profile?.pinnedRepositories ?? [], 73 + handle, 74 + viewerDid 321 75 }); 322 - 323 - const pinnedKeys = parent.profile?.pinnedRepositories ?? []; 324 - const byKey = new Map<string, ListItem>(); 325 - for (const item of enriched.output.items) { 326 - const value = item.value as RepoRecord; 327 - if (value.repoDid) byKey.set(value.repoDid, item); 328 - byKey.set(item.uri, item); 329 - } 330 - const pinnedItems = pinnedKeys 331 - .map((key) => byKey.get(key)) 332 - .filter((item): item is ListItem => item !== undefined); 333 - const pinned = pinnedItems.map((item) => resolveRepoCard(item, handle, enriched.stats)); 334 - 335 76 return { tab: "overview" as const, overview: { pinned } }; 336 77 } 337 78 }