Monorepo for Tangled
0

Configure Feed

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

web: add optimistic overlays and record mutation apis

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

dawn (Jul 10, 2026, 8:09 PM +0300) 3d8d2760 db34e87c

+269 -6
+110
web/src/lib/api/graph.ts
··· 1 + import { ok } from '@atcute/client'; 2 + import { mainSchema as createRecordSchema } from '@atcute/atproto/types/repo/createRecord'; 3 + import { mainSchema as deleteRecordSchema } from '@atcute/atproto/types/repo/deleteRecord'; 4 + import type { Did, Nsid, RecordKey } from '@atcute/lexicons/syntax'; 5 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 6 + import { createClient } from '$lib/auth/agent'; 7 + import type { BobbinContext } from './client'; 8 + import { items } from './pagination'; 9 + import { rkeyFromUri } from './uri'; 10 + import type * as ShTangledGraphFollow from './lexicons/types/sh/tangled/graph/follow'; 11 + import type * as ShTangledGraphVouch from './lexicons/types/sh/tangled/graph/vouch'; 12 + import type * as ShTangledFeedStar from './lexicons/types/sh/tangled/feed/star'; 13 + 14 + export type FollowRecord = ShTangledGraphFollow.Main; 15 + export type VouchRecord = ShTangledGraphVouch.Main; 16 + export type StarRecord = ShTangledFeedStar.Main; 17 + 18 + const FOLLOW_COLLECTION = 'sh.tangled.graph.follow' as Nsid; 19 + const STAR_COLLECTION = 'sh.tangled.feed.star' as Nsid; 20 + 21 + // TODO(bobbin): needs a relation point-lookup (e.g. graph.getFollow?actor=&subject=) 22 + // or a `viewer` hydration param on lists; scanning listFollowsBy pages is O(follows). 23 + export const findFollowRkey = async ( 24 + ctx: BobbinContext, 25 + viewerDid: string, 26 + subject: string, 27 + options: { maxPages?: number } = {} 28 + ): Promise<string | null> => { 29 + for await (const item of items( 30 + ctx, 31 + 'sh.tangled.graph.listFollowsBy', 32 + { subject: viewerDid as Did }, 33 + { maxPages: options.maxPages ?? 10 } 34 + )) { 35 + if ((item.value as FollowRecord).subject === subject) return rkeyFromUri(item.uri); 36 + } 37 + return null; 38 + }; 39 + 40 + const createGenericRecord = async <T extends { $type: string }>( 41 + agent: OAuthUserAgent, 42 + collection: Nsid, 43 + record: T 44 + ): Promise<string> => { 45 + const rpc = createClient(agent); 46 + const result = await ok( 47 + rpc.call(createRecordSchema, { 48 + input: { repo: agent.sub, collection, record } 49 + }) 50 + ); 51 + return rkeyFromUri(result.uri); 52 + }; 53 + 54 + const deleteGenericRecord = async ( 55 + agent: OAuthUserAgent, 56 + collection: Nsid, 57 + rkey: string 58 + ): Promise<void> => { 59 + const rpc = createClient(agent); 60 + await ok( 61 + rpc.call(deleteRecordSchema, { 62 + input: { repo: agent.sub, collection, rkey: rkey as RecordKey } 63 + }) 64 + ); 65 + }; 66 + 67 + export const createFollow = (agent: OAuthUserAgent, subject: string): Promise<string> => 68 + createGenericRecord(agent, FOLLOW_COLLECTION, { 69 + $type: 'sh.tangled.graph.follow', 70 + subject: subject as Did, 71 + createdAt: new Date().toISOString() 72 + }); 73 + 74 + export const deleteFollow = (agent: OAuthUserAgent, rkey: string): Promise<void> => 75 + deleteGenericRecord(agent, FOLLOW_COLLECTION, rkey); 76 + 77 + export const createStar = (agent: OAuthUserAgent, repoDid: string): Promise<string> => 78 + createGenericRecord(agent, STAR_COLLECTION, { 79 + $type: 'sh.tangled.feed.star', 80 + subject: { 81 + $type: 'sh.tangled.feed.star#repo', 82 + did: repoDid as Did 83 + }, 84 + createdAt: new Date().toISOString() 85 + }); 86 + 87 + export const deleteStar = (agent: OAuthUserAgent, rkey: string): Promise<void> => 88 + deleteGenericRecord(agent, STAR_COLLECTION, rkey); 89 + 90 + // TODO(bobbin): same relation-lookup gap as findFollowRkey; walking every star of 91 + // the viewer to build this map is O(stars) per page load. 92 + export const listStarRkeys = async ( 93 + ctx: BobbinContext, 94 + viewerDid: string, 95 + options: { maxPages?: number } = {} 96 + ): Promise<Map<string, string>> => { 97 + const rkeys = new Map<string, string>(); 98 + for await (const item of items( 99 + ctx, 100 + 'sh.tangled.feed.listStarsBy', 101 + { subject: viewerDid as Did }, 102 + { maxPages: options.maxPages ?? 10 } 103 + )) { 104 + const value = item.value as StarRecord; 105 + if (value.subject.$type === 'sh.tangled.feed.star#repo') { 106 + rkeys.set(value.subject.did, rkeyFromUri(item.uri)); 107 + } 108 + } 109 + return rkeys; 110 + };
+2
web/src/lib/api/index.ts
··· 7 7 export * from './coverage'; 8 8 export * from './identity'; 9 9 export * from './load'; 10 + export * from './uri'; 11 + export * from './graph';
+23
web/src/lib/api/profile.ts
··· 1 + import { ok } from '@atcute/client'; 2 + import { mainSchema as putRecordSchema } from '@atcute/atproto/types/repo/putRecord'; 3 + import type { Nsid, RecordKey } from '@atcute/lexicons/syntax'; 4 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 5 + import { createClient } from '$lib/auth/agent'; 6 + import type { ProfileRecord } from './records'; 7 + 8 + const PROFILE_COLLECTION = 'sh.tangled.actor.profile' as Nsid; 9 + 10 + // the profile record lives at the fixed rkey `self`; put upserts it. 11 + export const putProfile = async (agent: OAuthUserAgent, record: ProfileRecord): Promise<void> => { 12 + const rpc = createClient(agent); 13 + await ok( 14 + rpc.call(putRecordSchema, { 15 + input: { 16 + repo: agent.sub, 17 + collection: PROFILE_COLLECTION, 18 + rkey: 'self' as RecordKey, 19 + record 20 + } 21 + }) 22 + ); 23 + };
+7 -5
web/src/lib/api/records.ts
··· 5 5 import type * as ShTangledRepoIssue from './lexicons/types/sh/tangled/repo/issue'; 6 6 import type * as ShTangledRepoPull from './lexicons/types/sh/tangled/repo/pull'; 7 7 8 - /** getRecord-shaped view returned by the hydrated single-record `get*` endpoints. */ 9 8 export interface RecordView<V> { 10 9 uri: string; 11 10 cid?: string; 12 11 value: V; 13 12 } 14 13 15 - /** Envelope returned by the bulk `get*s` endpoints (no cursor — inputs are explicit uris). */ 16 14 export interface RecordList<V> { 17 15 items: RecordView<V>[]; 18 16 } ··· 22 20 export type IssueRecord = ShTangledRepoIssue.Main; 23 21 export type PullRecord = ShTangledRepoPull.Main; 24 22 25 - /** Max uris accepted per bulk request (mirrors bobbin's BULK_LIMIT). */ 26 23 export const BULK_LIMIT = 50; 27 24 28 25 export const getRepo = (ctx: BobbinContext, repo: string, init?: XrpcRequestInit) => ··· 31 28 export const getRepoByRepoDid = (ctx: BobbinContext, repoDid: string, init?: XrpcRequestInit) => 32 29 jsonGet<RecordView<RepoRecord>>(ctx, 'sh.tangled.repo.getRepoByRepoDid', { repoDid }, init); 33 30 34 - export const getProfile = (ctx: BobbinContext, actor: string, init?: XrpcRequestInit) => 35 - jsonGet<RecordView<ProfileRecord>>(ctx, 'sh.tangled.actor.getProfile', { actor }, init); 31 + export const getProfile = (ctx: BobbinContext, did: string, init?: XrpcRequestInit) => 32 + jsonGet<RecordView<ProfileRecord>>( 33 + ctx, 34 + 'sh.tangled.actor.getProfile', 35 + { actor: `at://${did}/sh.tangled.actor.profile/self` }, 36 + init 37 + ); 36 38 37 39 export const getIssue = (ctx: BobbinContext, issue: string, init?: XrpcRequestInit) => 38 40 jsonGet<RecordView<IssueRecord>>(ctx, 'sh.tangled.repo.getIssue', { issue }, init);
-1
web/src/lib/api/search.ts
··· 27 27 limit?: number; 28 28 } 29 29 30 - /** One page of full-text search hits. */ 31 30 export const search = (ctx: BobbinContext, params: SearchParams, init?: XrpcRequestInit) => 32 31 jsonGet<SearchPage>(ctx, 'sh.tangled.search.query', { ...params }, init); 33 32
+9
web/src/lib/api/uri.ts
··· 1 + // at-uri helpers: at://<authority>/<collection>/<rkey> 2 + 3 + export const didFromUri = (uri: string): string => { 4 + const rest = uri.startsWith('at://') ? uri.slice(5) : uri; 5 + const slash = rest.indexOf('/'); 6 + return slash === -1 ? rest : rest.slice(0, slash); 7 + }; 8 + 9 + export const rkeyFromUri = (uri: string): string => uri.slice(uri.lastIndexOf('/') + 1);
+118
web/src/lib/optimistic.svelte.ts
··· 1 + // optimistic overlays for eventually-consistent bobbin reads: mutations commit 2 + // locally (the pds write is authoritative) and reconcile on natural reloads. 3 + // `key` scopes an overlay to its subject so reused components drop it. 4 + // TODO(bobbin): read-your-writes (serve at-or-after a commit rev) would let 5 + // mutations invalidate loads immediately instead of waiting for navigation. 6 + 7 + interface OptimisticCountOptions { 8 + key: () => string; 9 + loaded: () => number | null | undefined; 10 + } 11 + 12 + export interface OptimisticCount { 13 + readonly value: number; 14 + readonly failed: boolean; 15 + adjust(delta: number): void; 16 + fail(): void; 17 + resetFailure(): void; 18 + } 19 + 20 + export const createOptimisticCount = (options: OptimisticCountOptions): OptimisticCount => { 21 + let failed = $state(false); 22 + // a bump is a bound: fresher data may pass it, never regress across it. 23 + let held = $state<null | { key: string; value: number; up: boolean }>(null); 24 + const currentKey = $derived(options.key()); 25 + const loaded = $derived(Math.max(0, options.loaded() ?? 0)); 26 + 27 + const value = $derived.by(() => { 28 + if (held === null || held.key !== currentKey) return loaded; 29 + return held.up ? Math.max(loaded, held.value) : Math.min(loaded, held.value); 30 + }); 31 + 32 + $effect(() => { 33 + if (held === null) return; 34 + const caughtUp = held.up ? loaded >= held.value : loaded <= held.value; 35 + if (held.key !== currentKey || caughtUp) held = null; 36 + }); 37 + 38 + return { 39 + get value() { 40 + return value; 41 + }, 42 + get failed() { 43 + return failed; 44 + }, 45 + adjust(delta) { 46 + failed = false; 47 + held = { key: currentKey, value: Math.max(0, value + delta), up: delta > 0 }; 48 + }, 49 + fail() { 50 + failed = true; 51 + }, 52 + resetFailure() { 53 + failed = false; 54 + } 55 + }; 56 + }; 57 + 58 + interface OptimisticRelationOptions { 59 + key: () => string; 60 + loadedRkey: () => string | null | undefined; 61 + } 62 + 63 + export interface OptimisticRelation { 64 + readonly rkey: string | null; 65 + readonly known: boolean; 66 + readonly active: boolean; 67 + readonly failed: boolean; 68 + created(rkey: string): void; 69 + deleted(): void; 70 + fail(): void; 71 + resetFailure(): void; 72 + } 73 + 74 + export const createOptimisticRelation = ( 75 + options: OptimisticRelationOptions 76 + ): OptimisticRelation => { 77 + let failed = $state(false); 78 + let committed = $state<null | { key: string; rkey: string | null }>(null); 79 + const currentKey = $derived(options.key()); 80 + const loaded = $derived(options.loadedRkey()); 81 + const rkey = $derived(committed?.key === currentKey ? committed.rkey : (loaded ?? null)); 82 + 83 + $effect(() => { 84 + if (committed === null) return; 85 + if (committed.key !== currentKey || loaded === committed.rkey) { 86 + committed = null; 87 + failed = false; 88 + } 89 + }); 90 + 91 + const set = (next: string | null): void => { 92 + failed = false; 93 + committed = { key: currentKey, rkey: next }; 94 + }; 95 + 96 + return { 97 + get rkey() { 98 + return rkey; 99 + }, 100 + get known() { 101 + return loaded !== undefined || committed?.key === currentKey; 102 + }, 103 + get active() { 104 + return rkey !== null; 105 + }, 106 + get failed() { 107 + return failed; 108 + }, 109 + created: set, 110 + deleted: () => set(null), 111 + fail() { 112 + failed = true; 113 + }, 114 + resetFailure() { 115 + failed = false; 116 + } 117 + }; 118 + };