From 5e284cd50ece9cb8056c7392ecd2c7e5b87eb14f Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Fri, 12 Jun 2026 20:35:28 -0400 Subject: [PATCH] create atproto-query that uses atproto-ui in a tanstack query compatible format --- package-lock.json | 154 +++++++++++-------- package.json | 3 +- src/App.tsx | 23 +-- src/components/common.tsx | 2 +- src/components/repo.tsx | 2 +- src/layout.tsx | 2 +- src/lib/api/repos.ts | 52 ++++++- src/lib/api/stars.ts | 68 ++++++++- src/lib/api/strings.ts | 18 ++- src/lib/atproto-query.tsx | 295 +++++++++++++++++++++++++++++++++++++ src/lib/preloading.tsx | 2 +- src/pages/home.tsx | 2 +- src/pages/profile.tsx | 27 ++-- src/pages/repo/code.tsx | 2 +- src/pages/repo/commits.tsx | 2 +- src/pages/repo/fork.tsx | 2 +- src/pages/repo/issues.tsx | 2 +- src/pages/repo/new.tsx | 2 +- src/pages/repo/pulls.tsx | 2 +- src/pages/repo/shared.tsx | 2 +- src/pages/search.tsx | 2 +- src/pages/settings.tsx | 2 +- src/pages/string.tsx | 2 +- 23 files changed, 567 insertions(+), 103 deletions(-) create mode 100644 src/lib/atproto-query.tsx diff --git a/package-lock.json b/package-lock.json index 96d2cd6..b8ac54a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,9 @@ "@atcute/microcosm": "^2.0.0", "@atcute/oauth-browser-client": "^4.0.0", "@atcute/tangled": "^2.0.1", + "@atproto-ui/core": "^0.13.0", + "@atproto-ui/solid": "^0.13.1", "@solidjs/router": "^0.16.1", - "@tanstack/solid-query": "^5.100.10", "clsx": "^2.1.1", "dompurify": "^3.4.3", "fflate": "^0.8.2", @@ -33,11 +34,24 @@ } }, "node_modules/@atcute/atproto": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@atcute/atproto/-/atproto-4.0.0.tgz", - "integrity": "sha512-Rd21kW0tl/QEXhyPkJXRpUV8HYs7sMOmmXRu2z1sKMZbm1tqvHz1L3CQeTFcC/Xf/5cg7S6ITGIKRP0++zu0aw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@atcute/atproto/-/atproto-4.0.2.tgz", + "integrity": "sha512-hLnvjiIOStpdUm0cEN+R5YydvbV0d6ap17Iv+t7i/nhSCN3TGMya7M0ftCWtCo+xoQ1EU6HK74R8jqXWlyrM0w==", + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.0" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/bluesky": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@atcute/bluesky/-/bluesky-4.0.9.tgz", + "integrity": "sha512-xSecUOyy0T0zsKAZ2GT/0YgzfwiPraI48hpX8KfJh7dAFekFFq7b9/tHN0rzwhqDQxAcyOH5I0kgjpmX2jL4yw==", "license": "0BSD", "dependencies": { + "@atcute/atproto": "^4.0.2", "@atcute/lexicons": "^2.0.0" }, "peerDependencies": { @@ -62,6 +76,7 @@ "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-2.0.0.tgz", "integrity": "sha512-YXFsggO7eJYifqkN85+kUXJE2a1iI9AyuzPTDjtS/4WE1Zs1/XiPkWmwZlAgtp+pYhVtjm3mJqy/h/mZ0OnIVw==", "license": "0BSD", + "peer": true, "dependencies": { "@atcute/lexicons": "^2.0.0", "valibot": "^1.4.0" @@ -207,12 +222,12 @@ } }, "node_modules/@atcute/tangled": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@atcute/tangled/-/tangled-2.0.1.tgz", - "integrity": "sha512-x+B77ukgl/YYfy/oXDyPesqlT765i3goDm6lDQEgJCQwSRcczps8XC26pBVUKSPGcSMTo+p76vaeXLpi3wB+FQ==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@atcute/tangled/-/tangled-2.0.7.tgz", + "integrity": "sha512-1L9WG3x5RZRXspHiA2gSXbD+lusYt350RVEqqdrkQYryPnYyaN5864d4KI4UjVwG2H3cDWamMqH8DUCdVughGw==", "license": "0BSD", "dependencies": { - "@atcute/atproto": "^4.0.0", + "@atcute/atproto": "^4.0.2", "@atcute/lexicons": "^2.0.0" }, "peerDependencies": { @@ -243,6 +258,32 @@ "unicode-segmenter": "^0.14.5" } }, + "node_modules/@atproto-ui/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@atproto-ui/core/-/core-0.13.0.tgz", + "integrity": "sha512-vV/vsc7q/yi38L/wUw6lj5fdGPwBhP3+QsYfvbigpQelFYUTfnrphd70SGEObwYRZjOce9pBzTIv1YUamdAHow==", + "dependencies": { + "@atcute/atproto": "^4.0.0", + "@atcute/bluesky": "^4.0.2", + "@atcute/client": "^5.0.0", + "@atcute/identity": "^2.0.0", + "@atcute/identity-resolver": "^2.0.0", + "@atcute/lexicons": "^2.0.0", + "@atcute/tangled": "^2.0.2" + } + }, + "node_modules/@atproto-ui/solid": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@atproto-ui/solid/-/solid-0.13.1.tgz", + "integrity": "sha512-b6LCcPM1S8En35FP7fohQQxNDoULXNj6kyIpoxK+0T15XAYrBCUTV8kqMDJXLDHOYAr7S/C4HpZFS9N19GFdCg==", + "dependencies": { + "@atcute/bluesky": "^4.0.2", + "@atproto-ui/core": "^0.13.0" + }, + "peerDependencies": { + "solid-js": "^1.8.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -274,6 +315,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -509,33 +551,10 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -845,6 +864,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", @@ -901,32 +954,6 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, - "node_modules/@tanstack/query-core": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", - "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/solid-query": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-5.100.10.tgz", - "integrity": "sha512-4uUqc+l9f7wNfL9/fxU9P+/RyA0wXh67jrl8EeNYsXHo5Xcy31uiSBq5wZ80NHBCbyw+eLtrkZZMW2ZXNe1ViQ==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.100.10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "solid-js": "^1.6.0" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -989,6 +1016,7 @@ "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1082,6 +1110,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1690,6 +1719,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1775,6 +1805,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -1796,6 +1827,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz", "integrity": "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", @@ -1858,6 +1890,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1930,6 +1963,7 @@ "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/package.json b/package.json index 30303eb..9372278 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,9 @@ "@atcute/microcosm": "^2.0.0", "@atcute/oauth-browser-client": "^4.0.0", "@atcute/tangled": "^2.0.1", + "@atproto-ui/core": "^0.13.0", + "@atproto-ui/solid": "^0.13.1", "@solidjs/router": "^0.16.1", - "@tanstack/solid-query": "^5.100.10", "clsx": "^2.1.1", "dompurify": "^3.4.3", "fflate": "^0.8.2", diff --git a/src/App.tsx b/src/App.tsx index a7bc8d8..a5f88e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; +import { QueryClient, QueryClientProvider } from './lib/atproto-query'; import type { Component } from 'solid-js'; +import { AtProtoProvider } from '@atproto-ui/solid'; import { AuthProvider } from './lib/auth'; import { LiveEventsProvider } from './lib/live-events'; import { AppSettingsProvider } from './lib/settings'; @@ -16,15 +17,17 @@ const queryClient = new QueryClient({ }); const App: Component = () => ( - - - - - - - - - + + + + + + + + + + + ); export default App; diff --git a/src/components/common.tsx b/src/components/common.tsx index 9277844..008532f 100644 --- a/src/components/common.tsx +++ b/src/components/common.tsx @@ -12,7 +12,7 @@ import { } from 'lucide-solid'; import { A } from '@solidjs/router'; import { Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; -import { createQuery, useQueryClient } from '@tanstack/solid-query'; +import { createQuery, useQueryClient } from '../lib/atproto-query'; export { useQueryClient }; import { resolveAvatarUrl } from '../lib/api/identity'; diff --git a/src/components/repo.tsx b/src/components/repo.tsx index 2d85ded..ae7220e 100644 --- a/src/components/repo.tsx +++ b/src/components/repo.tsx @@ -27,7 +27,7 @@ import { A, useNavigate, useLocation } from '@solidjs/router'; import { For, Show, createMemo, createSignal, createEffect, onCleanup, onMount, type Component, type JSX } from 'solid-js'; import type { RepoContext, TreeEntry } from '../lib/api/repos'; import type { Did } from '@atcute/lexicons/syntax'; -import { createQuery } from '@tanstack/solid-query'; +import { createQuery } from '../lib/atproto-query'; import { getRepo, getRepoLanguages, getRepoForkCount, getRepoByDid } from '../lib/api/repos'; import { getRepoStarCount } from '../lib/api/stars'; import { getRepoIssueCount } from '../lib/api/issues'; diff --git a/src/layout.tsx b/src/layout.tsx index e676261..f6bfabe 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import { Bell, BookMarked, LogOut, Search, Settings, UserRound, Plus, LineSquiggle, BookPlus } from 'lucide-solid'; import { A, useLocation, useNavigate } from '@solidjs/router'; -import { createQuery } from '@tanstack/solid-query'; +import { createQuery } from './lib/atproto-query'; import { For, Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; import { Avatar, buttonStyles, inputStyles } from './components/common'; diff --git a/src/lib/api/repos.ts b/src/lib/api/repos.ts index f667416..fd60acf 100644 --- a/src/lib/api/repos.ts +++ b/src/lib/api/repos.ts @@ -306,11 +306,25 @@ const listRepoRecordsFromPds = async (owner: string | ResolvedActor): Promise => - appviewFallback( +export const listRepoRecords = async (owner: string | ResolvedActor): Promise => { + const records = await appviewFallback( () => listRepoRecordsFromAppview(owner), () => listRepoRecordsFromPds(owner), ); + if (records.length > 0) { + return records; + } + + // Bobbin/Hydrant can return a successful empty list while the user's PDS has + // repo records. Treat an empty appview list as inconclusive and ask the PDS + // before showing an empty profile. + try { + const pdsRecords = await listRepoRecordsFromPds(owner); + return pdsRecords.length > 0 ? pdsRecords : records; + } catch { + return records; + } +}; export const getRepo = async (ownerIdentifier: string, repoSlug: string): Promise => { const owner = await resolveActor(ownerIdentifier); @@ -344,6 +358,40 @@ export const getRepo = async (ownerIdentifier: string, repoSlug: string): Promis }; }; +export const getRepoByUri = async (uri: ResourceUri): Promise => { + const parsed = parseAtUri(uri); + const owner = await resolveActor(parsed.did); + const response = await ok( + getRpc(owner.pds).get('com.atproto.repo.getRecord', { + params: { + repo: owner.did, + collection: parsed.collection, + rkey: parsed.rkey, + }, + }), + ); + const value = response.value as ShTangledRepo.Main; + const slug = value.name?.trim() || parsed.rkey; + + if (!value.repoDid) { + throw new Error(`Repository is missing repoDid metadata`); + } + + return { + owner, + record: { + uri: response.uri, + cid: response.cid ?? '', + rkey: parsed.rkey, + value, + }, + repoDid: value.repoDid, + slug, + rkey: parsed.rkey, + knot: normalizeServiceUrl(value.knot), + }; +}; + export const getRepoByDid = async (repoDid: Did): Promise => { const record = await appviewJson>( 'sh.tangled.repo.getRepoByRepoDid', diff --git a/src/lib/api/stars.ts b/src/lib/api/stars.ts index c484c92..707ff56 100644 --- a/src/lib/api/stars.ts +++ b/src/lib/api/stars.ts @@ -1,5 +1,5 @@ import { ok } from '@atcute/client'; -import type { Did } from '@atcute/lexicons/syntax'; +import type { Did, ResourceUri } from '@atcute/lexicons/syntax'; import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; import { ShTangledFeedStar } from '@atcute/tangled'; @@ -14,6 +14,7 @@ import { getBacklinkDids, } from './backlinks'; import { STAR_COLLECTION } from './constants'; +import { getRpc } from './client'; import { createAuthRpc } from './identity'; import { parseAtUri, @@ -27,6 +28,13 @@ export interface RepoStarSummary { currentUserStarRkey?: string; } +export interface StarRecord { + uri: ResourceUri; + cid: string; + rkey: string; + value: ShTangledFeedStar.Main; +} + const STAR_BACKLINK_SOURCES = [ 'sh.tangled.feed.star:subject.did', 'sh.tangled.feed.star:subjectDid', @@ -66,6 +74,64 @@ const appviewStarSubjectDid = (star: ShTangledFeedStar.Main): Did | null => { const getRepoStarCountFromAppview = async (repo: RepoContext): Promise => getDistinctAppviewRecordCount('sh.tangled.feed.countStars', repo.repoDid); +const listStarRecordsFromAppview = async (actor: { did: Did }): Promise => { + const stars = await listAllAppviewRecords('sh.tangled.feed.listStarsBy', actor.did); + return stars.map((star) => ({ + uri: star.uri, + cid: star.cid ?? '', + rkey: parseAtUri(star.uri).rkey, + value: star.value, + })); +}; + +const listStarRecordsFromPds = async (actor: { did: Did; pds: string }): Promise => { + const rpc = getRpc(actor.pds); + const records: StarRecord[] = []; + let cursor: string | undefined; + + do { + const page = await ok( + rpc.get('com.atproto.repo.listRecords', { + params: { + repo: actor.did, + collection: STAR_COLLECTION, + limit: 100, + cursor, + reverse: true, + }, + }), + ); + records.push(...page.records.map((record) => ({ + uri: record.uri, + cid: record.cid ?? '', + rkey: parseAtUri(record.uri).rkey, + value: record.value as ShTangledFeedStar.Main, + }))); + cursor = page.cursor; + } while (cursor); + + return records; +}; + +export const listStarRecordsByActor = async (actor: { did: Did; pds: string }): Promise => { + const records = await appviewFallback( + () => listStarRecordsFromAppview(actor), + () => listStarRecordsFromPds(actor), + ); + if (records.length > 0) { + return records; + } + + // Bobbin/Hydrant can return a successful empty list while the actor's PDS has + // stars. Use PDS as the authoritative tie-breaker for profile data. + try { + const pdsRecords = await listStarRecordsFromPds(actor); + return pdsRecords.length > 0 ? pdsRecords : records; + } catch { + return records; + } +}; + const findCurrentUserStarFromAppview = async ( repo: RepoContext, viewerDid: Did, diff --git a/src/lib/api/strings.ts b/src/lib/api/strings.ts index 54dc649..9d8a76e 100644 --- a/src/lib/api/strings.ts +++ b/src/lib/api/strings.ts @@ -95,11 +95,25 @@ const listStringRecordsFromPds = async (owner: string | ResolvedActor): Promise< })); }; -export const listStringRecords = async (owner: string | ResolvedActor): Promise => - appviewFallback( +export const listStringRecords = async (owner: string | ResolvedActor): Promise => { + const records = await appviewFallback( () => listStringRecordsFromAppview(owner), () => listStringRecordsFromPds(owner), ); + if (records.length > 0) { + return records; + } + + // Bobbin/Hydrant can return a successful empty list while the user's PDS has + // string records. Treat an empty appview list as inconclusive and ask the PDS + // before showing an empty profile. + try { + const pdsRecords = await listStringRecordsFromPds(owner); + return pdsRecords.length > 0 ? pdsRecords : records; + } catch { + return records; + } +}; export const getString = async ( ownerIdentifier: string, diff --git a/src/lib/atproto-query.tsx b/src/lib/atproto-query.tsx new file mode 100644 index 0000000..1233e8b --- /dev/null +++ b/src/lib/atproto-query.tsx @@ -0,0 +1,295 @@ +import { createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type Accessor, type JSX } from 'solid-js'; +import { getSharedCaches, clearSharedCaches } from '@atproto-ui/core'; + +type QueryKey = readonly unknown[]; +type QueryKeyInput = QueryKey | unknown[]; +type QueryFn = () => Promise | T; +type QueryUpdater = T | ((old: T | undefined) => T | undefined); + +interface QueryOptions { + queryKey: QueryKeyInput; + queryFn: QueryFn; + enabled?: boolean; + staleTime?: number; + placeholderData?: unknown; + refetchOnWindowFocus?: boolean; +} + +interface QueryEntry { + key: QueryKey; + keyHash: string; + data?: T; + error?: Error; + updatedAt: number; + promise?: Promise; + listeners: Set<() => void>; + queryFn?: QueryFn; + staleTime: number; + invalidated: boolean; +} + +export interface QueryResult { + readonly data: T | undefined; + readonly error: Error | undefined; + readonly isLoading: boolean; + readonly isFetching: boolean; + readonly isPending: boolean; + readonly isError: boolean; + readonly isSuccess: boolean; + readonly status: 'pending' | 'error' | 'success'; + refetch: () => Promise; +} + +const UNTANGLED_QUERY_DID = 'did:web:untangled.local'; +const UNTANGLED_QUERY_COLLECTION = 'app.untangled.query'; + +const queryKeyHash = (key: QueryKeyInput): string => JSON.stringify(key); + +const atprotoQueryCache = () => getSharedCaches().recordCache; + +const queryKeyStartsWith = (candidate: QueryKey, prefix?: QueryKeyInput): boolean => { + if (!prefix) return true; + if (prefix.length > candidate.length) return false; + return prefix.every((part, index) => Object.is(part, candidate[index])); +}; + +const toError = (error: unknown): Error => error instanceof Error ? error : new Error(String(error)); + +export class QueryClient { + private entries = new Map(); + private defaultStaleTime: number; + + constructor(options?: { defaultOptions?: { queries?: { staleTime?: number; retry?: number | boolean } } }) { + this.defaultStaleTime = options?.defaultOptions?.queries?.staleTime ?? 30_000; + } + + getOrCreateEntry(key: QueryKeyInput, staleTime?: number): QueryEntry { + const normalizedKey = [...key]; + const hash = queryKeyHash(normalizedKey); + let entry = this.entries.get(hash) as QueryEntry | undefined; + if (!entry) { + const cached = atprotoQueryCache().get<{ + data: T; + updatedAt: number; + }>(UNTANGLED_QUERY_DID, UNTANGLED_QUERY_COLLECTION, hash); + entry = { + key: normalizedKey, + keyHash: hash, + data: cached?.data, + updatedAt: cached?.updatedAt ?? 0, + listeners: new Set(), + staleTime: staleTime ?? this.defaultStaleTime, + invalidated: false, + }; + this.entries.set(hash, entry); + } else if (staleTime !== undefined) { + entry.staleTime = staleTime; + } + return entry; + } + + subscribe(entry: QueryEntry, listener: () => void): () => void { + entry.listeners.add(listener); + return () => entry.listeners.delete(listener); + } + + private notify(entry: QueryEntry) { + for (const listener of entry.listeners) listener(); + } + + private isFresh(entry: QueryEntry): boolean { + return !entry.invalidated && entry.data !== undefined && Date.now() - entry.updatedAt < entry.staleTime; + } + + async fetchQuery(options: { queryKey: QueryKeyInput; queryFn: QueryFn; staleTime?: number }): Promise { + const entry = this.getOrCreateEntry(options.queryKey, options.staleTime); + entry.queryFn = options.queryFn; + if (this.isFresh(entry)) return entry.data as T; + return this.fetchEntry(entry, options.queryFn); + } + + async ensureQueryData(options: { queryKey: QueryKeyInput; queryFn: QueryFn; staleTime?: number }): Promise { + const entry = this.getOrCreateEntry(options.queryKey, options.staleTime); + entry.queryFn = options.queryFn; + if (entry.data !== undefined && !entry.invalidated) return entry.data as T; + return this.fetchEntry(entry, options.queryFn); + } + + prefetchQuery(options: { queryKey: QueryKeyInput; queryFn: QueryFn; staleTime?: number }): Promise { + return this.fetchQuery(options).then(() => undefined, () => undefined); + } + + async refetchQueries(filter: { queryKey?: QueryKeyInput } = {}): Promise { + await Promise.all(this.matchingEntries(filter.queryKey).map((entry) => { + if (!entry.queryFn) return undefined; + return this.fetchEntry(entry, entry.queryFn).then(() => undefined, () => undefined); + })); + } + + async invalidateQueries(filter: { queryKey?: QueryKeyInput; refetchType?: 'none' | string } = {}): Promise { + const entries = this.matchingEntries(filter.queryKey); + for (const entry of entries) { + entry.invalidated = true; + this.notify(entry); + } + if (filter.refetchType === 'none') return; + await this.refetchQueries(filter); + } + + setQueryData(queryKey: QueryKeyInput, updater: QueryUpdater): T | undefined { + const entry = this.getOrCreateEntry(queryKey); + const next = typeof updater === 'function' + ? (updater as (old: T | undefined) => T | undefined)(entry.data as T | undefined) + : updater; + entry.data = next; + entry.error = undefined; + entry.updatedAt = Date.now(); + entry.invalidated = false; + if (next !== undefined) { + atprotoQueryCache().set(UNTANGLED_QUERY_DID, UNTANGLED_QUERY_COLLECTION, entry.keyHash, { + data: next, + updatedAt: entry.updatedAt, + }); + } + this.notify(entry); + return next; + } + + setQueriesData(filter: { queryKey?: QueryKeyInput }, updater: QueryUpdater): Array<[QueryKey, T | undefined]> { + return this.matchingEntries(filter.queryKey).map((entry) => [ + entry.key, + this.setQueryData(entry.key, updater), + ]); + } + + getQueryData(queryKey: QueryKeyInput): T | undefined { + return this.entries.get(queryKeyHash(queryKey))?.data as T | undefined; + } + + clear(): void { + this.entries.clear(); + clearSharedCaches(); + } + + private matchingEntries(prefix?: QueryKeyInput): QueryEntry[] { + return [...this.entries.values()].filter((entry) => queryKeyStartsWith(entry.key, prefix)); + } + + private fetchEntry(entry: QueryEntry, queryFn: QueryFn): Promise { + if (entry.promise) return entry.promise; + entry.queryFn = queryFn; + const promise = Promise.resolve() + .then(queryFn) + .then((data) => { + entry.data = data; + entry.error = undefined; + entry.updatedAt = Date.now(); + entry.invalidated = false; + atprotoQueryCache().set(UNTANGLED_QUERY_DID, UNTANGLED_QUERY_COLLECTION, entry.keyHash, { + data, + updatedAt: entry.updatedAt, + }); + return data; + }) + .catch((error) => { + entry.error = toError(error); + throw entry.error; + }) + .finally(() => { + entry.promise = undefined; + this.notify(entry); + }); + entry.promise = promise; + this.notify(entry); + return promise; + } +} + +const AtprotoQueryContext = createContext(); +export const defaultQueryClient = new QueryClient(); + +export function QueryClientProvider(props: { client?: QueryClient; children: JSX.Element }) { + return ( + + {props.children} + + ); +} + +export function useQueryClient(): QueryClient { + return useContext(AtprotoQueryContext) ?? defaultQueryClient; +} + +const accessOptions = (options: QueryOptions | Accessor>): QueryOptions => + typeof options === 'function' ? (options as Accessor>)() : options; + +export function createQuery(options: QueryOptions | Accessor>): QueryResult { + const client = useQueryClient(); + const getOptions = createMemo(() => accessOptions(options)); + const [version, setVersion] = createSignal(0); + const [fetching, setFetching] = createSignal(false); + let activeEntry: QueryEntry | undefined; + let unsubscribe: (() => void) | undefined; + let token = 0; + + const bump = () => setVersion((value) => value + 1); + + createEffect(() => { + const opts = getOptions(); + const enabled = opts.enabled ?? true; + const key = opts.queryKey; + const entry = client.getOrCreateEntry(key, opts.staleTime); + entry.queryFn = opts.queryFn; + activeEntry = entry; + unsubscribe?.(); + unsubscribe = client.subscribe(entry, bump); + bump(); + + const runToken = ++token; + if (!enabled) { + setFetching(false); + return; + } + if (entry.data !== undefined && !entry.invalidated && Date.now() - entry.updatedAt < entry.staleTime) { + setFetching(false); + return; + } + setFetching(true); + client.fetchQuery({ queryKey: key, queryFn: opts.queryFn, staleTime: opts.staleTime }) + .catch(() => undefined) + .finally(() => { + if (runToken === token) setFetching(false); + }); + }); + + onCleanup(() => unsubscribe?.()); + + const entry = () => { + version(); + return activeEntry; + }; + + return { + get data() { return entry()?.data as T | undefined; }, + get error() { return entry()?.error; }, + get isFetching() { return fetching() || !!entry()?.promise; }, + get isLoading() { return (fetching() || !!entry()?.promise) && entry()?.data === undefined; }, + get isPending() { return this.isLoading; }, + get isError() { return !!entry()?.error; }, + get isSuccess() { return entry()?.data !== undefined && !entry()?.error; }, + get status() { return this.isError ? 'error' : this.isSuccess ? 'success' : 'pending'; }, + refetch: async () => { + const opts = getOptions(); + setFetching(true); + try { + return await client.fetchQuery({ ...opts, staleTime: -1 }); + } finally { + setFetching(false); + } + }, + }; +} + +export const keepPreviousData = Symbol('keepPreviousData'); + +export { getSharedCaches }; diff --git a/src/lib/preloading.tsx b/src/lib/preloading.tsx index 663998a..12a0c51 100644 --- a/src/lib/preloading.tsx +++ b/src/lib/preloading.tsx @@ -1,4 +1,4 @@ -import { useQueryClient } from '@tanstack/solid-query'; +import { useQueryClient } from '../lib/atproto-query'; import { useAuth } from './auth'; import { getRepo, diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 6ae411f..db2c6a4 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -14,7 +14,7 @@ import { } from 'lucide-solid'; import clsx from 'clsx'; import { A } from '@solidjs/router'; -import { createQuery } from '@tanstack/solid-query'; +import { createQuery } from '../lib/atproto-query'; import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component, type JSX } from 'solid-js'; import { Avatar, ErrorState, LoadingState, StateBadge } from '../components/common'; diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx index a9f2e43..1e9acb9 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -20,9 +20,9 @@ import { LineSquiggle, } from 'lucide-solid'; import { A, useParams, useSearchParams, useNavigate } from '@solidjs/router'; -import { createQuery, useQueryClient } from '@tanstack/solid-query'; +import { createQuery, useQueryClient } from '../lib/atproto-query'; import { For, Show, Switch, Match, createMemo, createSignal, createEffect, type Component } from 'solid-js'; -import type { Did } from '@atcute/lexicons/syntax'; +import type { Did, ResourceUri } from '@atcute/lexicons/syntax'; import { ErrorState, LoadingState, PlaceholderAvatar, SkeletonBlock, textareaStyles, inputStyles } from '../components/common'; import { RepoCard, RepoCardSkeleton, StringCardSkeleton } from '../components/repo'; import { useStringPreloader } from '../lib/preloading'; @@ -40,12 +40,13 @@ import { type VouchRecord, } from '../lib/api/graph'; import { resolveActor, getActorProfile, resolveAvatarUrl, putActorProfile } from '../lib/api/identity'; -import { listRepoRecords, getRepoByDid, type RepoContext } from '../lib/api/repos'; +import { listRepoRecords, getRepoByDid, getRepoByUri, type RepoContext } from '../lib/api/repos'; import { listStringRecords } from '../lib/api/strings'; -import { useAuth } from '../lib/auth'; +import { listStarRecordsByActor } from '../lib/api/stars'; import { listAllAppviewRecords } from '../lib/api/appview'; +import { useAuth } from '../lib/auth'; import { formatRelativeTime, getErrorMessage } from '../lib/repo-utils'; -import { ShTangledFeedStar, ShTangledActorProfile } from '@atcute/tangled'; +import { ShTangledActorProfile } from '@atcute/tangled'; const ProfileTabs: Component<{ activeTab: string; @@ -1059,18 +1060,20 @@ export const ProfilePage: Component = () => { queryKey: ['profile-stars', actorQuery.data?.did], enabled: !!actorQuery.data, queryFn: async () => { - const did = actorQuery.data!.did; - const starRecords = await listAllAppviewRecords('sh.tangled.feed.listStarsBy', did); + const actor = actorQuery.data!; + const starRecords = await listStarRecordsByActor(actor); const repos = await Promise.all( starRecords.map(async (star) => { - const subject = star.value.subject as { did?: string; subjectDid?: string }; - const repoDid = (subject.did ?? subject.subjectDid) as Did; + const subject = star.value.subject as string | { did?: string; subjectDid?: string }; + const subjectUri = typeof subject === 'string' && subject.startsWith('at://') ? subject as ResourceUri : undefined; + const repoDid = typeof subject === 'object' ? (subject.did ?? subject.subjectDid) as Did | undefined : undefined; try { - return await getRepoByDid(repoDid); + if (repoDid) return await getRepoByDid(repoDid); + if (subjectUri) return await getRepoByUri(subjectUri); } catch (e) { - console.error('failed to resolve starred repo', repoDid, e); - return null; + console.error('failed to resolve starred repo', repoDid ?? subjectUri, e); } + return null; }) ); return repos.filter(Boolean) as RepoContext[]; diff --git a/src/pages/repo/code.tsx b/src/pages/repo/code.tsx index 538e397..b098a3a 100644 --- a/src/pages/repo/code.tsx +++ b/src/pages/repo/code.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import { File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid'; import { A, useNavigate, useParams } from '@solidjs/router'; -import { createQuery, keepPreviousData, useQueryClient } from '@tanstack/solid-query'; +import { createQuery, keepPreviousData, useQueryClient } from '../../lib/atproto-query'; import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type Accessor, type Component } from 'solid-js'; import { buildBlobDataUrl, decodeBlobBytes, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree, type CommitHeadline, type RepoContext, type BranchEntry } from '../../lib/api/repos'; import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common'; diff --git a/src/pages/repo/commits.tsx b/src/pages/repo/commits.tsx index 80fff90..621d7ea 100644 --- a/src/pages/repo/commits.tsx +++ b/src/pages/repo/commits.tsx @@ -7,7 +7,7 @@ import { FolderCode, } from 'lucide-solid'; import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; -import { createQuery } from '@tanstack/solid-query'; +import { createQuery } from '../../lib/atproto-query'; import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; import { getRepoBranches, diff --git a/src/pages/repo/fork.tsx b/src/pages/repo/fork.tsx index ea3a74e..1382142 100644 --- a/src/pages/repo/fork.tsx +++ b/src/pages/repo/fork.tsx @@ -1,5 +1,5 @@ import { A, useNavigate } from '@solidjs/router'; -import { createQuery, useQueryClient } from '@tanstack/solid-query'; +import { createQuery, useQueryClient } from '../../lib/atproto-query'; import { GitFork, Info, LoaderCircle } from 'lucide-solid'; import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; diff --git a/src/pages/repo/issues.tsx b/src/pages/repo/issues.tsx index 00ac55b..32fb8f0 100644 --- a/src/pages/repo/issues.tsx +++ b/src/pages/repo/issues.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import { Ban, CirclePlus, LoaderCircle, MessageSquarePlus, Pencil, RefreshCcwDot, Reply, Search, Smile, X } from 'lucide-solid'; import { A, useNavigate, useParams, useSearchParams, useLocation } from '@solidjs/router'; -import { createQuery, useQueryClient } from '@tanstack/solid-query'; +import { createQuery, useQueryClient } from '../../lib/atproto-query'; import type { ResourceUri } from '@atcute/lexicons/syntax'; import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; import { createIssue, createIssueComment, getIssue, listIssuesPage, setIssueState, type IssueComment } from '../../lib/api/issues'; diff --git a/src/pages/repo/new.tsx b/src/pages/repo/new.tsx index 075fbc6..0836bfa 100644 --- a/src/pages/repo/new.tsx +++ b/src/pages/repo/new.tsx @@ -1,5 +1,5 @@ import { A, useNavigate } from '@solidjs/router'; -import { createQuery, useQueryClient } from '@tanstack/solid-query'; +import { createQuery, useQueryClient } from '../../lib/atproto-query'; import { BookPlus, LoaderCircle } from 'lucide-solid'; import { For, Show, createEffect, createSignal, type Component } from 'solid-js'; diff --git a/src/pages/repo/pulls.tsx b/src/pages/repo/pulls.tsx index 39f4580..9aaed55 100644 --- a/src/pages/repo/pulls.tsx +++ b/src/pages/repo/pulls.tsx @@ -22,7 +22,7 @@ import { } from 'lucide-solid'; import type { Did } from '@atcute/lexicons/syntax'; import { A, useNavigate, useParams, useSearchParams, useLocation } from '@solidjs/router'; -import { createQuery, useQueryClient } from '@tanstack/solid-query'; +import { createQuery, useQueryClient } from '../../lib/atproto-query'; import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; import { resolveActor } from '../../lib/api/identity'; import { createPull, createPullComment, fetchPullRoundPatch, getPull, listPullsPage, mergePull, setPullStatus } from '../../lib/api/pulls'; diff --git a/src/pages/repo/shared.tsx b/src/pages/repo/shared.tsx index 47179c6..2a65cc3 100644 --- a/src/pages/repo/shared.tsx +++ b/src/pages/repo/shared.tsx @@ -1,7 +1,7 @@ import { CircleDot, GitFork, GitPullRequest, Globe, LoaderCircle, Rss, SquareChartGantt, Star } from 'lucide-solid'; import type { Did } from '@atcute/lexicons/syntax'; import { A, type RouteSectionProps, useParams } from '@solidjs/router'; -import { createQuery } from '@tanstack/solid-query'; +import { createQuery } from '../../lib/atproto-query'; import { For, Match, Show, Switch, createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type Component, type JSX } from 'solid-js'; import { getRepoIssueCount } from '../../lib/api/issues'; import { getRepoPullCount } from '../../lib/api/pulls'; diff --git a/src/pages/search.tsx b/src/pages/search.tsx index 43cb80c..c9aa4fc 100644 --- a/src/pages/search.tsx +++ b/src/pages/search.tsx @@ -13,7 +13,7 @@ import { LineSquiggle, } from 'lucide-solid'; import { A, useNavigate, useSearchParams } from '@solidjs/router'; -import { createQuery } from '@tanstack/solid-query'; +import { createQuery } from '../lib/atproto-query'; import type { Did, ResourceUri } from '@atcute/lexicons/syntax'; import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index c132c51..a4d98bc 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -10,7 +10,7 @@ import { } from 'solid-js'; import { Portal } from 'solid-js/web'; import { A, useNavigate, useParams, useLocation, type RouteSectionProps } from '@solidjs/router'; -import { createQuery, useQueryClient } from '@tanstack/solid-query'; +import { createQuery, useQueryClient } from '../lib/atproto-query'; import { Settings, User, diff --git a/src/pages/string.tsx b/src/pages/string.tsx index 33bf90f..d853ed8 100644 --- a/src/pages/string.tsx +++ b/src/pages/string.tsx @@ -1,5 +1,5 @@ import { A, useParams, useNavigate } from '@solidjs/router'; -import { createQuery, useQueryClient } from '@tanstack/solid-query'; +import { createQuery, useQueryClient } from '../lib/atproto-query'; import { Show, Switch, Match, type Component, createSignal, createMemo, createEffect, onCleanup } from 'solid-js'; import type { ResourceUri } from '@atcute/lexicons/syntax'; import { Avatar, ErrorState, inputStyles, textareaStyles } from '../components/common'; -- 2.51.2