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