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';