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