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 if (!record.ok)
251 throw new Error((record.data as { message?: string })?.message ?? 'Record not found');
252
253 return JSON.parse(JSON.stringify(record.data));
254}
255
256/**
257 * Creates or updates a record in the current user's repository.
258 * Only accepts collections that are configured in permissions.
259 * @param collection - The collection to write to (must be in permissions.collections)
260 * @param rkey - The record key (defaults to "self")
261 * @param record - The record data to write
262 * @returns The response from the PDS
263 * @throws If the user is not logged in
264 */
265export async function putRecord({
266 collection,
267 rkey = 'self',
268 record
269}: {
270 collection: AllowedCollection;
271 rkey?: string;
272 record: Record<string, unknown>;
273}) {
274 if (!user.client || !user.did) throw new Error('No rpc or did');
275
276 const response = await user.client.post('com.atproto.repo.putRecord', {
277 input: {
278 collection,
279 repo: user.did,
280 rkey,
281 record: {
282 ...record
283 }
284 }
285 });
286
287 return response;
288}
289
290/**
291 * Deletes a record from the current user's repository.
292 * Only accepts collections that are configured in permissions.
293 * @param collection - The collection the record belongs to (must be in permissions.collections)
294 * @param rkey - The record key (defaults to "self")
295 * @returns True if the deletion was successful
296 * @throws If the user is not logged in
297 */
298export async function deleteRecord({
299 collection,
300 rkey = 'self'
301}: {
302 collection: AllowedCollection;
303 rkey: string;
304}) {
305 if (!user.client || !user.did) throw new Error('No profile or rpc or did');
306
307 const response = await user.client.post('com.atproto.repo.deleteRecord', {
308 input: {
309 collection,
310 repo: user.did,
311 rkey
312 }
313 });
314
315 return response.ok;
316}
317
318/**
319 * Uploads a blob to the current user's PDS.
320 * @param blob - The blob data to upload
321 * @returns The blob metadata including ref, mimeType, and size, or undefined on failure
322 * @throws If the user is not logged in
323 */
324export async function uploadBlob({ blob }: { blob: Blob }) {
325 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in");
326
327 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', {
328 params: {
329 repo: user.did
330 },
331 input: blob
332 });
333
334 if (!blobResponse?.ok) return;
335
336 const blobInfo = blobResponse?.data.blob as {
337 $type: 'blob';
338 ref: {
339 $link: string;
340 };
341 mimeType: string;
342 size: number;
343 };
344
345 return blobInfo;
346}
347
348/**
349 * Gets metadata about a repository.
350 * @param client - The client to use
351 * @param did - The DID of the repository (defaults to current user)
352 * @returns Repository metadata or undefined on failure
353 */
354export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
355 did ??= user.did;
356 if (!did) {
357 throw new Error('Error describeRepo: No did');
358 }
359 client ??= await getClient({ did });
360
361 const repo = await client.get('com.atproto.repo.describeRepo', {
362 params: {
363 repo: did
364 }
365 });
366 if (!repo.ok) return;
367
368 return repo.data;
369}
370
371/**
372 * Constructs a URL to fetch a blob directly from a user's PDS.
373 * @param did - The DID of the user who owns the blob
374 * @param blob - The blob reference object
375 * @returns The URL to fetch the blob
376 */
377export async function getBlobURL({
378 did,
379 blob
380}: {
381 did: Did;
382 blob: {
383 $type: 'blob';
384 ref: {
385 $link: string;
386 };
387 };
388}) {
389 const pds = await getPDS(did);
390 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`;
391}
392
393/**
394 * Constructs a Bluesky CDN URL for an image blob.
395 * @param did - The DID of the user who owns the blob (defaults to current user)
396 * @param blob - The blob reference object
397 * @returns The CDN URL for the image in webp format
398 */
399export function getCDNImageBlobUrl({
400 did,
401 blob,
402 type = 'webp'
403}: {
404 did?: string;
405 blob: {
406 $type: 'blob';
407 ref: {
408 $link: string;
409 };
410 };
411 type?: 'webp' | 'jpeg';
412}) {
413 if (!blob || !did) return;
414 did ??= user.did;
415
416 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${type}`;
417}
418
419/**
420 * Searches for actors with typeahead/autocomplete functionality.
421 * @param q - The search query
422 * @param limit - Maximum number of results (default 10)
423 * @param host - The API host to use (defaults to public Bluesky API)
424 * @returns An object containing matching actors and the original query
425 */
426export async function searchActorsTypeahead(
427 q: string,
428 limit: number = 10,
429 host?: string
430): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> {
431 host ??= 'https://public.api.bsky.app';
432
433 const client = new Client({
434 handler: simpleFetchHandler({ service: host })
435 });
436
437 const response = await client.get('app.bsky.actor.searchActorsTypeahead', {
438 params: {
439 q,
440 limit
441 }
442 });
443
444 if (!response.ok) return { actors: [], q };
445
446 return { actors: response.data.actors, q };
447}
448
449/**
450 * Return a TID based on current time
451 *
452 * @returns TID for current time
453 */
454export function createTID() {
455 return TID.now();
456}
457
458export async function getAuthorFeed(data?: {
459 did?: Did;
460 client?: Client;
461 filter?: string;
462 limit?: number;
463 cursor?: string;
464}) {
465 data ??= {};
466 data.did ??= user.did;
467
468 if (!data.did) throw new Error('Error getting detailed profile: no did');
469
470 data.client ??= new Client({
471 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
472 });
473
474 const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
475 params: {
476 actor: data.did,
477 filter: data.filter ?? 'posts_with_media',
478 limit: data.limit || 100,
479 cursor: data.cursor
480 }
481 });
482
483 if (!response.ok) return;
484
485 return response.data;
486}
487
488/**
489 * Fetches posts by their AT URIs.
490 * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123")
491 * @param client - The client to use (defaults to public Bluesky API)
492 * @returns Array of posts or undefined on failure
493 */
494export async function getPosts(data: { uris: string[]; client?: Client }) {
495 data.client ??= new Client({
496 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
497 });
498
499 const response = await data.client.get('app.bsky.feed.getPosts', {
500 params: { uris: data.uris as ResourceUri[] }
501 });
502
503 if (!response.ok) return;
504
505 return response.data.posts;
506}
507
508export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier {
509 if (profile.handle && profile.handle !== 'handle.invalid') {
510 return profile.handle;
511 } else {
512 return profile.did;
513 }
514}
515
516/**
517 * Fetches a post's thread including replies.
518 * @param uri - The AT URI of the post
519 * @param depth - How many levels of replies to fetch (default 1)
520 * @param client - The client to use (defaults to public Bluesky API)
521 * @returns The thread data or undefined on failure
522 */
523export async function getPostThread({
524 uri,
525 depth = 1,
526 client
527}: {
528 uri: string;
529 depth?: number;
530 client?: Client;
531}) {
532 client ??= new Client({
533 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
534 });
535
536 const response = await client.get('app.bsky.feed.getPostThread', {
537 params: { uri: uri as ResourceUri, depth }
538 });
539
540 if (!response.ok) return;
541
542 return response.data.thread;
543}
544
545/**
546 * Creates a Bluesky post on the authenticated user's account.
547 * @param text - The post text
548 * @param facets - Optional rich text facets (links, mentions, etc.)
549 * @returns The response containing the post's URI and CID
550 * @throws If the user is not logged in
551 */
552export async function createPost({
553 text,
554 facets
555}: {
556 text: string;
557 facets?: Array<{
558 index: { byteStart: number; byteEnd: number };
559 features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>;
560 }>;
561}) {
562 if (!user.client || !user.did) throw new Error('No client or did');
563
564 const record: Record<string, unknown> = {
565 $type: 'app.bsky.feed.post',
566 text,
567 createdAt: new Date().toISOString()
568 };
569
570 if (facets) {
571 record.facets = facets;
572 }
573
574 const response = await user.client.post('com.atproto.repo.createRecord', {
575 input: {
576 collection: 'app.bsky.feed.post',
577 repo: user.did,
578 record
579 }
580 });
581
582 return response;
583}