Something went wrong. Try again.
Hopefully feature-complete Android Bluesky client written in Expo
atproto bluesky
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509import type * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs";import type * as ComAtprotoRepoApplyWrites from "@atcute/atproto/types/repo/applyWrites";import type * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef";import type * as AppBskyEmbedExternal from "@atcute/bluesky/types/app/embed/external";import type * as AppBskyEmbedImages from "@atcute/bluesky/types/app/embed/images";import type * as AppBskyEmbedRecord from "@atcute/bluesky/types/app/embed/record";import type * as AppBskyEmbedVideo from "@atcute/bluesky/types/app/embed/video";import type * as AppBskyFeedDefs from "@atcute/bluesky/types/app/feed/defs";import type * as AppBskyFeedPost from "@atcute/bluesky/types/app/feed/post";import type * as AppBskyFeedThreadgate from "@atcute/bluesky/types/app/feed/threadgate";import { ClientResponseError, ok, type Client } from "@atcute/client";import type { $type, Blob as AtBlob, CanonicalResourceUri, ResourceUri, Did, GenericUri } from "@atcute/lexicons";import type * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet";import * as TID from "@atcute/tid";import { encode } from "@atcute/cbor";import { create, toString } from "@atcute/cid";
export async function serializeRecordCid(record: { $type: string }): Promise<string> { const bytes = encode(record);
const cid = await create(0x71, bytes); const serialized = toString(cid);
return serialized;}
let lastTimestamp: number = 0;
/** * Return the current time, make sure that each call never returns same value * so that posts sent at the same time from different accounts don't end up * colliding with each other potentially causing them to not be shown. */function getNow(threadSize: number): number { let timestamp = Math.max(Date.now(), lastTimestamp); lastTimestamp = timestamp + 2 + threadSize;
return timestamp;}
export interface MediaAspectRatio { width: number; height: number;}
export interface PostExternalEmbed { type: "external"; uri: GenericUri; title: string; description?: string; thumbnail?: Blob | AtBlob; labels?: string[];}
export interface ComposedImage { blob: Blob | AtBlob; alt?: string; aspectRatio?: MediaAspectRatio;}
export interface PostImageEmbed { type: "image"; images: ComposedImage[]; labels?: string[];}
export interface PostVideoEmbed { type: "video"; blob: Blob | AtBlob; alt?: string; aspectRatio?: MediaAspectRatio; labels?: string[];}
export type PostMediaEmbed = PostExternalEmbed | PostImageEmbed | PostVideoEmbed;
export interface PostFeedEmbed { type: "feed"; uri: ResourceUri; cid?: string;}
export interface PostListEmbed { type: "list"; uri: ResourceUri; cid?: string;}
export interface PostQuoteEmbed { type: "quote"; uri: ResourceUri; cid?: string;}
export interface PostStarterpackEmbed { type: "starterpack"; uri: ResourceUri; cid?: string;}
export type PostRecordEmbed = PostFeedEmbed | PostListEmbed | PostQuoteEmbed | PostStarterpackEmbed;
/** Embed in a post, can contain media and links to other records */export interface PostEmbed { media?: PostMediaEmbed; record?: PostRecordEmbed;}
export interface ComposedPost { languages?: string[]; content: { text: string; facets?: AppBskyRichtextFacet.Main[]; }; embed?: PostEmbed;}
export interface ComposedThreadgate { follows?: boolean; mentions?: boolean; listUris?: ResourceUri[];}
export interface ComposedThread { client: Client; signal?: AbortSignal; author: Did; createdAt?: string | number | Date; reply?: string | AppBskyFeedDefs.PostView | AppBskyEmbedRecord.ViewRecord; gate?: ComposedThreadgate; languages?: string[]; posts: ComposedPost[];}
/** * Create post records and publish them * @param client An authenticated Bluesky RPC client * @param thread Composed thread * @returns An array of post records that were published */export async function publishThread( client: Client, thread: Omit<ComposedThread, "client">): Promise<$type.enforce<ComAtprotoRepoApplyWrites.Create>[]> { const records = await createThread({ ...thread, client: client });
await ok( client.post("com.atproto.repo.applyWrites", { signal: thread.signal, input: { repo: thread.author, writes: records, }, }) );
return records;}
async function createThread(thread: ComposedThread): Promise<$type.enforce<ComAtprotoRepoApplyWrites.Create>[]> { const client = thread.client; const signal = thread.signal;
const did = thread.author; const posts = thread.posts; const threadgate = thread.gate; const languages = thread.languages;
const writes: $type.enforce<ComAtprotoRepoApplyWrites.Create>[] = [];
const now = thread.createdAt !== undefined ? new Date(thread.createdAt) : new Date(getNow(posts.length)); assert(!Number.isNaN(now.getTime()), `provided createdAt value is invalid`);
let reply: AppBskyFeedPost.ReplyRef | undefined; let rkey: string | undefined;
if (thread.reply) { let post = thread.reply;
if (typeof post === "string") { // AT-URI being passed post = await getPost(post as ResourceUri); }
let root: ComAtprotoRepoStrongRef.Main | undefined; let ref: ComAtprotoRepoStrongRef.Main;
if ("record" in post) { // AppBskyFeedDefs.PostView being passed
root = (post.record as AppBskyFeedPost.Main).reply?.root; ref = { uri: post.uri, cid: post.cid }; } else if ("value" in post) { // AppBskyEmbedRecord.ViewRecord being passed
root = (post.value as AppBskyFeedPost.Main).reply?.root; ref = { uri: post.uri, cid: post.cid }; } else { assert(false, `Unexpected end of code`); }
reply = { root: root ? { uri: root.uri, cid: root.cid } : ref, parent: ref, }; }
assert(!reply || !threadgate, `threadgate and reply are mutually exclusive`);
for (let idx = 0, len = posts.length; idx < len; idx++) { // Get the record key for this post rkey = TID.createRaw(now.getTime(), Math.floor(Math.random() * 1024));
const post = posts[idx]; const uri: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
// Resolve embeds let embed: AppBskyFeedPost.Main["embed"]; if (post.embed !== undefined) { embed = await resolveEmbed(post.embed); }
// Get the self-labels const labels = getEmbedLabels(post.embed); let selfLabels: $type.enforce<ComAtprotoLabelDefs.SelfLabels> | undefined;
if (labels?.length) { selfLabels = { $type: "com.atproto.label.defs#selfLabels", values: labels.map((val) => ({ val })), }; }
// Now form the record const content = post.content;
const record: AppBskyFeedPost.Main = { $type: "app.bsky.feed.post", createdAt: now.toISOString(), text: content.text, facets: content.facets, reply: reply, embed: embed, langs: post.languages ?? languages, labels: selfLabels, tags: ["DuskskyApp"], };
writes.push({ $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.feed.post", rkey: rkey, value: record, });
// If this is the first post, and we have a threadgate set, create one now. if (idx === 0 && threadgate) { const threadgateRecord: AppBskyFeedThreadgate.Main = { $type: "app.bsky.feed.threadgate", createdAt: now.toISOString(), post: uri, allow: resolveThreadgate(threadgate), };
writes.push({ $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.feed.threadgate", rkey: rkey, value: threadgateRecord, }); }
if (idx !== len - 1) { // Retrieve the next reply reference const serialized = await serializeRecordCid(record);
const ref: ComAtprotoRepoStrongRef.Main = { cid: serialized, uri: uri, };
reply = { root: reply ? reply.root : ref, parent: ref, };
// Posts are not guaranteed to be shown in the correct order if they are // all posted with the same timestamp. now.setMilliseconds(now.getMilliseconds() + 1); } }
return writes;
async function resolveEmbed(embed: PostEmbed): Promise<AppBskyFeedPost.Main["embed"] | undefined> { const { media, record } = embed;
if (media && record) { return { $type: "app.bsky.embed.recordWithMedia", media: await resolveMediaEmbed(media), record: await resolveRecordEmbed(record), }; } else if (media) { return resolveMediaEmbed(media); } else if (record) { return resolveRecordEmbed(record); }
return;
async function resolveMediaEmbed( embed: PostMediaEmbed ): Promise<$type.enforce<AppBskyEmbedExternal.Main | AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main>> { const type = embed.type;
if (type === "external") { const rawThumb = embed.thumbnail; let thumb: AtBlob<any> | undefined;
if (rawThumb !== undefined) { if (rawThumb instanceof Blob) { thumb = await uploadBlob(rawThumb); } else { thumb = rawThumb; } }
return { $type: "app.bsky.embed.external", external: { uri: embed.uri, title: embed.title, description: embed.description ?? "", thumb: thumb, }, }; }
if (type === "image") { const images: AppBskyEmbedImages.Image[] = [];
for (const image of embed.images) { const aspectRatio = image.aspectRatio; const rawBlob = image.blob; let blob: AtBlob<any>;
if (rawBlob instanceof Blob) { blob = await uploadBlob(rawBlob); } else { blob = rawBlob; }
images.push({ image: blob, alt: image.alt ?? "", aspectRatio: aspectRatio ? { width: aspectRatio.width, height: aspectRatio.height } : undefined, }); }
return { $type: "app.bsky.embed.images", images: images, }; }
if (type === "video") { const aspectRatio = embed.aspectRatio; const rawBlob = embed.blob; let blob: AtBlob<any> | undefined;
if (rawBlob instanceof Blob) { blob = await uploadBlob(rawBlob); } else { blob = rawBlob; }
return { $type: "app.bsky.embed.video", video: blob, alt: embed.alt ?? "", aspectRatio: aspectRatio ? { width: aspectRatio.width, height: aspectRatio.height } : undefined, }; }
assert(false, `Unexpected end of code`); }
async function resolveRecordEmbed(embed: PostRecordEmbed): Promise<$type.enforce<AppBskyEmbedRecord.Main>> { const uri = embed.uri; let cid = embed.cid;
if (cid === undefined) { const type = embed.type;
if (type === "quote") { const post = await getPost(uri);
cid = post.cid; } else if (type === "feed") { const data = await ok( client.get("app.bsky.feed.getFeedGenerator", { signal: signal, params: { feed: uri }, }) );
cid = data.view.cid; } else if (type === "list") { const data = await ok( client.get("app.bsky.graph.getList", { signal: signal, params: { list: uri, limit: 1 }, }) );
cid = data.list.cid; } else if (type === "starterpack") { const data = await ok( client.get("app.bsky.graph.getStarterPack", { signal: signal, params: { starterPack: uri }, }) );
cid = data.starterPack.cid; } else { assert(false, `Unexpected end of code`); } }
return { $type: "app.bsky.embed.record", record: { uri: uri, cid: cid, }, }; } }
async function uploadBlob(blob: Blob): Promise<AtBlob> { const data = await ok( client.post("com.atproto.repo.uploadBlob", { signal: signal, input: blob, }) );
return data.blob; }
async function getPost(uri: ResourceUri): Promise<AppBskyFeedDefs.PostView> { const data = await ok( client.get("app.bsky.feed.getPosts", { signal: signal, params: { uris: [uri], }, }) );
const post = data.posts[0]; if (!post) { throw new ClientResponseError({ status: 400, data: { error: "NotFound", message: `Post not found: ${uri}` }, }); }
return post; }}
function resolveThreadgate(gate: ComposedThreadgate): AppBskyFeedThreadgate.Main["allow"] { const rules: AppBskyFeedThreadgate.Main["allow"] = [];
if (gate.follows) { rules.push({ $type: "app.bsky.feed.threadgate#followingRule" }); } if (gate.mentions) { rules.push({ $type: "app.bsky.feed.threadgate#mentionRule" }); }
for (const listUri of gate.listUris ?? []) { rules.push({ $type: "app.bsky.feed.threadgate#listRule", list: listUri }); }
return rules;}
function getEmbedLabels(embed: PostEmbed | undefined): string[] | undefined { const media = embed?.media;
if (media !== undefined) { const type = media.type;
if (type === "image" || type === "external") { return media.labels; } }}
function assert(condition: boolean, message: string): asserts condition { if (!condition) { throw new Error(message); }}