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