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 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}