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}: {
400 did?: string;
401 blob: {
402 $type: 'blob';
403 ref: {
404 $link: string;
405 };
406 };
407}) {
408 if (!blob || !did) return;
409 did ??= user.did;
410
411 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
412}
413
414/**
415 * Searches for actors with typeahead/autocomplete functionality.
416 * @param q - The search query
417 * @param limit - Maximum number of results (default 10)
418 * @param host - The API host to use (defaults to public Bluesky API)
419 * @returns An object containing matching actors and the original query
420 */
421export async function searchActorsTypeahead(
422 q: string,
423 limit: number = 10,
424 host?: string
425): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> {
426 host ??= 'https://public.api.bsky.app';
427
428 const client = new Client({
429 handler: simpleFetchHandler({ service: host })
430 });
431
432 const response = await client.get('app.bsky.actor.searchActorsTypeahead', {
433 params: {
434 q,
435 limit
436 }
437 });
438
439 if (!response.ok) return { actors: [], q };
440
441 return { actors: response.data.actors, q };
442}
443
444/**
445 * Return a TID based on current time
446 *
447 * @returns TID for current time
448 */
449export function createTID() {
450 return TID.now();
451}
452
453export async function getAuthorFeed(data?: {
454 did?: Did;
455 client?: Client;
456 filter?: string;
457 limit?: number;
458 cursor?: string;
459}) {
460 data ??= {};
461 data.did ??= user.did;
462
463 if (!data.did) throw new Error('Error getting detailed profile: no did');
464
465 data.client ??= new Client({
466 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
467 });
468
469 const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
470 params: {
471 actor: data.did,
472 filter: data.filter ?? 'posts_with_media',
473 limit: data.limit || 100,
474 cursor: data.cursor
475 }
476 });
477
478 if (!response.ok) return;
479
480 return response.data;
481}
482
483/**
484 * Fetches posts by their AT URIs.
485 * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123")
486 * @param client - The client to use (defaults to public Bluesky API)
487 * @returns Array of posts or undefined on failure
488 */
489export async function getPosts(data: { uris: string[]; client?: Client }) {
490 data.client ??= new Client({
491 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
492 });
493
494 const response = await data.client.get('app.bsky.feed.getPosts', {
495 params: { uris: data.uris as ResourceUri[] }
496 });
497
498 if (!response.ok) return;
499
500 return response.data.posts;
501}
502
503export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier {
504 if (profile.handle && profile.handle !== 'handle.invalid') {
505 return profile.handle;
506 } else {
507 return profile.did;
508 }
509}
510
511/**
512 * Fetches a post's thread including replies.
513 * @param uri - The AT URI of the post
514 * @param depth - How many levels of replies to fetch (default 1)
515 * @param client - The client to use (defaults to public Bluesky API)
516 * @returns The thread data or undefined on failure
517 */
518export async function getPostThread({
519 uri,
520 depth = 1,
521 client
522}: {
523 uri: string;
524 depth?: number;
525 client?: Client;
526}) {
527 client ??= new Client({
528 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
529 });
530
531 const response = await client.get('app.bsky.feed.getPostThread', {
532 params: { uri: uri as ResourceUri, depth }
533 });
534
535 if (!response.ok) return;
536
537 return response.data.thread;
538}
539
540/**
541 * Creates a Bluesky post on the authenticated user's account.
542 * @param text - The post text
543 * @param facets - Optional rich text facets (links, mentions, etc.)
544 * @returns The response containing the post's URI and CID
545 * @throws If the user is not logged in
546 */
547export async function createPost({
548 text,
549 facets
550}: {
551 text: string;
552 facets?: Array<{
553 index: { byteStart: number; byteEnd: number };
554 features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>;
555 }>;
556}) {
557 if (!user.client || !user.did) throw new Error('No client or did');
558
559 const record: Record<string, unknown> = {
560 $type: 'app.bsky.feed.post',
561 text,
562 createdAt: new Date().toISOString()
563 };
564
565 if (facets) {
566 record.facets = facets;
567 }
568
569 const response = await user.client.post('com.atproto.repo.createRecord', {
570 input: {
571 collection: 'app.bsky.feed.post',
572 repo: user.did,
573 record
574 }
575 });
576
577 return response;
578}