csr tangled client in solid-js
15

Configure Feed

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

use appview

dawn (May 20, 2026, 12:47 AM +0300) 74919c2c 9c88d5ea

+985 -79
-22
AGENTS.md
··· 1 1 # Agent Notes 2 2 3 - ## RTK Commands 4 - 5 - When running shell commands, prefix them with `rtk` by default. If `rtk` has no 6 - filter for a command, it passes through unchanged. 7 - 8 - Examples: 9 - 10 - ```bash 11 - rtk git status 12 - rtk git diff 13 - rtk rg "pattern" src 14 - rtk npm run build 15 - ``` 16 - 17 - Rules: 18 - 19 - - In command chains, prefix each segment: `rtk git add . && rtk git commit -m "msg"`. 20 - - Use `rtk rg` for searching, including TSX/JSX files. 21 - - Do not use `rtk sed`, `rtk proxy sed`, `rtk cat`, `rtk read`, or other RTK-filtered readers for `.tsx` or `.jsx` files; use raw `sed`, `cat`, or similar so JSX syntax is preserved. 22 - - For debugging, use a raw command without `rtk` if the filter hides needed detail. 23 - - Use `apply_patch` for manual edits. 24 - 25 3 ## Build 26 4 27 5 Use this as the main verification command:
+8 -5
src/App.tsx
··· 3 3 4 4 import { AuthProvider } from './lib/auth'; 5 5 import { LiveEventsProvider } from './lib/live-events'; 6 + import { AppSettingsProvider } from './lib/settings'; 6 7 import { AppRoutes } from './routes'; 7 8 8 9 const queryClient = new QueryClient({ ··· 16 17 17 18 const App: Component = () => ( 18 19 <QueryClientProvider client={queryClient}> 19 - <AuthProvider> 20 - <LiveEventsProvider> 21 - <AppRoutes /> 22 - </LiveEventsProvider> 23 - </AuthProvider> 20 + <AppSettingsProvider> 21 + <AuthProvider> 22 + <LiveEventsProvider> 23 + <AppRoutes /> 24 + </LiveEventsProvider> 25 + </AuthProvider> 26 + </AppSettingsProvider> 24 27 </QueryClientProvider> 25 28 ); 26 29
+110 -4
src/layout.tsx
··· 1 1 import clsx from 'clsx'; 2 - import { Bell, LogOut } from 'lucide-solid'; 2 + import { Bell, LogOut, RotateCcw, Save, Settings } from 'lucide-solid'; 3 3 import { A } from '@solidjs/router'; 4 - import { createQuery } from '@tanstack/solid-query'; 5 - import { For, Show, createSignal, type Component, type JSX } from 'solid-js'; 4 + import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 + import { For, Show, createEffect, createSignal, type Component, type JSX } from 'solid-js'; 6 6 7 7 import { Avatar, buttonStyles, inputStyles } from './components/common'; 8 - import { resolveActor } from './lib/api'; 8 + import { clearApiCaches, resolveActor } from './lib/api'; 9 9 import { useAuth } from './lib/auth'; 10 10 import { useLiveEvents, type LiveEvent } from './lib/live-events'; 11 11 import { formatRelativeTime } from './lib/repo-utils'; 12 + import { useAppSettings } from './lib/settings'; 12 13 13 14 const eventLabel = (event: LiveEvent) => { 14 15 if (event.source === 'sh.tangled.repo.issue:repo') { ··· 22 23 return `${event.source} -> ${event.subject}`; 23 24 }; 24 25 26 + const AppSettingsMenu: Component = () => { 27 + const settings = useAppSettings(); 28 + const queryClient = useQueryClient(); 29 + const [draftAppviewUrl, setDraftAppviewUrl] = createSignal(settings.appviewUrl()); 30 + const [draftRouteKnotRequestsThroughAppview, setDraftRouteKnotRequestsThroughAppview] = createSignal( 31 + settings.routeKnotRequestsThroughAppview(), 32 + ); 33 + const [error, setError] = createSignal<string | null>(null); 34 + const [saved, setSaved] = createSignal(false); 35 + 36 + createEffect(() => { 37 + setDraftAppviewUrl(settings.appviewUrl()); 38 + setDraftRouteKnotRequestsThroughAppview(settings.routeKnotRequestsThroughAppview()); 39 + }); 40 + 41 + const reloadAppviewBackedQueries = () => { 42 + clearApiCaches(); 43 + void queryClient.invalidateQueries(); 44 + }; 45 + 46 + const onSave = (event: SubmitEvent) => { 47 + event.preventDefault(); 48 + try { 49 + settings.setAppviewUrl(draftAppviewUrl()); 50 + settings.setRouteKnotRequestsThroughAppview(draftRouteKnotRequestsThroughAppview()); 51 + setError(null); 52 + setSaved(true); 53 + reloadAppviewBackedQueries(); 54 + } catch (cause) { 55 + setSaved(false); 56 + setError(cause instanceof Error ? cause.message : 'Invalid appview URL'); 57 + } 58 + }; 59 + 60 + const onReset = () => { 61 + settings.resetAppviewSettings(); 62 + setDraftAppviewUrl(settings.defaultAppviewUrl); 63 + setDraftRouteKnotRequestsThroughAppview(false); 64 + setError(null); 65 + setSaved(true); 66 + reloadAppviewBackedQueries(); 67 + }; 68 + 69 + return ( 70 + <details class="relative"> 71 + <summary class={clsx('cursor-pointer list-none')} title="App settings"> 72 + <Settings class="size-5 text-gray-500 dark:text-gray-400" /> 73 + </summary> 74 + <div class="absolute right-0 mt-3 w-96 max-w-[calc(100vw-2rem)] rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-lg z-50"> 75 + <form onSubmit={onSave} class="flex flex-col gap-4"> 76 + <div class="space-y-1"> 77 + <label for="appview-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200"> 78 + appview URL 79 + </label> 80 + <input 81 + id="appview-url" 82 + value={draftAppviewUrl()} 83 + onInput={(event) => { 84 + setDraftAppviewUrl(event.currentTarget.value); 85 + setSaved(false); 86 + setError(null); 87 + }} 88 + placeholder={settings.defaultAppviewUrl} 89 + class={inputStyles()} 90 + /> 91 + </div> 92 + 93 + <label class="flex items-center gap-3 text-sm text-gray-700 dark:text-gray-200"> 94 + <input 95 + type="checkbox" 96 + checked={draftRouteKnotRequestsThroughAppview()} 97 + onChange={(event) => { 98 + setDraftRouteKnotRequestsThroughAppview(event.currentTarget.checked); 99 + setSaved(false); 100 + setError(null); 101 + }} 102 + class="size-4 rounded border-gray-300 dark:border-gray-600" 103 + /> 104 + <span>route knot requests through appview</span> 105 + </label> 106 + 107 + <div class="flex items-center justify-between gap-3"> 108 + <button type="button" class={buttonStyles()} onClick={onReset}> 109 + <RotateCcw class="size-4" /> 110 + reset 111 + </button> 112 + <button type="submit" class={buttonStyles('primary')}> 113 + <Save class="size-4" /> 114 + save 115 + </button> 116 + </div> 117 + 118 + <Show when={error()}> 119 + <p class="text-sm text-red-500">{error()}</p> 120 + </Show> 121 + <Show when={saved()}> 122 + <p class="text-sm text-gray-500 dark:text-gray-400">saved</p> 123 + </Show> 124 + </form> 125 + </div> 126 + </details> 127 + ); 128 + }; 129 + 25 130 const Topbar: Component = () => { 26 131 const auth = useAuth(); 27 132 const live = useLiveEvents(); ··· 48 153 </A> 49 154 50 155 <div class="flex items-center gap-4"> 156 + <AppSettingsMenu /> 51 157 <Show 52 158 when={auth.currentDid()} 53 159 fallback={
+2
src/lib/api.ts
··· 3 3 PUBLIC_API_SERVICE, 4 4 SLINGSHOT_SERVICE, 5 5 SPACEDUST_SERVICE, 6 + TANGLED_APPVIEW_SERVICE, 6 7 TANGLED_OAUTH_SCOPE, 7 8 } from './api/constants'; 8 9 export { 10 + clearApiCaches, 9 11 createAuthRpc, 10 12 identityResolver, 11 13 resolveActor,
+1
src/lib/api/constants.ts
··· 3 3 PUBLIC_API_SERVICE, 4 4 SLINGSHOT_SERVICE, 5 5 SPACEDUST_SERVICE, 6 + TANGLED_APPVIEW_SERVICE, 6 7 TANGLED_OAUTH_SCOPE, 7 8 } from './core';
+739 -48
src/lib/api/core.ts
··· 7 7 import type { 8 8 ActorIdentifier, 9 9 Did, 10 + Handle, 10 11 Nsid, 11 12 ResourceUri, 12 13 } from '@atcute/lexicons/syntax'; ··· 28 29 import type {} from '@atcute/atproto'; 29 30 import type {} from '@atcute/microcosm'; 30 31 import type {} from '@atcute/tangled'; 32 + 33 + import { 34 + DEFAULT_TANGLED_APPVIEW_SERVICE, 35 + getRouteKnotRequestsThroughAppview, 36 + getTangledAppviewService, 37 + } from '../settings'; 31 38 32 39 export const PUBLIC_API_SERVICE = 'https://public.api.bsky.app'; 33 40 export const CONSTELLATION_SERVICE = 'https://constellation.microcosm.blue'; 34 41 export const SLINGSHOT_SERVICE = 'https://slingshot.microcosm.blue'; 35 42 export const SPACEDUST_SERVICE = 'wss://spacedust.microcosm.blue/subscribe'; 43 + export const TANGLED_APPVIEW_SERVICE = DEFAULT_TANGLED_APPVIEW_SERVICE; 36 44 37 45 export const TANGLED_OAUTH_SCOPE = import.meta.env.VITE_OAUTH_SCOPE; 38 46 ··· 47 55 const PULL_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.pull.comment'; 48 56 const PULL_STATUS_COLLECTION: Nsid = 'sh.tangled.repo.pull.status'; 49 57 50 - class SlingshotActorResolver implements ActorResolver { 58 + interface MiniDocResponse { 59 + did: Did; 60 + handle: Handle; 61 + pds: string; 62 + } 63 + 64 + class AppviewFirstActorResolver implements ActorResolver { 51 65 async resolve(actor: ActorIdentifier, options?: ResolveActorOptions): Promise<ResolvedActor> { 52 - const resolved = await ok( 53 - getRpc(SLINGSHOT_SERVICE).get('blue.microcosm.identity.resolveMiniDoc', { 54 - params: { 55 - identifier: actor, 56 - }, 57 - signal: options?.signal, 58 - }), 59 - ); 66 + try { 67 + return normalizeResolvedActor( 68 + await appviewJson<MiniDocResponse>( 69 + 'com.bad-example.identity.resolveMiniDoc', 70 + { identifier: actor }, 71 + options?.signal, 72 + ), 73 + ); 74 + } catch (cause) { 75 + if (!(cause instanceof AppviewUnavailableError)) { 76 + throw cause; 77 + } 60 78 61 - return { 62 - did: resolved.did, 63 - handle: resolved.handle, 64 - pds: normalizeServiceUrl(resolved.pds), 65 - }; 79 + const resolved = await ok( 80 + getRpc(SLINGSHOT_SERVICE).get('blue.microcosm.identity.resolveMiniDoc', { 81 + params: { 82 + identifier: actor, 83 + }, 84 + signal: options?.signal, 85 + }), 86 + ); 87 + 88 + return normalizeResolvedActor(resolved); 89 + } 66 90 } 67 91 } 68 92 69 - export const identityResolver = new SlingshotActorResolver(); 93 + export const identityResolver = new AppviewFirstActorResolver(); 70 94 71 95 export interface RepoContext { 72 96 owner: ResolvedActor; ··· 307 331 avatar?: unknown; 308 332 } 309 333 334 + interface AppviewRecordView<T> { 335 + uri: ResourceUri; 336 + cid?: string | null; 337 + value: T; 338 + } 339 + 340 + interface AppviewListResponse<T> { 341 + items: Array<AppviewRecordView<T>>; 342 + cursor?: string | null; 343 + } 344 + 345 + interface AppviewStatefulRecordView<T> extends AppviewRecordView<T> { 346 + state?: string; 347 + stateUpdatedAt?: string; 348 + commentCount?: number; 349 + } 350 + 351 + interface AppviewStatefulListResponse<T> { 352 + items: Array<AppviewStatefulRecordView<T>>; 353 + cursor?: string | null; 354 + } 355 + 356 + interface AppviewCountResponse { 357 + count: number; 358 + distinctAuthors?: number; 359 + distinct_authors?: number; 360 + } 361 + 362 + type AppviewParamValue = string | number | boolean | null | undefined; 363 + 310 364 const actorCache = new Map<string, Promise<ResolvedActor>>(); 311 365 const rpcCache = new Map<string, Client>(); 312 366 367 + interface AppviewCursorCheckpoint { 368 + matchingSeen: number; 369 + scannedSeen: number; 370 + cursor?: string; 371 + exhausted: boolean; 372 + } 373 + 374 + const APPVIEW_PAGE_BATCH_LIMIT = 100; 375 + const APPVIEW_CURSOR_CACHE_LIMIT = 64; 376 + const appviewPageCursorCache = new Map<string, Map<number, AppviewCursorCheckpoint>>(); 377 + 378 + export const clearApiCaches = () => { 379 + actorCache.clear(); 380 + appviewPageCursorCache.clear(); 381 + }; 382 + 313 383 const normalizeServiceUrl = (input: string): string => { 314 384 if (input.startsWith('http://') || input.startsWith('https://')) { 315 385 return input; ··· 318 388 return `https://${input}`; 319 389 }; 320 390 391 + const normalizeResolvedActor = (resolved: MiniDocResponse): ResolvedActor => ({ 392 + did: resolved.did, 393 + handle: resolved.handle, 394 + pds: normalizeServiceUrl(resolved.pds), 395 + }); 396 + 397 + const unresolvedActorFromDid = (did: Did): ResolvedActor => ({ 398 + did, 399 + handle: did as unknown as Handle, 400 + pds: '', 401 + }); 402 + 403 + const resolveActorForDisplay = async (did: Did): Promise<ResolvedActor> => { 404 + try { 405 + return await resolveActor(did); 406 + } catch { 407 + return unresolvedActorFromDid(did); 408 + } 409 + }; 410 + 321 411 const getRpc = (service: string): Client => { 322 412 const normalized = normalizeServiceUrl(service); 323 413 const existing = rpcCache.get(normalized); ··· 335 425 return client; 336 426 }; 337 427 428 + class AppviewResponseError extends Error { 429 + readonly status: number; 430 + 431 + constructor(status: number, message: string) { 432 + super(message); 433 + this.name = 'AppviewResponseError'; 434 + this.status = status; 435 + } 436 + } 437 + 438 + class AppviewUnavailableError extends Error { 439 + readonly cause: unknown; 440 + 441 + constructor(cause: unknown) { 442 + super(cause instanceof Error ? cause.message : 'Appview unavailable'); 443 + this.name = 'AppviewUnavailableError'; 444 + this.cause = cause; 445 + } 446 + } 447 + 448 + const appviewJson = async <T>( 449 + nsid: string, 450 + params: Record<string, AppviewParamValue> = {}, 451 + signal?: AbortSignal, 452 + ): Promise<T> => { 453 + const url = new URL(`/xrpc/${nsid}`, normalizeServiceUrl(getTangledAppviewService())); 454 + for (const [key, value] of Object.entries(params)) { 455 + if (value !== undefined && value !== null) { 456 + url.searchParams.set(key, String(value)); 457 + } 458 + } 459 + 460 + let response: Response; 461 + try { 462 + response = await fetch(url, { 463 + headers: { 464 + accept: 'application/json', 465 + }, 466 + signal, 467 + }); 468 + } catch (cause) { 469 + throw new AppviewUnavailableError(cause); 470 + } 471 + 472 + if (!response.ok) { 473 + let message = `${response.status} ${response.statusText}`.trim(); 474 + try { 475 + const body = (await response.json()) as { message?: unknown; error?: unknown }; 476 + const bodyMessage = typeof body.message === 'string' ? body.message : body.error; 477 + if (typeof bodyMessage === 'string') { 478 + message = bodyMessage; 479 + } 480 + } catch { 481 + // Keep the HTTP status message when the appview does not return JSON. 482 + } 483 + 484 + throw new AppviewResponseError(response.status, `Appview ${nsid} failed (${response.status}): ${message}`); 485 + } 486 + 487 + return (await response.json()) as T; 488 + }; 489 + 490 + const appviewFallback = async <T>(primary: () => Promise<T>, fallback: () => Promise<T>): Promise<T> => { 491 + try { 492 + return await primary(); 493 + } catch (cause) { 494 + if (!(cause instanceof AppviewUnavailableError)) { 495 + throw cause; 496 + } 497 + 498 + return fallback(); 499 + } 500 + }; 501 + 502 + const listAllAppviewRecords = async <T>( 503 + nsid: string, 504 + subject: string, 505 + extraParams: Record<string, AppviewParamValue> = {}, 506 + ): Promise<Array<AppviewRecordView<T>>> => { 507 + const records: Array<AppviewRecordView<T>> = []; 508 + let cursor: string | undefined; 509 + 510 + do { 511 + const page = await appviewJson<AppviewListResponse<T>>(nsid, { 512 + subject, 513 + limit: 100, 514 + cursor, 515 + ...extraParams, 516 + }); 517 + records.push(...page.items); 518 + cursor = page.cursor ?? undefined; 519 + } while (cursor); 520 + 521 + return records; 522 + }; 523 + 524 + const appviewRecordToHydrated = async <T>(record: AppviewRecordView<T>): Promise<HydratedRecord<T>> => { 525 + const parsed = parseAtUri(record.uri); 526 + const author = await resolveActorForDisplay(parsed.did); 527 + 528 + return { 529 + author, 530 + collection: parsed.collection, 531 + rkey: parsed.rkey, 532 + uri: record.uri, 533 + cid: record.cid ?? undefined, 534 + value: record.value, 535 + }; 536 + }; 537 + 538 + const appviewCursorCacheKey = ( 539 + nsid: string, 540 + subject: string, 541 + state: string, 542 + extraParams: Record<string, AppviewParamValue> = {}, 543 + ): string => { 544 + const extras = Object.entries(extraParams) 545 + .filter(([, value]) => value !== undefined && value !== null) 546 + .sort(([left], [right]) => left.localeCompare(right)) 547 + .map(([key, value]) => `${key}=${String(value)}`) 548 + .join('&'); 549 + 550 + return [ 551 + normalizeServiceUrl(getTangledAppviewService()), 552 + nsid, 553 + subject, 554 + state, 555 + extras, 556 + ].join('|'); 557 + }; 558 + 559 + const rememberAppviewCursorCheckpoint = (cacheKey: string, checkpoint: AppviewCursorCheckpoint) => { 560 + let checkpoints = appviewPageCursorCache.get(cacheKey); 561 + if (!checkpoints) { 562 + checkpoints = new Map(); 563 + appviewPageCursorCache.set(cacheKey, checkpoints); 564 + } 565 + 566 + const existing = checkpoints.get(checkpoint.matchingSeen); 567 + if (!existing || checkpoint.scannedSeen >= existing.scannedSeen) { 568 + checkpoints.set(checkpoint.matchingSeen, checkpoint); 569 + } 570 + 571 + while (checkpoints.size > APPVIEW_CURSOR_CACHE_LIMIT) { 572 + const first = checkpoints.keys().next().value; 573 + if (first === undefined) break; 574 + checkpoints.delete(first); 575 + } 576 + }; 577 + 578 + const getNearestAppviewCursorCheckpoint = ( 579 + cacheKey: string, 580 + offset: number, 581 + ): AppviewCursorCheckpoint => { 582 + const checkpoints = appviewPageCursorCache.get(cacheKey); 583 + const start: AppviewCursorCheckpoint = { 584 + matchingSeen: 0, 585 + scannedSeen: 0, 586 + exhausted: false, 587 + }; 588 + 589 + if (!checkpoints) { 590 + return start; 591 + } 592 + 593 + let nearest = start; 594 + for (const checkpoint of checkpoints.values()) { 595 + if (checkpoint.matchingSeen > offset) { 596 + continue; 597 + } 598 + 599 + if ( 600 + checkpoint.matchingSeen > nearest.matchingSeen || 601 + ( 602 + checkpoint.matchingSeen === nearest.matchingSeen && 603 + checkpoint.scannedSeen > nearest.scannedSeen 604 + ) 605 + ) { 606 + nearest = checkpoint; 607 + } 608 + } 609 + 610 + return nearest; 611 + }; 612 + 613 + const listAppviewStatefulRecordsPage = async <T, State extends string>( 614 + nsid: string, 615 + subject: string, 616 + options: { offset: number; limit: number; state: State }, 617 + normalizeState: (value?: string) => State, 618 + extraParams: Record<string, AppviewParamValue> = {}, 619 + ): Promise<PaginatedResult<HydratedRecord<T> & { number: number; state: State }>> => { 620 + // Bobbin exposes cursor pagination, while the UI still uses offsets. Cache 621 + // page-boundary cursors so sequential pages can resume from the prior page. 622 + const offset = Math.max(options.offset, 0); 623 + const limit = Math.max(options.limit, 1); 624 + const requestedEnd = offset + limit; 625 + const cacheKey = appviewCursorCacheKey(nsid, subject, options.state, extraParams); 626 + const start = getNearestAppviewCursorCheckpoint(cacheKey, offset); 627 + const pendingItems: Array<{ 628 + record: AppviewStatefulRecordView<T>; 629 + number: number; 630 + state: State; 631 + }> = []; 632 + 633 + if (start.exhausted) { 634 + return { 635 + items: [], 636 + totalCount: start.matchingSeen, 637 + hasNext: false, 638 + }; 639 + } 640 + 641 + let cursor = start.cursor; 642 + let matchingSeen = start.matchingSeen; 643 + let scannedSeen = start.scannedSeen; 644 + 645 + while (true) { 646 + const pageLimit = Math.min( 647 + APPVIEW_PAGE_BATCH_LIMIT, 648 + Math.max(matchingSeen < requestedEnd ? requestedEnd - matchingSeen : 1, 1), 649 + ); 650 + const page = await appviewJson<AppviewStatefulListResponse<T>>(nsid, { 651 + subject, 652 + limit: pageLimit, 653 + cursor, 654 + ...extraParams, 655 + }); 656 + 657 + for (const record of page.items) { 658 + const state = normalizeState(record.state); 659 + const number = scannedSeen + 1; 660 + scannedSeen += 1; 661 + 662 + if (state !== options.state) { 663 + continue; 664 + } 665 + 666 + if (matchingSeen >= offset && matchingSeen < requestedEnd) { 667 + pendingItems.push({ record, number, state }); 668 + } 669 + 670 + matchingSeen += 1; 671 + } 672 + 673 + cursor = page.cursor ?? undefined; 674 + const exhausted = !cursor; 675 + rememberAppviewCursorCheckpoint(cacheKey, { 676 + matchingSeen, 677 + scannedSeen, 678 + cursor, 679 + exhausted, 680 + }); 681 + 682 + if (matchingSeen > requestedEnd) { 683 + const visibleItems = pendingItems.slice(0, limit); 684 + const hydrated = await Promise.all( 685 + visibleItems.map(({ record }) => appviewRecordToHydrated(record)), 686 + ); 687 + 688 + return { 689 + items: hydrated.map((item, index) => ({ 690 + ...item, 691 + number: visibleItems[index].number, 692 + state: visibleItems[index].state, 693 + })), 694 + hasNext: true, 695 + }; 696 + } 697 + 698 + if (exhausted || page.items.length === 0) { 699 + const hydrated = await Promise.all( 700 + pendingItems.map(({ record }) => appviewRecordToHydrated(record)), 701 + ); 702 + 703 + return { 704 + items: hydrated.map((item, index) => ({ 705 + ...item, 706 + number: pendingItems[index].number, 707 + state: pendingItems[index].state, 708 + })), 709 + totalCount: matchingSeen, 710 + hasNext: false, 711 + }; 712 + } 713 + } 714 + }; 715 + 716 + const getRepoViaAppview = async <T>( 717 + nsid: string, 718 + repo: RepoContext, 719 + params: Record<string, AppviewParamValue> = {}, 720 + ): Promise<T> => 721 + appviewJson<T>(nsid, { 722 + repo: repo.record.uri, 723 + ...params, 724 + }); 725 + 726 + const appviewOptionalKnotRequest = async <T>( 727 + appview: () => Promise<T>, 728 + direct: () => Promise<T>, 729 + ): Promise<T> => { 730 + if (!getRouteKnotRequestsThroughAppview()) { 731 + return direct(); 732 + } 733 + 734 + return appviewFallback(appview, direct); 735 + }; 736 + 338 737 export const createAuthRpc = (agent: OAuthUserAgent): Client => 339 738 new Client({ 340 739 handler: agent, ··· 380 779 return records; 381 780 }; 382 781 383 - export const listRepoRecords = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => { 782 + const listRepoRecordsFromAppview = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => { 783 + const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; 784 + const records = await listAllAppviewRecords<ShTangledRepo.Main>('sh.tangled.repo.listRepos', actor.did); 785 + 786 + return records.map((record) => ({ 787 + uri: record.uri, 788 + cid: record.cid ?? '', 789 + rkey: parseAtUri(record.uri).rkey, 790 + value: record.value, 791 + })); 792 + }; 793 + 794 + const listRepoRecordsFromPds = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => { 384 795 const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; 385 796 const records = await fetchAllRepoRecords(actor, REPO_COLLECTION); 386 797 ··· 392 803 })); 393 804 }; 394 805 806 + export const listRepoRecords = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => 807 + appviewFallback( 808 + () => listRepoRecordsFromAppview(owner), 809 + () => listRepoRecordsFromPds(owner), 810 + ); 811 + 395 812 export interface RepoStarSummary { 396 813 count: number; 397 814 isStarred: boolean; ··· 403 820 'sh.tangled.feed.star:subjectDid', 404 821 ]; 405 822 406 - export const getRepoStarSummary = async (repo: RepoContext, viewerDid?: Did | null): Promise<RepoStarSummary> => { 823 + const getRepoStarSummaryFromBacklinks = async (repo: RepoContext, viewerDid?: Did | null): Promise<RepoStarSummary> => { 407 824 const starrers = new Set<Did>(); 408 825 const backlinkDidGroups = await Promise.all(STAR_BACKLINK_SOURCES.map((source) => getBacklinkDids(repo.repoDid, source))); 409 826 ··· 423 840 }; 424 841 }; 425 842 426 - export const getRepoStarCount = async (repo: RepoContext): Promise<number> => { 427 - const summary = await getRepoStarSummary(repo); 843 + const getRepoStarCountFromBacklinks = async (repo: RepoContext): Promise<number> => { 844 + const summary = await getRepoStarSummaryFromBacklinks(repo); 428 845 return summary.count; 429 846 }; 430 847 848 + const appviewStarSubjectDid = (star: ShTangledFeedStar.Main): Did | null => { 849 + const subject = star.subject as { did?: unknown; subjectDid?: unknown }; 850 + const did = subject.did ?? subject.subjectDid; 851 + return typeof did === 'string' && did.startsWith('did:') ? (did as Did) : null; 852 + }; 853 + 854 + const getRepoStarCountFromAppview = async (repo: RepoContext): Promise<number> => { 855 + const count = await appviewJson<AppviewCountResponse>('sh.tangled.feed.countStars', { 856 + subject: repo.repoDid, 857 + }); 858 + return count.distinctAuthors ?? count.distinct_authors ?? count.count; 859 + }; 860 + 861 + const findCurrentUserStarFromAppview = async ( 862 + repo: RepoContext, 863 + viewerDid: Did, 864 + ): Promise<AppviewRecordView<ShTangledFeedStar.Main> | undefined> => { 865 + const stars = await listAllAppviewRecords<ShTangledFeedStar.Main>('sh.tangled.feed.listStarsBy', viewerDid); 866 + return stars.find((star) => appviewStarSubjectDid(star.value) === repo.repoDid); 867 + }; 868 + 869 + const getRepoStarSummaryFromAppview = async ( 870 + repo: RepoContext, 871 + viewerDid?: Did | null, 872 + ): Promise<RepoStarSummary> => { 873 + const [count, currentUserStar] = await Promise.all([ 874 + getRepoStarCountFromAppview(repo), 875 + viewerDid ? findCurrentUserStarFromAppview(repo, viewerDid) : undefined, 876 + ]); 877 + 878 + return { 879 + count, 880 + isStarred: !!currentUserStar, 881 + currentUserStarRkey: currentUserStar ? parseAtUri(currentUserStar.uri).rkey : undefined, 882 + }; 883 + }; 884 + 885 + export const getRepoStarSummary = async (repo: RepoContext, viewerDid?: Did | null): Promise<RepoStarSummary> => 886 + appviewFallback( 887 + () => getRepoStarSummaryFromAppview(repo, viewerDid), 888 + () => getRepoStarSummaryFromBacklinks(repo, viewerDid), 889 + ); 890 + 891 + export const getRepoStarCount = async (repo: RepoContext): Promise<number> => 892 + appviewFallback( 893 + () => getRepoStarCountFromAppview(repo), 894 + () => getRepoStarCountFromBacklinks(repo), 895 + ); 896 + 431 897 export const createRepoStar = async (agent: OAuthUserAgent, repo: RepoContext): Promise<RecordCreationResult> => { 432 898 const rpc = createAuthRpc(agent); 433 899 const record: ShTangledFeedStar.Main = { ··· 496 962 }; 497 963 }; 498 964 499 - export const getRepoTree = async ( 965 + const getRepoTreeFromKnot = async ( 500 966 repo: RepoContext, 501 967 ref: string, 502 968 path = '', ··· 511 977 }), 512 978 )) as TreeResponse; 513 979 514 - export const getRepoBranches = async (repo: RepoContext): Promise<BranchResponse> => 980 + const getRepoBranchesFromKnot = async (repo: RepoContext): Promise<BranchResponse> => 515 981 (await ok( 516 982 getRpc(repo.knot).get('sh.tangled.repo.branches', { 517 983 params: { ··· 521 987 }), 522 988 )) as BranchResponse; 523 989 524 - export const getRepoDefaultBranch = async (repo: RepoContext): Promise<DefaultBranchResponse> => 990 + const getRepoDefaultBranchFromKnot = async (repo: RepoContext): Promise<DefaultBranchResponse> => 525 991 (await ok( 526 992 getRpc(repo.knot).get('sh.tangled.repo.getDefaultBranch', { 527 993 params: { ··· 530 996 }), 531 997 )) as DefaultBranchResponse; 532 998 533 - export const getRepoTags = async (repo: RepoContext): Promise<TagResponse> => 999 + const getRepoTagsFromKnot = async (repo: RepoContext): Promise<TagResponse> => 534 1000 (await ok( 535 1001 getRpc(repo.knot).get('sh.tangled.repo.tags', { 536 1002 params: { ··· 540 1006 }), 541 1007 )) as TagResponse; 542 1008 543 - export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise<LanguageResponse> => 1009 + const getRepoLanguagesFromKnot = async (repo: RepoContext, ref: string): Promise<LanguageResponse> => 544 1010 (await ok( 545 1011 getRpc(repo.knot).get('sh.tangled.repo.languages', { 546 1012 params: { ··· 550 1016 }), 551 1017 )) as LanguageResponse; 552 1018 553 - export const getRepoLog = async (repo: RepoContext, ref: string): Promise<LogResponse> => 1019 + const getRepoLogFromKnot = async (repo: RepoContext, ref: string): Promise<LogResponse> => 554 1020 (await ok( 555 1021 getRpc(repo.knot).get('sh.tangled.repo.log', { 556 1022 params: { ··· 562 1028 }), 563 1029 )) as LogResponse; 564 1030 565 - export const getRepoBlob = async ( 1031 + const getRepoBlobFromKnot = async ( 566 1032 repo: RepoContext, 567 1033 ref: string, 568 1034 path: string, ··· 577 1043 }), 578 1044 )) as BlobResponse; 579 1045 580 - export const compareBranches = async ( 1046 + const compareBranchesFromKnot = async ( 581 1047 repo: RepoContext, 582 1048 rev1: string, 583 1049 rev2: string, ··· 593 1059 }), 594 1060 )) as CompareResponse; 595 1061 1062 + export const getRepoTree = async ( 1063 + repo: RepoContext, 1064 + ref: string, 1065 + path = '', 1066 + ): Promise<TreeResponse> => 1067 + appviewOptionalKnotRequest( 1068 + () => getRepoViaAppview<TreeResponse>('sh.tangled.repo.tree', repo, { ref, path }), 1069 + () => getRepoTreeFromKnot(repo, ref, path), 1070 + ); 1071 + 1072 + export const getRepoBranches = async (repo: RepoContext): Promise<BranchResponse> => 1073 + appviewOptionalKnotRequest( 1074 + () => getRepoViaAppview<BranchResponse>('sh.tangled.repo.branches', repo), 1075 + () => getRepoBranchesFromKnot(repo), 1076 + ); 1077 + 1078 + export const getRepoDefaultBranch = async (repo: RepoContext): Promise<DefaultBranchResponse> => 1079 + appviewOptionalKnotRequest( 1080 + () => getRepoViaAppview<DefaultBranchResponse>('sh.tangled.repo.getDefaultBranch', repo), 1081 + () => getRepoDefaultBranchFromKnot(repo), 1082 + ); 1083 + 1084 + export const getRepoTags = async (repo: RepoContext): Promise<TagResponse> => 1085 + appviewOptionalKnotRequest( 1086 + () => getRepoViaAppview<TagResponse>('sh.tangled.repo.tags', repo), 1087 + () => getRepoTagsFromKnot(repo), 1088 + ); 1089 + 1090 + export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise<LanguageResponse> => 1091 + appviewOptionalKnotRequest( 1092 + () => getRepoViaAppview<LanguageResponse>('sh.tangled.repo.languages', repo, { ref }), 1093 + () => getRepoLanguagesFromKnot(repo, ref), 1094 + ); 1095 + 1096 + export const getRepoLog = async (repo: RepoContext, ref: string): Promise<LogResponse> => 1097 + appviewOptionalKnotRequest( 1098 + () => getRepoViaAppview<LogResponse>('sh.tangled.repo.log', repo, { ref, limit: 8 }), 1099 + () => getRepoLogFromKnot(repo, ref), 1100 + ); 1101 + 1102 + export const getRepoBlob = async ( 1103 + repo: RepoContext, 1104 + ref: string, 1105 + path: string, 1106 + ): Promise<BlobResponse> => 1107 + appviewOptionalKnotRequest( 1108 + () => getRepoViaAppview<BlobResponse>('sh.tangled.repo.blob', repo, { ref, path }), 1109 + () => getRepoBlobFromKnot(repo, ref, path), 1110 + ); 1111 + 1112 + export const compareBranches = async ( 1113 + repo: RepoContext, 1114 + rev1: string, 1115 + rev2: string, 1116 + ): Promise<CompareResponse> => 1117 + appviewOptionalKnotRequest( 1118 + () => getRepoViaAppview<CompareResponse>('sh.tangled.repo.compare', repo, { rev1, rev2 }), 1119 + () => compareBranchesFromKnot(repo, rev1, rev2), 1120 + ); 1121 + 596 1122 const getBacklinksPage = async ( 597 1123 subject: GenericUri, 598 1124 source: string, ··· 702 1228 ): Promise<HydratedRecord<T>> => { 703 1229 const actor = await resolveActor(ref.did); 704 1230 const record = await ok( 705 - getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 1231 + getRpc(actor.pds).get('com.atproto.repo.getRecord', { 706 1232 params: { 707 1233 repo: ref.did, 708 1234 collection: ref.collection, ··· 724 1250 const fetchRecordValue = async <T>( 725 1251 ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, 726 1252 ): Promise<T> => { 1253 + const actor = await resolveActor(ref.did); 727 1254 const record = await ok( 728 - getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 1255 + getRpc(actor.pds).get('com.atproto.repo.getRecord', { 729 1256 params: { 730 1257 repo: ref.did, 731 1258 collection: ref.collection, ··· 747 1274 ): Promise<T | null> => { 748 1275 try { 749 1276 const record = await ok( 750 - getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 1277 + getRpc(actor.pds).get('com.atproto.repo.getRecord', { 751 1278 params: { 752 1279 repo: actor.did, 753 1280 collection, ··· 786 1313 }; 787 1314 788 1315 const normalizeIssueState = (value?: string): 'open' | 'closed' => 789 - value?.endsWith('.closed') ? 'closed' : 'open'; 1316 + value === 'closed' || value?.endsWith('.closed') ? 'closed' : 'open'; 790 1317 791 1318 const normalizePullStatus = (value?: string): 'open' | 'closed' | 'merged' => { 792 - if (value?.endsWith('.merged')) { 1319 + if (value === 'merged' || value?.endsWith('.merged')) { 793 1320 return 'merged'; 794 1321 } 795 1322 796 - if (value?.endsWith('.closed')) { 1323 + if (value === 'closed' || value?.endsWith('.closed')) { 797 1324 return 'closed'; 798 1325 } 799 1326 ··· 917 1444 return normalizePullStatus(status.status); 918 1445 }; 919 1446 920 - export const listIssues = async (repo: RepoContext): Promise<IssueSummary[]> => { 1447 + const listIssuesFromBacklinks = async (repo: RepoContext): Promise<IssueSummary[]> => { 921 1448 const refs = Array.from( 922 1449 new Map( 923 1450 ( ··· 943 1470 .sort((left, right) => right.number - left.number); 944 1471 }; 945 1472 946 - export const listIssuesPage = async ( 1473 + const listIssuesPageFromBacklinks = async ( 947 1474 repo: RepoContext, 948 1475 options: { offset: number; limit: number; state: 'open' | 'closed' }, 949 1476 ): Promise<PaginatedResult<IssueSummary>> => ··· 954 1481 getLatestIssueState, 955 1482 ); 956 1483 957 - export const getIssue = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 958 - const issues = await listIssues(repo); 1484 + const getIssueFromBacklinks = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 1485 + const issues = await listIssuesFromBacklinks(repo); 959 1486 const issue = findNumberedRecord(issues, issueRef); 960 1487 if (!issue) { 961 1488 throw new Error(`Issue not found`); ··· 971 1498 }; 972 1499 }; 973 1500 974 - export const listPulls = async (repo: RepoContext): Promise<PullSummary[]> => { 1501 + const listPullsFromBacklinks = async (repo: RepoContext): Promise<PullSummary[]> => { 975 1502 const refs = await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo'); 976 1503 const pulls = await hydrateBacklinks<ShTangledRepoPull.Main>(refs); 977 1504 const numbers = toNumberMap(pulls); ··· 986 1513 .sort((left, right) => right.number - left.number); 987 1514 }; 988 1515 989 - export const listPullsPage = async ( 1516 + const listPullsPageFromBacklinks = async ( 990 1517 repo: RepoContext, 991 1518 options: { offset: number; limit: number; state: 'open' | 'closed' | 'merged' }, 992 1519 ): Promise<PaginatedResult<PullSummary>> => ··· 997 1524 getLatestPullStatus, 998 1525 ); 999 1526 1000 - export const getPull = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { 1001 - const pulls = await listPulls(repo); 1527 + const getPullFromBacklinks = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { 1528 + const pulls = await listPullsFromBacklinks(repo); 1002 1529 const pull = findNumberedRecord(pulls, pullRef); 1003 1530 if (!pull) { 1004 1531 throw new Error(`Pull request not found`); ··· 1014 1541 }; 1015 1542 }; 1016 1543 1544 + const listIssueCommentsFromAppview = async (issueUri: ResourceUri): Promise<IssueComment[]> => { 1545 + const records = await listAllAppviewRecords<ShTangledRepoIssueComment.Main>( 1546 + 'sh.tangled.repo.issue.listComments', 1547 + issueUri, 1548 + ); 1549 + const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 1550 + comments.sort(sortByCreatedAt); 1551 + return comments; 1552 + }; 1553 + 1554 + const listPullCommentsFromAppview = async (pullUri: ResourceUri): Promise<PullComment[]> => { 1555 + const records = await listAllAppviewRecords<ShTangledRepoPullComment.Main>( 1556 + 'sh.tangled.repo.pull.listComments', 1557 + pullUri, 1558 + ); 1559 + const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 1560 + comments.sort(sortByCreatedAt); 1561 + return comments; 1562 + }; 1563 + 1564 + const listIssuesFromAppview = async (repo: RepoContext): Promise<IssueSummary[]> => { 1565 + const records = await listAllAppviewRecords<ShTangledRepoIssue.Main>( 1566 + 'sh.tangled.repo.listIssues', 1567 + repo.repoDid, 1568 + ) as Array<AppviewStatefulRecordView<ShTangledRepoIssue.Main>>; 1569 + const issues = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 1570 + const numbers = toNumberMap(issues); 1571 + 1572 + return issues 1573 + .map((issue, index) => ({ 1574 + ...issue, 1575 + number: numbers.get(issue.uri) ?? index + 1, 1576 + state: normalizeIssueState(records[index].state), 1577 + })) 1578 + .sort((left, right) => right.number - left.number); 1579 + }; 1580 + 1581 + const listIssuesPageFromAppview = async ( 1582 + repo: RepoContext, 1583 + options: { offset: number; limit: number; state: 'open' | 'closed' }, 1584 + ): Promise<PaginatedResult<IssueSummary>> => 1585 + listAppviewStatefulRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 1586 + 'sh.tangled.repo.listIssues', 1587 + repo.repoDid, 1588 + options, 1589 + normalizeIssueState, 1590 + ); 1591 + 1592 + const getIssueFromAppview = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 1593 + const issues = await listIssuesFromAppview(repo); 1594 + const issue = findNumberedRecord(issues, issueRef); 1595 + if (!issue) { 1596 + throw new Error(`Issue not found`); 1597 + } 1598 + 1599 + return { 1600 + issue, 1601 + comments: await listIssueCommentsFromAppview(issue.uri), 1602 + }; 1603 + }; 1604 + 1605 + const listPullsFromAppview = async (repo: RepoContext): Promise<PullSummary[]> => { 1606 + const records = await listAllAppviewRecords<ShTangledRepoPull.Main>( 1607 + 'sh.tangled.repo.listPulls', 1608 + repo.repoDid, 1609 + ) as Array<AppviewStatefulRecordView<ShTangledRepoPull.Main>>; 1610 + const pulls = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 1611 + const numbers = toNumberMap(pulls); 1612 + 1613 + return pulls 1614 + .map((pull, index) => ({ 1615 + ...pull, 1616 + number: numbers.get(pull.uri) ?? index + 1, 1617 + state: normalizePullStatus(records[index].state), 1618 + })) 1619 + .sort((left, right) => right.number - left.number); 1620 + }; 1621 + 1622 + const listPullsPageFromAppview = async ( 1623 + repo: RepoContext, 1624 + options: { offset: number; limit: number; state: 'open' | 'closed' | 'merged' }, 1625 + ): Promise<PaginatedResult<PullSummary>> => 1626 + listAppviewStatefulRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 1627 + 'sh.tangled.repo.listPulls', 1628 + repo.repoDid, 1629 + options, 1630 + normalizePullStatus, 1631 + ); 1632 + 1633 + const getPullFromAppview = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { 1634 + const pulls = await listPullsFromAppview(repo); 1635 + const pull = findNumberedRecord(pulls, pullRef); 1636 + if (!pull) { 1637 + throw new Error(`Pull request not found`); 1638 + } 1639 + 1640 + return { 1641 + pull, 1642 + comments: await listPullCommentsFromAppview(pull.uri), 1643 + }; 1644 + }; 1645 + 1646 + export const listIssues = async (repo: RepoContext): Promise<IssueSummary[]> => 1647 + appviewFallback( 1648 + () => listIssuesFromAppview(repo), 1649 + () => listIssuesFromBacklinks(repo), 1650 + ); 1651 + 1652 + export const listIssuesPage = async ( 1653 + repo: RepoContext, 1654 + options: { offset: number; limit: number; state: 'open' | 'closed' }, 1655 + ): Promise<PaginatedResult<IssueSummary>> => 1656 + appviewFallback( 1657 + () => listIssuesPageFromAppview(repo, options), 1658 + () => listIssuesPageFromBacklinks(repo, options), 1659 + ); 1660 + 1661 + export const getIssue = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => 1662 + appviewFallback( 1663 + () => getIssueFromAppview(repo, issueRef), 1664 + () => getIssueFromBacklinks(repo, issueRef), 1665 + ); 1666 + 1667 + export const listPulls = async (repo: RepoContext): Promise<PullSummary[]> => 1668 + appviewFallback( 1669 + () => listPullsFromAppview(repo), 1670 + () => listPullsFromBacklinks(repo), 1671 + ); 1672 + 1673 + export const listPullsPage = async ( 1674 + repo: RepoContext, 1675 + options: { offset: number; limit: number; state: 'open' | 'closed' | 'merged' }, 1676 + ): Promise<PaginatedResult<PullSummary>> => 1677 + appviewFallback( 1678 + () => listPullsPageFromAppview(repo, options), 1679 + () => listPullsPageFromBacklinks(repo, options), 1680 + ); 1681 + 1682 + export const getPull = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => 1683 + appviewFallback( 1684 + () => getPullFromAppview(repo, pullRef), 1685 + () => getPullFromBacklinks(repo, pullRef), 1686 + ); 1687 + 1017 1688 export const fetchPullRoundPatch = async ( 1018 1689 pull: PullSummary, 1019 1690 roundIndex: number, ··· 1028 1699 throw new Error(`Missing patch blob CID`); 1029 1700 } 1030 1701 1031 - const rpc = getRpc(pull.author.pds); 1702 + const author = pull.author.pds ? pull.author : await resolveActor(pull.author.did); 1703 + const rpc = getRpc(author.pds); 1032 1704 const bytes = await ok( 1033 1705 rpc.get('com.atproto.sync.getBlob', { 1034 1706 params: { 1035 - did: pull.author.did, 1707 + did: author.did, 1036 1708 cid, 1037 1709 }, 1038 1710 as: 'bytes', ··· 1282 1954 return `data:${blob.mimeType};charset=utf-8,${encodeURIComponent(blob.content)}`; 1283 1955 }; 1284 1956 1957 + const getActorProfileFromAppview = async (actor: ResolvedActor): Promise<ShTangledActorProfile.Main | null> => { 1958 + const profile = await appviewJson<AppviewRecordView<ShTangledActorProfile.Main>>( 1959 + 'sh.tangled.actor.getProfile', 1960 + { 1961 + actor: `at://${actor.did}/${ACTOR_PROFILE_COLLECTION}/self`, 1962 + }, 1963 + ); 1964 + return profile.value; 1965 + }; 1966 + 1285 1967 export const resolveAvatarUrl = async (identifier: string): Promise<string | null> => { 1286 1968 const actor = await resolveActor(identifier); 1287 - const tangledProfile = await getOptionalRecord<ShTangledActorProfile.Main>( 1288 - actor, 1289 - ACTOR_PROFILE_COLLECTION, 1290 - 'self', 1291 - ); 1969 + let tangledProfile: ShTangledActorProfile.Main | null; 1970 + try { 1971 + tangledProfile = await getActorProfileFromAppview(actor); 1972 + } catch (cause) { 1973 + if (!(cause instanceof AppviewUnavailableError)) { 1974 + throw cause; 1975 + } 1976 + 1977 + tangledProfile = await getOptionalRecord<ShTangledActorProfile.Main>( 1978 + actor, 1979 + ACTOR_PROFILE_COLLECTION, 1980 + 'self', 1981 + ); 1982 + } 1292 1983 const tangledCid = extractBlobCid(tangledProfile?.avatar); 1293 1984 if (tangledCid) { 1294 1985 return buildPdsBlobUrl(actor.pds, actor.did, tangledCid);
+1
src/lib/api/identity.ts
··· 1 1 export { 2 + clearApiCaches, 2 3 createAuthRpc, 3 4 identityResolver, 4 5 resolveActor,
+124
src/lib/settings.tsx
··· 1 + import { createContext, createSignal, useContext, type Component, type JSX } from 'solid-js'; 2 + 3 + const APPVIEW_URL_KEY = 'untangled.settings.appviewUrl'; 4 + const ROUTE_KNOT_REQUESTS_THROUGH_APPVIEW_KEY = 'untangled.settings.routeKnotRequestsThroughAppview'; 5 + 6 + export const DEFAULT_TANGLED_APPVIEW_SERVICE = 7 + import.meta.env.VITE_TANGLED_APPVIEW_SERVICE || 'https://bobbin.klbr.net'; 8 + 9 + export const normalizeAppviewUrl = (input: string): string => { 10 + const trimmed = input.trim(); 11 + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; 12 + const url = new URL(withScheme); 13 + 14 + if (url.protocol !== 'https:' && url.protocol !== 'http:') { 15 + throw new Error('Appview URL must use http or https'); 16 + } 17 + 18 + url.hash = ''; 19 + url.search = ''; 20 + return url.toString().replace(/\/$/, ''); 21 + }; 22 + 23 + const readStoredAppviewUrl = (): string => { 24 + if (typeof localStorage === 'undefined') { 25 + return normalizeAppviewUrl(DEFAULT_TANGLED_APPVIEW_SERVICE); 26 + } 27 + 28 + const stored = localStorage.getItem(APPVIEW_URL_KEY); 29 + if (!stored) { 30 + return normalizeAppviewUrl(DEFAULT_TANGLED_APPVIEW_SERVICE); 31 + } 32 + 33 + try { 34 + return normalizeAppviewUrl(stored); 35 + } catch { 36 + localStorage.removeItem(APPVIEW_URL_KEY); 37 + return normalizeAppviewUrl(DEFAULT_TANGLED_APPVIEW_SERVICE); 38 + } 39 + }; 40 + 41 + const readStoredRouteKnotRequestsThroughAppview = (): boolean => { 42 + if (typeof localStorage === 'undefined') { 43 + return false; 44 + } 45 + 46 + return localStorage.getItem(ROUTE_KNOT_REQUESTS_THROUGH_APPVIEW_KEY) === 'true'; 47 + }; 48 + 49 + let currentAppviewUrl = readStoredAppviewUrl(); 50 + let currentRouteKnotRequestsThroughAppview = readStoredRouteKnotRequestsThroughAppview(); 51 + 52 + export const getTangledAppviewService = (): string => currentAppviewUrl; 53 + export const getRouteKnotRequestsThroughAppview = (): boolean => currentRouteKnotRequestsThroughAppview; 54 + 55 + interface AppSettingsContextValue { 56 + appviewUrl: () => string; 57 + routeKnotRequestsThroughAppview: () => boolean; 58 + defaultAppviewUrl: string; 59 + setAppviewUrl: (url: string) => void; 60 + setRouteKnotRequestsThroughAppview: (enabled: boolean) => void; 61 + resetAppviewUrl: () => void; 62 + resetAppviewSettings: () => void; 63 + } 64 + 65 + const AppSettingsContext = createContext<AppSettingsContextValue>(); 66 + 67 + export const AppSettingsProvider: Component<{ children: JSX.Element }> = (props) => { 68 + const [appviewUrl, setAppviewUrlSignal] = createSignal(currentAppviewUrl); 69 + const [routeKnotRequestsThroughAppview, setRouteKnotRequestsThroughAppviewSignal] = createSignal( 70 + currentRouteKnotRequestsThroughAppview, 71 + ); 72 + const defaultAppviewUrl = normalizeAppviewUrl(DEFAULT_TANGLED_APPVIEW_SERVICE); 73 + 74 + const setAppviewUrl = (url: string) => { 75 + const normalized = normalizeAppviewUrl(url); 76 + currentAppviewUrl = normalized; 77 + localStorage.setItem(APPVIEW_URL_KEY, normalized); 78 + setAppviewUrlSignal(normalized); 79 + }; 80 + 81 + const setRouteKnotRequestsThroughAppview = (enabled: boolean) => { 82 + currentRouteKnotRequestsThroughAppview = enabled; 83 + localStorage.setItem(ROUTE_KNOT_REQUESTS_THROUGH_APPVIEW_KEY, String(enabled)); 84 + setRouteKnotRequestsThroughAppviewSignal(enabled); 85 + }; 86 + 87 + const resetAppviewUrl = () => { 88 + currentAppviewUrl = defaultAppviewUrl; 89 + localStorage.removeItem(APPVIEW_URL_KEY); 90 + setAppviewUrlSignal(defaultAppviewUrl); 91 + }; 92 + 93 + const resetAppviewSettings = () => { 94 + resetAppviewUrl(); 95 + currentRouteKnotRequestsThroughAppview = false; 96 + localStorage.removeItem(ROUTE_KNOT_REQUESTS_THROUGH_APPVIEW_KEY); 97 + setRouteKnotRequestsThroughAppviewSignal(false); 98 + }; 99 + 100 + return ( 101 + <AppSettingsContext.Provider 102 + value={{ 103 + appviewUrl, 104 + routeKnotRequestsThroughAppview, 105 + defaultAppviewUrl, 106 + setAppviewUrl, 107 + setRouteKnotRequestsThroughAppview, 108 + resetAppviewUrl, 109 + resetAppviewSettings, 110 + }} 111 + > 112 + {props.children} 113 + </AppSettingsContext.Provider> 114 + ); 115 + }; 116 + 117 + export const useAppSettings = (): AppSettingsContextValue => { 118 + const context = useContext(AppSettingsContext); 119 + if (!context) { 120 + throw new Error('useAppSettings must be used inside AppSettingsProvider'); 121 + } 122 + 123 + return context; 124 + };