your personal website on atproto - mirror blento.app
at mobile-editing 567 lines 14 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 } 108> { 109 let blentoProfile; 110 try { 111 // try getting blento profile first 112 blentoProfile = await getRecord({ 113 collection: 'site.standard.publication', 114 did: data?.did, 115 rkey: 'blento.self', 116 client: data?.client 117 }); 118 } catch { 119 console.error('error getting blento profile, falling back to bsky profile'); 120 } 121 122 const response = await getDetailedProfile(data); 123 124 return { 125 did: data.did, 126 handle: response?.handle, 127 displayName: blentoProfile?.value?.name || response?.displayName || response?.handle, 128 avatar: (getCDNImageBlobUrl({ did: data?.did, blob: blentoProfile?.value?.icon }) || 129 response?.avatar) as `${string}:${string}`, 130 hasBlento: Boolean(blentoProfile.value) 131 }; 132} 133 134/** 135 * Creates an AT Protocol client for a user's PDS. 136 * @param did - The DID of the user 137 * @returns A client configured for the user's PDS 138 * @throws If the PDS cannot be found 139 */ 140export async function getClient({ did }: { did: Did }) { 141 const pds = await getPDS(did); 142 if (!pds) throw new Error('PDS not found'); 143 144 const client = new Client({ 145 handler: simpleFetchHandler({ service: pds }) 146 }); 147 148 return client; 149} 150 151/** 152 * Lists records from a repository collection with pagination support. 153 * @param did - The DID of the repository (defaults to current user) 154 * @param collection - The collection to list records from 155 * @param cursor - Pagination cursor for continuing from a previous request 156 * @param limit - Maximum number of records to return (default 100, set to 0 for all records) 157 * @param client - The client to use (defaults to user's PDS client) 158 * @returns An array of records from the collection 159 */ 160export async function listRecords({ 161 did, 162 collection, 163 cursor, 164 limit = 100, 165 client 166}: { 167 did?: Did; 168 collection: `${string}.${string}.${string}`; 169 cursor?: string; 170 limit?: number; 171 client?: Client; 172}) { 173 did ??= user.did; 174 if (!collection) { 175 throw new Error('Missing parameters for listRecords'); 176 } 177 if (!did) { 178 throw new Error('Missing did for getRecord'); 179 } 180 181 client ??= await getClient({ did }); 182 183 const allRecords = []; 184 185 let currentCursor = cursor; 186 do { 187 const response = await client.get('com.atproto.repo.listRecords', { 188 params: { 189 repo: did, 190 collection, 191 limit: !limit || limit > 100 ? 100 : limit, 192 cursor: currentCursor 193 } 194 }); 195 196 if (!response.ok) { 197 return allRecords; 198 } 199 200 allRecords.push(...response.data.records); 201 currentCursor = response.data.cursor; 202 } while (currentCursor && (!limit || allRecords.length < limit)); 203 204 return allRecords; 205} 206 207/** 208 * Fetches a single record from a repository. 209 * @param did - The DID of the repository (defaults to current user) 210 * @param collection - The collection the record belongs to 211 * @param rkey - The record key (defaults to "self") 212 * @param client - The client to use (defaults to user's PDS client) 213 * @returns The record data 214 */ 215export async function getRecord({ 216 did, 217 collection, 218 rkey = 'self', 219 client 220}: { 221 did?: Did; 222 collection: Collection; 223 rkey?: string; 224 client?: Client; 225}) { 226 did ??= user.did; 227 228 if (!collection) { 229 throw new Error('Missing parameters for getRecord'); 230 } 231 if (!did) { 232 throw new Error('Missing did for getRecord'); 233 } 234 235 client ??= await getClient({ did }); 236 237 const record = await client.get('com.atproto.repo.getRecord', { 238 params: { 239 repo: did, 240 collection, 241 rkey 242 } 243 }); 244 245 return JSON.parse(JSON.stringify(record.data)); 246} 247 248/** 249 * Creates or updates a record in the current user's repository. 250 * Only accepts collections that are configured in permissions. 251 * @param collection - The collection to write to (must be in permissions.collections) 252 * @param rkey - The record key (defaults to "self") 253 * @param record - The record data to write 254 * @returns The response from the PDS 255 * @throws If the user is not logged in 256 */ 257export async function putRecord({ 258 collection, 259 rkey = 'self', 260 record 261}: { 262 collection: AllowedCollection; 263 rkey?: string; 264 record: Record<string, unknown>; 265}) { 266 if (!user.client || !user.did) throw new Error('No rpc or did'); 267 268 const response = await user.client.post('com.atproto.repo.putRecord', { 269 input: { 270 collection, 271 repo: user.did, 272 rkey, 273 record: { 274 ...record 275 } 276 } 277 }); 278 279 return response; 280} 281 282/** 283 * Deletes a record from the current user's repository. 284 * Only accepts collections that are configured in permissions. 285 * @param collection - The collection the record belongs to (must be in permissions.collections) 286 * @param rkey - The record key (defaults to "self") 287 * @returns True if the deletion was successful 288 * @throws If the user is not logged in 289 */ 290export async function deleteRecord({ 291 collection, 292 rkey = 'self' 293}: { 294 collection: AllowedCollection; 295 rkey: string; 296}) { 297 if (!user.client || !user.did) throw new Error('No profile or rpc or did'); 298 299 const response = await user.client.post('com.atproto.repo.deleteRecord', { 300 input: { 301 collection, 302 repo: user.did, 303 rkey 304 } 305 }); 306 307 return response.ok; 308} 309 310/** 311 * Uploads a blob to the current user's PDS. 312 * @param blob - The blob data to upload 313 * @returns The blob metadata including ref, mimeType, and size, or undefined on failure 314 * @throws If the user is not logged in 315 */ 316export async function uploadBlob({ blob }: { blob: Blob }) { 317 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in"); 318 319 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', { 320 params: { 321 repo: user.did 322 }, 323 input: blob 324 }); 325 326 if (!blobResponse?.ok) return; 327 328 const blobInfo = blobResponse?.data.blob as { 329 $type: 'blob'; 330 ref: { 331 $link: string; 332 }; 333 mimeType: string; 334 size: number; 335 }; 336 337 return blobInfo; 338} 339 340/** 341 * Gets metadata about a repository. 342 * @param client - The client to use 343 * @param did - The DID of the repository (defaults to current user) 344 * @returns Repository metadata or undefined on failure 345 */ 346export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 347 did ??= user.did; 348 if (!did) { 349 throw new Error('Error describeRepo: No did'); 350 } 351 client ??= await getClient({ did }); 352 353 const repo = await client.get('com.atproto.repo.describeRepo', { 354 params: { 355 repo: did 356 } 357 }); 358 if (!repo.ok) return; 359 360 return repo.data; 361} 362 363/** 364 * Constructs a URL to fetch a blob directly from a user's PDS. 365 * @param did - The DID of the user who owns the blob 366 * @param blob - The blob reference object 367 * @returns The URL to fetch the blob 368 */ 369export async function getBlobURL({ 370 did, 371 blob 372}: { 373 did: Did; 374 blob: { 375 $type: 'blob'; 376 ref: { 377 $link: string; 378 }; 379 }; 380}) { 381 const pds = await getPDS(did); 382 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 383} 384 385/** 386 * Constructs a Bluesky CDN URL for an image blob. 387 * @param did - The DID of the user who owns the blob (defaults to current user) 388 * @param blob - The blob reference object 389 * @returns The CDN URL for the image in webp format 390 */ 391export function getCDNImageBlobUrl({ 392 did, 393 blob 394}: { 395 did?: string; 396 blob: { 397 $type: 'blob'; 398 ref: { 399 $link: string; 400 }; 401 }; 402}) { 403 if (!blob || !did) return; 404 did ??= user.did; 405 406 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 407} 408 409/** 410 * Searches for actors with typeahead/autocomplete functionality. 411 * @param q - The search query 412 * @param limit - Maximum number of results (default 10) 413 * @param host - The API host to use (defaults to public Bluesky API) 414 * @returns An object containing matching actors and the original query 415 */ 416export async function searchActorsTypeahead( 417 q: string, 418 limit: number = 10, 419 host?: string 420): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> { 421 host ??= 'https://public.api.bsky.app'; 422 423 const client = new Client({ 424 handler: simpleFetchHandler({ service: host }) 425 }); 426 427 const response = await client.get('app.bsky.actor.searchActorsTypeahead', { 428 params: { 429 q, 430 limit 431 } 432 }); 433 434 if (!response.ok) return { actors: [], q }; 435 436 return { actors: response.data.actors, q }; 437} 438 439/** 440 * Return a TID based on current time 441 * 442 * @returns TID for current time 443 */ 444export function createTID() { 445 return TID.now(); 446} 447 448export async function getAuthorFeed(data?: { 449 did?: Did; 450 client?: Client; 451 filter?: string; 452 limit?: number; 453}) { 454 data ??= {}; 455 data.did ??= user.did; 456 457 if (!data.did) throw new Error('Error getting detailed profile: no did'); 458 459 data.client ??= new Client({ 460 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 461 }); 462 463 const response = await data.client.get('app.bsky.feed.getAuthorFeed', { 464 params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 } 465 }); 466 467 if (!response.ok) return; 468 469 return response.data; 470} 471 472/** 473 * Fetches posts by their AT URIs. 474 * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 475 * @param client - The client to use (defaults to public Bluesky API) 476 * @returns Array of posts or undefined on failure 477 */ 478export async function getPosts(data: { uris: string[]; client?: Client }) { 479 data.client ??= new Client({ 480 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 481 }); 482 483 const response = await data.client.get('app.bsky.feed.getPosts', { 484 params: { uris: data.uris as ResourceUri[] } 485 }); 486 487 if (!response.ok) return; 488 489 return response.data.posts; 490} 491 492export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier { 493 if (profile.handle && profile.handle !== 'handle.invalid') { 494 return profile.handle; 495 } else { 496 return profile.did; 497 } 498} 499 500/** 501 * Fetches a post's thread including replies. 502 * @param uri - The AT URI of the post 503 * @param depth - How many levels of replies to fetch (default 1) 504 * @param client - The client to use (defaults to public Bluesky API) 505 * @returns The thread data or undefined on failure 506 */ 507export async function getPostThread({ 508 uri, 509 depth = 1, 510 client 511}: { 512 uri: string; 513 depth?: number; 514 client?: Client; 515}) { 516 client ??= new Client({ 517 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 518 }); 519 520 const response = await client.get('app.bsky.feed.getPostThread', { 521 params: { uri: uri as ResourceUri, depth } 522 }); 523 524 if (!response.ok) return; 525 526 return response.data.thread; 527} 528 529/** 530 * Creates a Bluesky post on the authenticated user's account. 531 * @param text - The post text 532 * @param facets - Optional rich text facets (links, mentions, etc.) 533 * @returns The response containing the post's URI and CID 534 * @throws If the user is not logged in 535 */ 536export async function createPost({ 537 text, 538 facets 539}: { 540 text: string; 541 facets?: Array<{ 542 index: { byteStart: number; byteEnd: number }; 543 features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>; 544 }>; 545}) { 546 if (!user.client || !user.did) throw new Error('No client or did'); 547 548 const record: Record<string, unknown> = { 549 $type: 'app.bsky.feed.post', 550 text, 551 createdAt: new Date().toISOString() 552 }; 553 554 if (facets) { 555 record.facets = facets; 556 } 557 558 const response = await user.client.post('com.atproto.repo.createRecord', { 559 input: { 560 collection: 'app.bsky.feed.post', 561 repo: user.did, 562 record 563 } 564 }); 565 566 return response; 567}