your personal website on atproto - mirror blento.app
at small-improvements 588 lines 15 kB view raw
1import { 2 parseResourceUri, 3 type ActorIdentifier, 4 type Did, 5 type Handle, 6 type ResourceUri 7} from '@atcute/lexicons'; 8import { user } from './auth.svelte'; 9import type { AllowedCollection } from './settings'; 10import { 11 CompositeDidDocumentResolver, 12 CompositeHandleResolver, 13 DohJsonHandleResolver, 14 PlcDidDocumentResolver, 15 WebDidDocumentResolver, 16 WellKnownHandleResolver 17} from '@atcute/identity-resolver'; 18import { Client, simpleFetchHandler } from '@atcute/client'; 19import { type AppBskyActorDefs } from '@atcute/bluesky'; 20 21export type Collection = `${string}.${string}.${string}`; 22import * as TID from '@atcute/tid'; 23 24/** 25 * Parses an AT Protocol URI into its components. 26 * @param uri - The AT URI to parse (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 27 * @returns An object containing the repo, collection, and rkey or undefined if not an AT uri 28 */ 29export function parseUri(uri: string) { 30 const parts = parseResourceUri(uri); 31 if (!parts.ok) return; 32 return parts.value; 33} 34 35/** 36 * Resolves a handle to a DID using DNS and HTTP methods. 37 * @param handle - The handle to resolve (e.g., "alice.bsky.social") 38 * @returns The DID associated with the handle 39 */ 40export async function resolveHandle({ handle }: { handle: Handle }) { 41 const handleResolver = new CompositeHandleResolver({ 42 methods: { 43 dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), 44 http: new WellKnownHandleResolver() 45 } 46 }); 47 48 const data = await handleResolver.resolve(handle); 49 return data; 50} 51 52const didResolver = new CompositeDidDocumentResolver({ 53 methods: { 54 plc: new PlcDidDocumentResolver(), 55 web: new WebDidDocumentResolver() 56 } 57}); 58 59/** 60 * Gets the PDS (Personal Data Server) URL for a given DID. 61 * @param did - The DID to look up 62 * @returns The PDS service endpoint URL 63 * @throws If no PDS is found in the DID document 64 */ 65export async function getPDS(did: Did) { 66 const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>); 67 if (!doc.service) throw new Error('No PDS found'); 68 for (const service of doc.service) { 69 if (service.id === '#atproto_pds') { 70 return service.serviceEndpoint.toString(); 71 } 72 } 73} 74 75/** 76 * Fetches a detailed Bluesky profile for a user. 77 * @param data - Optional object with did and client 78 * @param data.did - The DID to fetch the profile for (defaults to current user) 79 * @param data.client - The client to use (defaults to public Bluesky API) 80 * @returns The profile data or undefined if not found 81 */ 82export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { 83 data ??= {}; 84 data.did ??= user.did; 85 86 if (!data.did) throw new Error('Error getting detailed profile: no did'); 87 88 data.client ??= new Client({ 89 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 90 }); 91 92 const response = await data.client.get('app.bsky.actor.getProfile', { 93 params: { actor: data.did } 94 }); 95 96 if (!response.ok || response.data.handle === 'handle.invalid') { 97 const repo = await describeRepo({ did: data.did }); 98 return { handle: repo?.handle ?? 'handle.invalid', did: data.did }; 99 } 100 101 return response.data; 102} 103 104export async function getBlentoOrBskyProfile(data: { did: Did; client?: Client }): Promise< 105 Awaited<ReturnType<typeof getDetailedProfile>> & { 106 hasBlento: boolean; 107 url?: string; 108 } 109> { 110 let blentoProfile; 111 try { 112 // try getting blento profile first 113 blentoProfile = await getRecord({ 114 collection: 'site.standard.publication', 115 did: data?.did, 116 rkey: 'blento.self', 117 client: data?.client 118 }); 119 } catch { 120 console.error('error getting blento profile, falling back to bsky profile'); 121 } 122 123 let response; 124 try { 125 response = await getDetailedProfile(data); 126 } catch { 127 // public API or PDS unreachable — return minimal profile 128 } 129 130 const avatar = blentoProfile?.value?.icon 131 ? getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) 132 : response?.avatar; 133 134 return { 135 did: data.did, 136 handle: response?.handle ?? (data.did as `${string}.${string}`), 137 displayName: blentoProfile?.value?.name || response?.displayName || response?.handle, 138 avatar: avatar as `${string}:${string}`, 139 hasBlento: Boolean(blentoProfile?.value), 140 url: blentoProfile?.value?.url as string | undefined 141 }; 142} 143 144/** 145 * Creates an AT Protocol client for a user's PDS. 146 * @param did - The DID of the user 147 * @returns A client configured for the user's PDS 148 * @throws If the PDS cannot be found 149 */ 150export async function getClient({ did }: { did: Did }) { 151 const pds = await getPDS(did); 152 if (!pds) throw new Error('PDS not found'); 153 154 const client = new Client({ 155 handler: simpleFetchHandler({ service: pds }) 156 }); 157 158 return client; 159} 160 161/** 162 * Lists records from a repository collection with pagination support. 163 * @param did - The DID of the repository (defaults to current user) 164 * @param collection - The collection to list records from 165 * @param cursor - Pagination cursor for continuing from a previous request 166 * @param limit - Maximum number of records to return (default 100, set to 0 for all records) 167 * @param client - The client to use (defaults to user's PDS client) 168 * @returns An array of records from the collection 169 */ 170export async function listRecords({ 171 did, 172 collection, 173 cursor, 174 limit = 100, 175 client 176}: { 177 did?: Did; 178 collection: `${string}.${string}.${string}`; 179 cursor?: string; 180 limit?: number; 181 client?: Client; 182}) { 183 did ??= user.did; 184 if (!collection) { 185 throw new Error('Missing parameters for listRecords'); 186 } 187 if (!did) { 188 throw new Error('Missing did for getRecord'); 189 } 190 191 client ??= await getClient({ did }); 192 193 const allRecords = []; 194 195 let currentCursor = cursor; 196 do { 197 const response = await client.get('com.atproto.repo.listRecords', { 198 params: { 199 repo: did, 200 collection, 201 limit: !limit || limit > 100 ? 100 : limit, 202 cursor: currentCursor 203 } 204 }); 205 206 if (!response.ok) { 207 return allRecords; 208 } 209 210 allRecords.push(...response.data.records); 211 currentCursor = response.data.cursor; 212 } while (currentCursor && (!limit || allRecords.length < limit)); 213 214 return allRecords; 215} 216 217/** 218 * Fetches a single record from a repository. 219 * @param did - The DID of the repository (defaults to current user) 220 * @param collection - The collection the record belongs to 221 * @param rkey - The record key (defaults to "self") 222 * @param client - The client to use (defaults to user's PDS client) 223 * @returns The record data 224 */ 225export async function getRecord({ 226 did, 227 collection, 228 rkey = 'self', 229 client 230}: { 231 did?: Did; 232 collection: Collection; 233 rkey?: string; 234 client?: Client; 235}) { 236 did ??= user.did; 237 238 if (!collection) { 239 throw new Error('Missing parameters for getRecord'); 240 } 241 if (!did) { 242 throw new Error('Missing did for getRecord'); 243 } 244 245 client ??= await getClient({ did }); 246 247 const record = await client.get('com.atproto.repo.getRecord', { 248 params: { 249 repo: did, 250 collection, 251 rkey 252 } 253 }); 254 255 if (!record.ok) 256 throw new Error((record.data as { message?: string })?.message ?? 'Record not found'); 257 258 return JSON.parse(JSON.stringify(record.data)); 259} 260 261/** 262 * Creates or updates a record in the current user's repository. 263 * Only accepts collections that are configured in permissions. 264 * @param collection - The collection to write to (must be in permissions.collections) 265 * @param rkey - The record key (defaults to "self") 266 * @param record - The record data to write 267 * @returns The response from the PDS 268 * @throws If the user is not logged in 269 */ 270export async function putRecord({ 271 collection, 272 rkey = 'self', 273 record 274}: { 275 collection: AllowedCollection; 276 rkey?: string; 277 record: Record<string, unknown>; 278}) { 279 if (!user.client || !user.did) throw new Error('No rpc or did'); 280 281 const response = await user.client.post('com.atproto.repo.putRecord', { 282 input: { 283 collection, 284 repo: user.did, 285 rkey, 286 record: { 287 ...record 288 } 289 } 290 }); 291 292 return response; 293} 294 295/** 296 * Deletes a record from the current user's repository. 297 * Only accepts collections that are configured in permissions. 298 * @param collection - The collection the record belongs to (must be in permissions.collections) 299 * @param rkey - The record key (defaults to "self") 300 * @returns True if the deletion was successful 301 * @throws If the user is not logged in 302 */ 303export async function deleteRecord({ 304 collection, 305 rkey = 'self' 306}: { 307 collection: AllowedCollection; 308 rkey: string; 309}) { 310 if (!user.client || !user.did) throw new Error('No profile or rpc or did'); 311 312 const response = await user.client.post('com.atproto.repo.deleteRecord', { 313 input: { 314 collection, 315 repo: user.did, 316 rkey 317 } 318 }); 319 320 return response.ok; 321} 322 323/** 324 * Uploads a blob to the current user's PDS. 325 * @param blob - The blob data to upload 326 * @returns The blob metadata including ref, mimeType, and size, or undefined on failure 327 * @throws If the user is not logged in 328 */ 329export async function uploadBlob({ blob }: { blob: Blob }) { 330 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in"); 331 332 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', { 333 params: { 334 repo: user.did 335 }, 336 input: blob 337 }); 338 339 if (!blobResponse?.ok) return; 340 341 const blobInfo = blobResponse?.data.blob as { 342 $type: 'blob'; 343 ref: { 344 $link: string; 345 }; 346 mimeType: string; 347 size: number; 348 }; 349 350 return blobInfo; 351} 352 353/** 354 * Gets metadata about a repository. 355 * @param client - The client to use 356 * @param did - The DID of the repository (defaults to current user) 357 * @returns Repository metadata or undefined on failure 358 */ 359export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 360 did ??= user.did; 361 if (!did) { 362 throw new Error('Error describeRepo: No did'); 363 } 364 client ??= await getClient({ did }); 365 366 const repo = await client.get('com.atproto.repo.describeRepo', { 367 params: { 368 repo: did 369 } 370 }); 371 if (!repo.ok) return; 372 373 return repo.data; 374} 375 376/** 377 * Constructs a URL to fetch a blob directly from a user's PDS. 378 * @param did - The DID of the user who owns the blob 379 * @param blob - The blob reference object 380 * @returns The URL to fetch the blob 381 */ 382export async function getBlobURL({ 383 did, 384 blob 385}: { 386 did: Did; 387 blob: { 388 $type: 'blob'; 389 ref: { 390 $link: string; 391 }; 392 }; 393}) { 394 const pds = await getPDS(did); 395 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 396} 397 398/** 399 * Constructs a Bluesky CDN URL for an image blob. 400 * @param did - The DID of the user who owns the blob (defaults to current user) 401 * @param blob - The blob reference object 402 * @returns The CDN URL for the image in webp format 403 */ 404export function getCDNImageBlobUrl({ 405 did, 406 blob, 407 type = 'webp' 408}: { 409 did?: string; 410 blob: { 411 $type: 'blob'; 412 ref: { 413 $link: string; 414 }; 415 }; 416 type?: 'webp' | 'jpeg'; 417}) { 418 if (!blob || !did) return; 419 did ??= user.did; 420 421 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${type}`; 422} 423 424/** 425 * Searches for actors with typeahead/autocomplete functionality. 426 * @param q - The search query 427 * @param limit - Maximum number of results (default 10) 428 * @param host - The API host to use (defaults to public Bluesky API) 429 * @returns An object containing matching actors and the original query 430 */ 431export async function searchActorsTypeahead( 432 q: string, 433 limit: number = 10, 434 host?: string 435): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> { 436 host ??= 'https://public.api.bsky.app'; 437 438 const client = new Client({ 439 handler: simpleFetchHandler({ service: host }) 440 }); 441 442 const response = await client.get('app.bsky.actor.searchActorsTypeahead', { 443 params: { 444 q, 445 limit 446 } 447 }); 448 449 if (!response.ok) return { actors: [], q }; 450 451 return { actors: response.data.actors, q }; 452} 453 454/** 455 * Return a TID based on current time 456 * 457 * @returns TID for current time 458 */ 459export function createTID() { 460 return TID.now(); 461} 462 463export async function getAuthorFeed(data?: { 464 did?: Did; 465 client?: Client; 466 filter?: string; 467 limit?: number; 468 cursor?: string; 469}) { 470 data ??= {}; 471 data.did ??= user.did; 472 473 if (!data.did) throw new Error('Error getting detailed profile: no did'); 474 475 data.client ??= new Client({ 476 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 477 }); 478 479 const response = await data.client.get('app.bsky.feed.getAuthorFeed', { 480 params: { 481 actor: data.did, 482 filter: data.filter ?? 'posts_with_media', 483 limit: data.limit || 100, 484 cursor: data.cursor 485 } 486 }); 487 488 if (!response.ok) return; 489 490 return response.data; 491} 492 493/** 494 * Fetches posts by their AT URIs. 495 * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 496 * @param client - The client to use (defaults to public Bluesky API) 497 * @returns Array of posts or undefined on failure 498 */ 499export async function getPosts(data: { uris: string[]; client?: Client }) { 500 data.client ??= new Client({ 501 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 502 }); 503 504 const response = await data.client.get('app.bsky.feed.getPosts', { 505 params: { uris: data.uris as ResourceUri[] } 506 }); 507 508 if (!response.ok) return; 509 510 return response.data.posts; 511} 512 513export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier { 514 if (profile.handle && profile.handle !== 'handle.invalid') { 515 return profile.handle; 516 } else { 517 return profile.did; 518 } 519} 520 521/** 522 * Fetches a post's thread including replies. 523 * @param uri - The AT URI of the post 524 * @param depth - How many levels of replies to fetch (default 1) 525 * @param client - The client to use (defaults to public Bluesky API) 526 * @returns The thread data or undefined on failure 527 */ 528export async function getPostThread({ 529 uri, 530 depth = 1, 531 client 532}: { 533 uri: string; 534 depth?: number; 535 client?: Client; 536}) { 537 client ??= new Client({ 538 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 539 }); 540 541 const response = await client.get('app.bsky.feed.getPostThread', { 542 params: { uri: uri as ResourceUri, depth } 543 }); 544 545 if (!response.ok) return; 546 547 return response.data.thread; 548} 549 550/** 551 * Creates a Bluesky post on the authenticated user's account. 552 * @param text - The post text 553 * @param facets - Optional rich text facets (links, mentions, etc.) 554 * @returns The response containing the post's URI and CID 555 * @throws If the user is not logged in 556 */ 557export async function createPost({ 558 text, 559 facets 560}: { 561 text: string; 562 facets?: Array<{ 563 index: { byteStart: number; byteEnd: number }; 564 features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>; 565 }>; 566}) { 567 if (!user.client || !user.did) throw new Error('No client or did'); 568 569 const record: Record<string, unknown> = { 570 $type: 'app.bsky.feed.post', 571 text, 572 createdAt: new Date().toISOString() 573 }; 574 575 if (facets) { 576 record.facets = facets; 577 } 578 579 const response = await user.client.post('com.atproto.repo.createRecord', { 580 input: { 581 collection: 'app.bsky.feed.post', 582 repo: user.did, 583 record 584 } 585 }); 586 587 return response; 588}