diff --git a/src/lexicons/app/bsky/actor/profile.json b/lexicons/app/bsky/actor/profile.json similarity index 100% rename from src/lexicons/app/bsky/actor/profile.json rename to lexicons/app/bsky/actor/profile.json diff --git a/src/lexicons/app/bsky/embed/external.json b/lexicons/app/bsky/embed/external.json similarity index 100% rename from src/lexicons/app/bsky/embed/external.json rename to lexicons/app/bsky/embed/external.json diff --git a/src/lexicons/app/bsky/embed/images.json b/lexicons/app/bsky/embed/images.json similarity index 94% rename from src/lexicons/app/bsky/embed/images.json rename to lexicons/app/bsky/embed/images.json index c6b6fa2..e8599ae 100644 --- a/src/lexicons/app/bsky/embed/images.json +++ b/lexicons/app/bsky/embed/images.json @@ -20,8 +20,9 @@ "properties": { "image": { "type": "blob", + "description": "The raw image file. May be up to 2 MB, formerly limited to 1 MB.", "accept": ["image/*"], - "maxSize": 1000000 + "maxSize": 2000000 }, "alt": { "type": "string", diff --git a/src/lexicons/app/bsky/embed/record.json b/lexicons/app/bsky/embed/record.json similarity index 100% rename from src/lexicons/app/bsky/embed/record.json rename to lexicons/app/bsky/embed/record.json diff --git a/src/lexicons/app/bsky/feed/post.json b/lexicons/app/bsky/feed/post.json similarity index 100% rename from src/lexicons/app/bsky/feed/post.json rename to lexicons/app/bsky/feed/post.json diff --git a/src/lexicons/app/bsky/richtext/facet.json b/lexicons/app/bsky/richtext/facet.json similarity index 100% rename from src/lexicons/app/bsky/richtext/facet.json rename to lexicons/app/bsky/richtext/facet.json diff --git a/lexicons/com/atproto/label/defs.json b/lexicons/com/atproto/label/defs.json new file mode 100644 index 0000000..26b6a3f --- /dev/null +++ b/lexicons/com/atproto/label/defs.json @@ -0,0 +1,153 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "label": { + "type": "object", + "description": "Metadata tag on an atproto resource (eg, repo or record).", + "required": ["src", "uri", "val", "cts"], + "properties": { + "ver": { + "type": "integer", + "description": "The AT Protocol version of the label object." + }, + "src": { + "type": "string", + "format": "did", + "description": "DID of the actor who created this label." + }, + "uri": { + "type": "string", + "format": "uri", + "description": "AT URI of the record, repository (account), or other resource that this label applies to." + }, + "cid": { + "type": "string", + "format": "cid", + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." + }, + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + }, + "neg": { + "type": "boolean", + "description": "If true, this is a negation label, overwriting a previous label." + }, + "cts": { + "type": "string", + "format": "datetime", + "description": "Timestamp when this label was created." + }, + "exp": { + "type": "string", + "format": "datetime", + "description": "Timestamp at which this label expires (no longer applies)." + }, + "sig": { + "type": "bytes", + "description": "Signature of dag-cbor encoded label." + } + } + }, + "selfLabels": { + "type": "object", + "description": "Metadata tags on an atproto record, published by the author within the record.", + "required": ["values"], + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + } + }, + "selfLabel": { + "type": "object", + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.", + "required": ["val"], + "properties": { + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + } + } + }, + "labelValueDefinition": { + "type": "object", + "description": "Declares a label value and its expected interpretations and behaviors.", + "required": ["identifier", "severity", "blurs", "locales"], + "properties": { + "identifier": { + "type": "string", + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", + "maxLength": 100, + "maxGraphemes": 100 + }, + "severity": { + "type": "string", + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", + "knownValues": ["inform", "alert", "none"] + }, + "blurs": { + "type": "string", + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", + "knownValues": ["content", "media", "none"] + }, + "defaultSetting": { + "type": "string", + "description": "The default setting for this label.", + "knownValues": ["ignore", "warn", "hide"], + "default": "warn" + }, + "adultOnly": { + "type": "boolean", + "description": "Does the user need to have adult content enabled in order to configure this label?" + }, + "locales": { + "type": "array", + "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } + } + } + }, + "labelValueDefinitionStrings": { + "type": "object", + "description": "Strings which describe the label in the UI, localized into a specific language.", + "required": ["lang", "name", "description"], + "properties": { + "lang": { + "type": "string", + "description": "The code of the language these strings are written in.", + "format": "language" + }, + "name": { + "type": "string", + "description": "A short human-readable name for the label.", + "maxGraphemes": 64, + "maxLength": 640 + }, + "description": { + "type": "string", + "description": "A longer description of what the label means and why it might be applied.", + "maxGraphemes": 10000, + "maxLength": 100000 + } + } + }, + "labelValue": { + "type": "string", + "knownValues": [ + "!hide", + "!warn", + "!no-unauthenticated", + "porn", + "sexual", + "nudity", + "graphic-media", + "bot" + ] + } + } +} diff --git a/lexicons/com/atproto/repo/strongRef.json b/lexicons/com/atproto/repo/strongRef.json new file mode 100644 index 0000000..cb79625 --- /dev/null +++ b/lexicons/com/atproto/repo/strongRef.json @@ -0,0 +1,15 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.strongRef", + "description": "A URI with a content-hash fingerprint.", + "defs": { + "main": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" } + } + } + } +} diff --git a/src/app/post.gleam b/src/app/post.gleam deleted file mode 100644 index 35b823c..0000000 --- a/src/app/post.gleam +++ /dev/null @@ -1,157 +0,0 @@ -import gleam/dynamic/decode.{field, int, list, string, success} -import gleam/option.{type Option, None} - -pub type StrongRef { - StrongRef(uri: String, cid: String) -} - -pub fn decode_strong_ref() -> decode.Decoder(StrongRef) { - use uri <- field("uri", string) - use cid <- field("cid", string) - success(StrongRef(uri:, cid:)) -} - -pub type Label { - Label(val: String) -} - -pub fn decode_label() -> decode.Decoder(Label) { - use val <- field("val", string) - success(Label(val:)) -} - -pub type ByteSlice { - ByteSlice(byte_start: Int, byte_end: Int) -} - -pub fn decode_byte_slice() -> decode.Decoder(ByteSlice) { - use byte_start <- field("byteStart", int) - use byte_end <- field("byteEnd", int) - success(ByteSlice(byte_start:, byte_end:)) -} - -pub type FacetFeature { - Mention(did: String) - Link(uri: String) - Tag(tag: String) -} - -fn decode_facet_feature() -> decode.Decoder(FacetFeature) { - use type_ <- field("$type", string) - case type_ { - "app.bsky.richtext.facet#mention" -> { - use did <- field("did", string) - success(Mention(did:)) - } - "app.bsky.richtext.facet#link" -> { - use uri <- field("uri", string) - success(Link(uri:)) - } - "app.bsky.richtext.facet#tag" -> { - use tag <- field("tag", string) - success(Tag(tag:)) - } - _ -> decode.failure(Mention(""), expected: "FacetFeature") - } -} - -pub type Facet { - Facet(index: ByteSlice, features: List(FacetFeature)) -} - -pub fn decode_facet() -> decode.Decoder(Facet) { - use index <- field("index", decode_byte_slice()) - use features <- field("features", list(of: decode_facet_feature())) - success(Facet(index:, features:)) -} - -pub type AspectRatio { - AspectRatio(width: Int, height: Int) -} - -pub fn decode_aspect_ratio() -> decode.Decoder(AspectRatio) { - use width <- field("width", int) - use height <- field("height", int) - success(AspectRatio(width:, height:)) -} - -pub type Image { - Image( - alt: String, - thumb: Option(String), - fullsize: Option(String), - aspect_ratio: Option(AspectRatio), - ) -} - -pub fn decode_image() -> decode.Decoder(Image) { - use alt <- field("alt", string) - success(Image(alt:, thumb: None, fullsize: None, aspect_ratio: None)) -} - -pub type External { - External( - uri: String, - title: String, - description: String, - thumb: Option(String), - ) -} - -pub fn decode_external() -> decode.Decoder(External) { - use uri <- field("uri", string) - use title <- field("title", string) - use description <- field("description", string) - success(External(uri:, title:, description:, thumb: None)) -} - -pub type Embed { - Images(images: List(Image)) - ExternalLink(external: External) - Record(StrongRef) -} - -pub fn decode_embed() -> decode.Decoder(Embed) { - use type_ <- field("$type", string) - case type_ { - "app.bsky.embed.images" -> { - use images <- field("images", list(of: decode_image())) - success(Images(images:)) - } - "app.bsky.embed.external" -> { - use external <- field("external", decode_external()) - success(ExternalLink(external:)) - } - "app.bsky.embed.record" -> { - use record <- field("record", decode_strong_ref()) - success(Record(record)) - } - _ -> decode.failure(Images(images: []), expected: "Embed type") - } -} - -pub type Post { - Post( - text: String, - facets: List(Facet), - embed: Option(Embed), - langs: List(String), - labels: List(Label), - tags: List(String), - created_at: String, - ) -} - -pub fn decode_post() -> decode.Decoder(Post) { - use text <- field("text", string) - use created_at <- field("createdAt", string) - success(Post( - text:, - facets: [], - embed: None, - langs: [], - labels: [], - tags: [], - created_at:, - )) -} diff --git a/src/app/profile.gleam b/src/app/profile.gleam deleted file mode 100644 index 4b88ff0..0000000 --- a/src/app/profile.gleam +++ /dev/null @@ -1,60 +0,0 @@ -import gleam/dynamic/decode.{at, field, map, optional, string, success} -import gleam/option.{type Option} - -pub type BlobRef { - BlobRef(link: String) -} - -pub fn decode_blob_ref() -> decode.Decoder(BlobRef) { - at(["ref", "$link"], string) - |> map(BlobRef) -} - -pub fn blob_ref_to_url(pds_host: String, did: String, blob: BlobRef) -> String { - pds_host - <> "/xrpc/com.atproto.sync.getBlob?did=" - <> did - <> "&cid=" - <> blob.link -} - -pub type StrongRef { - StrongRef(uri: String, cid: String) -} - -pub fn decode_strong_ref() -> decode.Decoder(StrongRef) { - use uri <- field("uri", string) - use cid <- field("cid", string) - success(StrongRef(uri:, cid:)) -} - -pub type Label { - Label(val: String) -} - -pub fn decode_label() -> decode.Decoder(Label) { - use val <- field("val", string) - success(Label(val:)) -} - -pub type Profile { - Profile(display_name: Option(String), avatar: Option(BlobRef)) -} - -pub type MiniDoc { - MiniDoc(did: String, handle: String, pds: String, signing_key: String) -} - -pub fn decode_mini_doc() -> decode.Decoder(MiniDoc) { - use did <- field("did", string) - use handle <- field("handle", string) - use pds <- field("pds", string) - use signing_key <- field("signing_key", string) - success(MiniDoc(did:, handle:, pds:, signing_key:)) -} - -pub fn decode_profile() -> decode.Decoder(Profile) { - use display_name <- field("displayName", optional(string)) - use avatar <- field("avatar", optional(decode_blob_ref())) - success(Profile(display_name:, avatar:)) -} diff --git a/src/bsky/decoders.gleam b/src/bsky/decoders.gleam new file mode 100644 index 0000000..99e515e --- /dev/null +++ b/src/bsky/decoders.gleam @@ -0,0 +1,470 @@ +import gleam/dynamic as dyn +import gleam/dynamic/decode.{ + at, bool, dynamic as decode_dyn, failure, field, int, list, map, one_of, + optional_field, string, success, +} +import gleam/option.{type Option, None, Some} + +// === Simple decoders === + +pub type StrongRefJson { + StrongRefJson(uri: String, cid: String) +} + +pub fn decode_strong_ref() -> decode.Decoder(StrongRefJson) { + use uri <- field("uri", string) + use cid <- field("cid", string) + success(StrongRefJson(uri:, cid:)) +} + +pub type LabelJson { + LabelJson(val: String) +} + +pub fn decode_label() -> decode.Decoder(LabelJson) { + use val <- field("val", string) + success(LabelJson(val:)) +} + +pub type ByteSliceJson { + ByteSliceJson(byte_start: Int, byte_end: Int) +} + +pub fn decode_byte_slice() -> decode.Decoder(ByteSliceJson) { + use byte_start <- field("byteStart", int) + use byte_end <- field("byteEnd", int) + success(ByteSliceJson(byte_start:, byte_end:)) +} + +pub type MentionJson { + MentionJson(did: String) +} + +pub fn decode_mention() -> decode.Decoder(MentionJson) { + use did <- field("did", string) + success(MentionJson(did:)) +} + +pub type LinkJson { + LinkJson(uri: String) +} + +pub fn decode_link() -> decode.Decoder(LinkJson) { + use uri <- field("uri", string) + success(LinkJson(uri:)) +} + +pub type TagJson { + TagJson(tag: String) +} + +pub fn decode_tag() -> decode.Decoder(TagJson) { + use tag <- field("tag", string) + success(TagJson(tag:)) +} + +pub type AspectRatioJson { + AspectRatioJson(width: Int, height: Int) +} + +pub fn decode_aspect_ratio() -> decode.Decoder(AspectRatioJson) { + use width <- field("width", int) + use height <- field("height", int) + success(AspectRatioJson(width:, height:)) +} + +fn decode_blob_ref() -> decode.Decoder(String) { + at(["ref", "$link"], string) +} + +/// Decodes an avatar field that may be either a plain string URL +/// (as in ProfileViewBasic) or a blob object with ref.$link +/// (as in getRecord responses for app.bsky.actor.profile). +fn decode_avatar() -> decode.Decoder(String) { + let blob_decoder = at(["ref", "$link"], string) + one_of(string, [blob_decoder]) +} + +pub type ProfileJson { + ProfileJson( + display_name: Option(String), + description: Option(String), + avatar: Option(String), + ) +} + +pub fn decode_profile() -> decode.Decoder(ProfileJson) { + use display_name <- optional_field("displayName", None, map(string, Some)) + use description <- optional_field("description", None, map(string, Some)) + use avatar <- optional_field("avatar", None, map(decode_avatar(), Some)) + success(ProfileJson(display_name:, description:, avatar:)) +} + +pub type ImageJson { + ImageJson(alt: String, ref: String, aspect_ratio: Option(AspectRatioJson)) +} + +pub fn decode_image() -> decode.Decoder(ImageJson) { + use alt <- field("alt", string) + use ref_field <- field("image", decode_blob_ref()) + use aspect_ratio <- optional_field( + "aspectRatio", + None, + map(decode_aspect_ratio(), Some), + ) + success(ImageJson(alt:, ref: ref_field, aspect_ratio:)) +} + +pub type ViewImageJson { + ViewImageJson( + thumb: String, + fullsize: String, + alt: String, + aspect_ratio: Option(AspectRatioJson), + ) +} + +pub fn decode_view_image() -> decode.Decoder(ViewImageJson) { + use thumb <- field("thumb", string) + use fullsize <- field("fullsize", string) + use alt <- field("alt", string) + use aspect_ratio <- optional_field( + "aspectRatio", + None, + map(decode_aspect_ratio(), Some), + ) + success(ViewImageJson(thumb:, fullsize:, alt:, aspect_ratio:)) +} + +pub type ExternalJson { + ExternalJson(uri: String, title: String, description: String) +} + +pub fn decode_external() -> decode.Decoder(ExternalJson) { + use uri <- field("uri", string) + use title <- field("title", string) + use description <- field("description", string) + success(ExternalJson(uri:, title:, description:)) +} + +pub type ViewExternalJson { + ViewExternalJson( + uri: String, + title: String, + description: String, + thumb: Option(String), + ) +} + +pub fn decode_view_external() -> decode.Decoder(ViewExternalJson) { + use uri <- field("uri", string) + use title <- field("title", string) + use description <- field("description", string) + use thumb <- optional_field("thumb", None, map(string, Some)) + success(ViewExternalJson(uri:, title:, description:, thumb:)) +} + +pub type EmbedRecordJson { + EmbedRecordJson(record: StrongRefJson) +} + +pub fn decode_embed_record() -> decode.Decoder(EmbedRecordJson) { + use record <- field("record", decode_strong_ref()) + success(EmbedRecordJson(record:)) +} + +pub type ViewNotFoundJson { + ViewNotFoundJson(uri: String, not_found: Bool) +} + +pub fn decode_view_not_found() -> decode.Decoder(ViewNotFoundJson) { + use uri <- field("uri", string) + use not_found <- field("notFound", bool) + success(ViewNotFoundJson(uri:, not_found:)) +} + +pub type ViewBlockedJson { + ViewBlockedJson(uri: String, blocked: Bool) +} + +pub fn decode_view_blocked() -> decode.Decoder(ViewBlockedJson) { + use uri <- field("uri", string) + use blocked <- field("blocked", bool) + success(ViewBlockedJson(uri:, blocked:)) +} + +pub type ViewDetachedJson { + ViewDetachedJson(uri: String, detached: Bool) +} + +pub fn decode_view_detached() -> decode.Decoder(ViewDetachedJson) { + use uri <- field("uri", string) + use detached <- field("detached", bool) + success(ViewDetachedJson(uri:, detached:)) +} + +pub type ReplyRefJson { + ReplyRefJson(root: StrongRefJson, parent: StrongRefJson) +} + +pub fn decode_reply_ref() -> decode.Decoder(ReplyRefJson) { + use root <- field("root", decode_strong_ref()) + use parent <- field("parent", decode_strong_ref()) + success(ReplyRefJson(root:, parent:)) +} + +pub type TextSliceJson { + TextSliceJson(start: Int, end: Int) +} + +pub fn decode_text_slice() -> decode.Decoder(TextSliceJson) { + use start <- field("start", int) + use end <- field("end", int) + success(TextSliceJson(start:, end:)) +} + +pub type MiniDocJson { + MiniDocJson(did: String, handle: String, pds: String, signing_key: String) +} + +pub fn decode_mini_doc() -> decode.Decoder(MiniDocJson) { + use did <- field("did", string) + use handle <- field("handle", string) + use pds <- field("pds", string) + use signing_key <- field("signing_key", string) + success(MiniDocJson(did:, handle:, pds:, signing_key:)) +} + +// === Union types === + +pub type FacetFeature { + Mention(did: String) + Link(uri: String) + Tag(tag: String) +} + +pub fn decode_facet_feature() -> decode.Decoder(FacetFeature) { + use type_ <- field("$type", string) + case type_ { + "app.bsky.richtext.facet#mention" -> { + use did <- field("did", string) + success(Mention(did:)) + } + "app.bsky.richtext.facet#link" -> { + use uri <- field("uri", string) + success(Link(uri:)) + } + "app.bsky.richtext.facet#tag" -> { + use tag <- field("tag", string) + success(Tag(tag:)) + } + _ -> failure(Mention(""), "unknown facet feature type") + } +} + +pub type FacetJson { + FacetJson(index: ByteSliceJson, features: List(FacetFeature)) +} + +pub fn decode_facet() -> decode.Decoder(FacetJson) { + use index <- field("index", decode_byte_slice()) + use features <- field("features", list(decode_facet_feature())) + success(FacetJson(index:, features:)) +} + +pub type Embed { + Images(List(ImageJson)) + ExternalLink(ExternalJson) + Record(EmbedRecordJson) +} + +pub fn decode_embed() -> decode.Decoder(Embed) { + use type_ <- field("$type", string) + case type_ { + "app.bsky.embed.images" -> { + use images <- field("images", list(decode_image())) + success(Images(images)) + } + "app.bsky.embed.external" -> { + use external <- field("external", decode_external()) + success(ExternalLink(external)) + } + "app.bsky.embed.record" -> map(decode_embed_record(), Record) + _ -> failure(Images([]), "unknown embed type") + } +} + +pub type PostJson { + PostJson( + text: String, + facets: Option(List(FacetJson)), + reply: Option(ReplyRefJson), + embed: Option(Embed), + langs: Option(List(String)), + labels: Option(List(LabelJson)), + tags: Option(List(String)), + created_at: String, + ) +} + +pub fn decode_post() -> decode.Decoder(PostJson) { + use text <- field("text", string) + use facets <- optional_field("facets", None, map(list(decode_facet()), Some)) + use reply <- optional_field("reply", None, map(decode_reply_ref(), Some)) + use embed <- optional_field("embed", None, map(decode_embed(), Some)) + use langs <- optional_field("langs", None, map(list(string), Some)) + use labels <- optional_field("labels", None, map(list(decode_label()), Some)) + use tags <- optional_field("tags", None, map(list(string), Some)) + use created_at <- field("createdAt", string) + success(PostJson( + text:, + facets:, + reply:, + embed:, + langs:, + labels:, + tags:, + created_at:, + )) +} + +// === EmbedRecordView union === + +pub type ProfileViewBasicJson { + ProfileViewBasicJson( + did: String, + handle: String, + display_name: Option(String), + avatar: Option(String), + ) +} + +pub fn decode_profile_view_basic() -> decode.Decoder(ProfileViewBasicJson) { + use did <- field("did", string) + use handle <- field("handle", string) + use display_name <- optional_field("displayName", None, map(string, Some)) + use avatar <- optional_field("avatar", None, map(string, Some)) + success(ProfileViewBasicJson(did:, handle:, display_name:, avatar:)) +} + +pub type EmbedRecordView { + ViewRecord(ViewRecordJson) + NotFound(ViewNotFoundJson) + Blocked(ViewBlockedJson) + Detached(ViewDetachedJson) +} + +pub type ViewRecordJson { + ViewRecordJson( + uri: String, + cid: String, + author: ProfileViewBasicJson, + value: dyn.Dynamic, + labels: Option(List(LabelJson)), + reply_count: Option(Int), + repost_count: Option(Int), + like_count: Option(Int), + quote_count: Option(Int), + embeds: Option(List(EmbedRecordView)), + indexed_at: String, + ) +} + +pub fn decode_embed_record_view() -> decode.Decoder(EmbedRecordView) { + use type_ <- field("$type", string) + case type_ { + "app.bsky.embed.record#viewRecord" -> map(decode_view_record(), ViewRecord) + "app.bsky.embed.record#viewNotFound" -> + map(decode_view_not_found(), NotFound) + "app.bsky.embed.record#viewBlocked" -> map(decode_view_blocked(), Blocked) + "app.bsky.embed.record#viewDetached" -> + map(decode_view_detached(), Detached) + _ -> + failure( + ViewRecord(ViewRecordJson( + uri: "", + cid: "", + author: ProfileViewBasicJson("", "", None, None), + value: dyn.int(0), + labels: None, + reply_count: None, + repost_count: None, + like_count: None, + quote_count: None, + embeds: None, + indexed_at: "", + )), + "unknown embed record view type", + ) + } +} + +pub fn decode_view_record() -> decode.Decoder(ViewRecordJson) { + use uri <- field("uri", string) + use cid <- field("cid", string) + use author <- field("author", decode_profile_view_basic()) + use value <- field("value", decode_dyn) + use labels <- optional_field("labels", None, map(list(decode_label()), Some)) + use reply_count <- optional_field("replyCount", None, map(int, Some)) + use repost_count <- optional_field("repostCount", None, map(int, Some)) + use like_count <- optional_field("likeCount", None, map(int, Some)) + use quote_count <- optional_field("quoteCount", None, map(int, Some)) + use embeds <- optional_field( + "embeds", + None, + map(list(decode_embed_record_view()), Some), + ) + use indexed_at <- field("indexedAt", string) + success(ViewRecordJson( + uri:, + cid:, + author:, + value:, + labels:, + reply_count:, + repost_count:, + like_count:, + quote_count:, + embeds:, + indexed_at:, + )) +} + +pub type ThreadCountsJson { + ThreadCountsJson( + reply_count: Option(Int), + repost_count: Option(Int), + like_count: Option(Int), + quote_count: Option(Int), + ) +} + +pub fn decode_thread_counts() -> decode.Decoder(ThreadCountsJson) { + use reply_count <- optional_field("replyCount", None, map(int, Some)) + use repost_count <- optional_field("repostCount", None, map(int, Some)) + use like_count <- optional_field("likeCount", None, map(int, Some)) + use quote_count <- optional_field("quoteCount", None, map(int, Some)) + success(ThreadCountsJson( + reply_count:, + repost_count:, + like_count:, + quote_count:, + )) +} + +pub type ThreadViewJson { + ThreadViewJson(post: Option(ThreadPostViewJson)) +} + +pub type ThreadPostViewJson { + ThreadPostViewJson(counts: ThreadCountsJson) +} + +pub fn decode_thread_view() -> decode.Decoder(ThreadViewJson) { + use post <- optional_field("post", None, map(decode_thread_post_view(), Some)) + success(ThreadViewJson(post:)) +} + +pub fn decode_thread_post_view() -> decode.Decoder(ThreadPostViewJson) { + map(decode_thread_counts(), ThreadPostViewJson) +} diff --git a/src/gpreview.css b/src/gpreview.css index 48ccfed..7b65486 100644 --- a/src/gpreview.css +++ b/src/gpreview.css @@ -22,8 +22,7 @@ body { .card { background-color: white; border-radius: 0.75rem; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06); overflow: hidden; transition: box-shadow 0.2s ease; } @@ -38,7 +37,7 @@ body { color: white; border-radius: 0.75rem; font-weight: 500; - transition: background-color 0.2s ease, box-shadow 0.2s ease; + transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.1s ease; white-space: nowrap; } @@ -46,6 +45,10 @@ body { background-color: var(--color-sky-600); } +.btn-primary:active { + transform: scale(0.96); +} + .btn-primary:focus { outline: none; box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.3); @@ -55,7 +58,7 @@ body { padding: 1rem 1.25rem; border: 1px solid #cbd5e1; border-radius: 0.75rem; - transition: all 0.2s ease; + transition: border-color 0.2s ease, box-shadow 0.2s ease; } .input-primary:focus { @@ -122,16 +125,14 @@ body { .link-card { display: block; - border: 1px solid #e2e8f0; border-radius: 0.5rem; overflow: hidden; - transition: all 0.2s ease; + transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; } .link-card:hover { - border-color: #bae6fd; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04); background-color: #f8fafc; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); } .image-grid { @@ -148,6 +149,7 @@ body { height: auto; object-fit: cover; transition: transform 0.2s ease; + outline: 1px solid rgba(0, 0, 0, 0.08); } .image-grid img:hover { @@ -163,7 +165,7 @@ body { font-weight: 500; background-color: #fef3c7; color: #b45309; - border: 1px solid #fcd34d; + box-shadow: 0 0 0 1px rgba(252, 211, 77, 0.4); } .post-content { @@ -172,6 +174,7 @@ body { line-height: 1.75; white-space: pre-wrap; word-break: break-word; + text-wrap: pretty; } .external-link-preview { @@ -183,6 +186,7 @@ body { color: #0f172a; font-size: 1rem; margin-bottom: 0.25rem; + text-wrap: balance; } .external-link-desc { @@ -194,6 +198,10 @@ body { overflow: hidden; } +.tabular-nums { + font-variant-numeric: tabular-nums; +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/src/gpreview.gleam b/src/gpreview.gleam index f7c8412..22597ae 100644 --- a/src/gpreview.gleam +++ b/src/gpreview.gleam @@ -1,5 +1,4 @@ -import app/post -import app/profile +import bsky/decoders import gleam/dynamic/decode.{field, string, success} import gleam/int import gleam/list @@ -24,9 +23,10 @@ pub fn main() { pub type Model { App( at_url: String, - did_doc: Option(Result(profile.MiniDoc, String)), - post: Option(Result(Record(post.Post), String)), - profile: Option(Result(profile.Profile, String)), + did_doc: Option(Result(decoders.MiniDocJson, String)), + post: Option(Result(Record(decoders.PostJson), String)), + profile: Option(Result(decoders.ProfileJson, String)), + thread_counts: Option(decoders.ThreadCountsJson), ) } @@ -37,6 +37,7 @@ fn init(_args) -> #(Model, Effect(Msg)) { None, None, None, + None, ), effect.none(), ) @@ -45,9 +46,10 @@ fn init(_args) -> #(Model, Effect(Msg)) { pub type Msg { LinkWasSet(String) UserClickedShow - MiniDocWasResolved(Result(profile.MiniDoc, rsvp.Error)) - PostWasFetched(Result(Record(post.Post), rsvp.Error)) - ProfileWasFetched(Result(Record(profile.Profile), rsvp.Error)) + MiniDocWasResolved(Result(decoders.MiniDocJson, rsvp.Error)) + PostWasFetched(Result(Record(decoders.PostJson), rsvp.Error)) + ProfileWasFetched(Result(Record(decoders.ProfileJson), rsvp.Error)) + ThreadWasFetched(Result(decoders.ThreadCountsJson, rsvp.Error)) } pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { @@ -85,10 +87,19 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { App(..model, post: Some(Error(error_to_string(e)))), effect.none(), ) - ProfileWasFetched(Ok(p)) -> #( - App(..model, profile: Some(Ok(p.value))), - effect.none(), - ) + ProfileWasFetched(Ok(p)) -> { + case model.post { + Some(Ok(Record(uri: uri, ..))) -> + case model.did_doc { + Some(Ok(doc)) -> #( + App(..model, profile: Some(Ok(p.value))), + fetch_thread_counts(doc.pds, uri), + ) + _ -> #(App(..model, profile: Some(Ok(p.value))), effect.none()) + } + _ -> #(App(..model, profile: Some(Ok(p.value))), effect.none()) + } + } ProfileWasFetched(Error(e)) -> #( App( ..model, @@ -96,6 +107,11 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ), effect.none(), ) + ThreadWasFetched(Ok(counts)) -> #( + App(..model, thread_counts: Some(counts)), + effect.none(), + ) + ThreadWasFetched(Error(_e)) -> #(model, effect.none()) } } @@ -136,7 +152,7 @@ fn view(model: Model) -> Element(Msg) { } fn get_post_error( - post: Option(Result(Record(post.Post), String)), + post: Option(Result(Record(decoders.PostJson), String)), ) -> Option(String) { case post { Some(Error(e)) -> Some(e) @@ -146,7 +162,8 @@ fn get_post_error( fn display_post(model: Model) -> Element(Msg) { case model.post, model.profile { - Some(_), Some(_) -> post_card(model.post, model.profile, model.did_doc) + Some(_), Some(_) -> + post_card(model.post, model.profile, model.did_doc, model.thread_counts) Some(Ok(_)), None -> loading_state() _, _ -> element.none() } @@ -180,38 +197,76 @@ fn loading_state() -> Element(Msg) { } fn post_card( - post_opt: Option(Result(Record(post.Post), String)), - profile_opt: Option(Result(profile.Profile, String)), - did_doc: Option(Result(profile.MiniDoc, String)), + post_opt: Option(Result(Record(decoders.PostJson), String)), + profile_opt: Option(Result(decoders.ProfileJson, String)), + did_doc: Option(Result(decoders.MiniDocJson, String)), + thread_counts: Option(decoders.ThreadCountsJson), ) -> Element(Msg) { case post_opt, profile_opt, did_doc { Some(Ok(Record(uri: _, cid: _, value: post))), Some(Ok(profile)), Some(Ok(doc)) - -> + -> { + let reply_context = case post.reply { + Some(reply) -> { + let parent_did = + reply.parent.uri + |> extract_did_from_uri + |> fn(r) { + case r { + Ok(d) -> d + Error(_) -> "unknown" + } + } + let short_did = string.slice(parent_did, 0, 20) <> "…" + html.div( + [ + attribute.attribute("class", "px-6 pb-2 text-xs text-slate-500"), + ], + [ + html.span( + [ + attribute.attribute("class", "text-slate-400"), + ], + [html.text("Replying to " <> short_did)], + ), + ], + ) + } + None -> element.none() + } + html.div( [ attribute.attribute("class", "card"), ], [ - post_header(profile, doc.did, doc.pds), + post_header(profile, doc.handle, doc.did, doc.pds), html.div( [ attribute.attribute("class", "px-6 pb-4"), ], [ + reply_context, html.p( [ attribute.attribute("class", "post-content"), + attribute.attribute("style", "text-wrap: pretty"), ], - [html.text(render_post_with_facets(post.text, post.facets))], + render_post_with_facets(post.text, post.facets, doc.pds), + ), + post_embed(post.embed, doc.pds, doc.did), + post_footer( + post.labels, + post.tags, + post.created_at, + thread_counts, ), - post_embed(post.embed), - post_footer(post.labels, post.created_at), ], ), ], ) + } Some(Error(e)), _, _ | _, Some(Error(e)), _ | _, _, Some(Error(e)) -> html.text(e) _, _, _ -> element.none() @@ -219,10 +274,39 @@ fn post_card( } fn post_header( - profile: profile.Profile, + profile: decoders.ProfileJson, + handle: String, did: String, pds_host: String, ) -> Element(Msg) { + let display_name_el = case profile.display_name { + Some(name) -> + html.span( + [ + attribute.attribute( + "class", + "font-semibold text-slate-900 text-base leading-tight", + ), + ], + [html.text(name)], + ) + None -> element.none() + } + + let description_el = case profile.description { + Some(desc) -> + html.p( + [ + attribute.attribute( + "class", + "text-xs text-slate-500 leading-tight mt-0.5 truncate", + ), + ], + [html.text(desc)], + ) + None -> element.none() + } + html.div( [ attribute.attribute( @@ -234,10 +318,7 @@ fn post_header( case profile.avatar { Some(blob) -> html.img([ - attribute.attribute( - "src", - profile.blob_ref_to_url(pds_host, did, blob), - ), + attribute.attribute("src", blob_ref_to_url(pds_host, did, blob)), attribute.attribute("alt", "Avatar"), attribute.attribute("class", "avatar"), attribute.attribute("referrerpolicy", "no-referrer"), @@ -255,45 +336,154 @@ fn post_header( attribute.attribute("class", "flex flex-col min-w-0"), ], [ + display_name_el, html.div( [ attribute.attribute( "class", - "font-semibold text-slate-900 text-base leading-tight", + "text-sm text-slate-500 leading-tight mt-0.5", ), ], - [ - case profile.display_name { - None -> element.none() - Some(handle) -> - html.div( - [ - attribute.attribute( - "class", - "text-sm text-slate-500 leading-tight mt-0.5", - ), - ], - [html.text("@" <> handle)], - ) - }, - ], + [html.text("@" <> handle)], ), + description_el, ], ), ], ) } -fn render_post_with_facets(text: String, _facets: List(post.Facet)) -> String { - text +fn blob_ref_to_url(pds_host: String, did: String, blob: String) -> String { + pds_host <> "/xrpc/com.atproto.sync.getBlob?did=" <> did <> "&cid=" <> blob +} + +fn render_post_with_facets( + text: String, + facets: Option(List(decoders.FacetJson)), + pds_host: String, +) -> List(Element(Msg)) { + case facets { + None -> [html.text(text)] + Some(f) -> { + let sorted = + list.sort(f, fn(a, b) { + int.compare(a.index.byte_start, b.index.byte_start) + }) + build_facet_elements(text, sorted, 0, pds_host) + } + } +} + +fn build_facet_elements( + text: String, + facets: List(decoders.FacetJson), + byte_offset: Int, + pds_host: String, +) -> List(Element(Msg)) { + case facets { + [] -> { + let remaining = + string.slice(text, byte_offset, string.length(text) - byte_offset) + case string.is_empty(remaining) { + True -> [] + False -> [html.text(remaining)] + } + } + [facet, ..rest] -> { + let start = facet.index.byte_start + let end = facet.index.byte_end + + let before = case start > byte_offset { + True -> { + let segment = string.slice(text, byte_offset, start - byte_offset) + case string.is_empty(segment) { + True -> [] + False -> [html.text(segment)] + } + } + False -> [] + } + + let facet_text = string.slice(text, start, end - start) + let facet_elements = + build_facet_element(facet.features, facet_text, pds_host) + + let after = build_facet_elements(text, rest, end, pds_host) + + list.append(before, list.append(facet_elements, after)) + } + } +} + +fn build_facet_element( + features: List(decoders.FacetFeature), + text: String, + _pds_host: String, +) -> List(Element(Msg)) { + case features { + [] -> [html.text(text)] + [feature, ..] -> { + case feature { + decoders.Mention(did) -> { + let profile_url = "https://bsky.app/profile/" <> did + [ + html.a( + [ + attribute.attribute("href", profile_url), + attribute.attribute("target", "_blank"), + attribute.attribute("rel", "noopener noreferrer"), + attribute.attribute("class", "mention-link"), + ], + [html.text(text)], + ), + ] + } + decoders.Link(uri) -> [ + html.a( + [ + attribute.attribute("href", uri), + attribute.attribute("target", "_blank"), + attribute.attribute("rel", "noopener noreferrer"), + attribute.attribute("class", "link-facet"), + ], + [html.text(text)], + ), + ] + decoders.Tag(tag) -> { + let tag_url = "https://bsky.app/hashtag/" <> tag + [ + html.span( + [ + attribute.attribute("class", "tag-link"), + ], + [ + html.a( + [ + attribute.attribute("href", tag_url), + attribute.attribute("target", "_blank"), + attribute.attribute("rel", "noopener noreferrer"), + ], + [html.text("#" <> tag)], + ), + ], + ), + ] + } + } + } + } } -fn post_embed(embed: Option(post.Embed)) -> Element(Msg) { +fn post_embed( + embed: Option(decoders.Embed), + pds_host: String, + did: String, +) -> Element(Msg) { case embed { None -> element.none() Some(embed_obj) -> case embed_obj { - post.Images(images) -> + decoders.Images(images) -> html.div( [ attribute.attribute("class", "image-grid"), @@ -302,7 +492,7 @@ fn post_embed(embed: Option(post.Embed)) -> Element(Msg) { html.img([ attribute.attribute( "src", - "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Crect width='400' height='300' fill='%23e2e8f0'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em'%3EImage%3C/text%3E%3C/svg%3E", + blob_ref_to_url(pds_host, did, img.ref), ), attribute.attribute("alt", img.alt), attribute.attribute("class", "w-full h-auto object-cover"), @@ -310,7 +500,7 @@ fn post_embed(embed: Option(post.Embed)) -> Element(Msg) { ]) }), ) - post.ExternalLink(external) -> + decoders.ExternalLink(external) -> html.a( [ attribute.attribute("href", external.uri), @@ -319,16 +509,6 @@ fn post_embed(embed: Option(post.Embed)) -> Element(Msg) { attribute.attribute("class", "link-card"), ], [ - case external.thumb { - Some(thumb_url) -> - html.img([ - attribute.attribute("src", thumb_url), - attribute.attribute("alt", external.title), - attribute.attribute("class", "w-full h-48 object-cover"), - attribute.attribute("referrerpolicy", "no-referrer"), - ]) - None -> element.none() - }, html.div( [ attribute.attribute("class", "external-link-preview"), @@ -350,12 +530,80 @@ fn post_embed(embed: Option(post.Embed)) -> Element(Msg) { ), ], ) - post.Record(r) -> html.text(r.uri) + decoders.Record(r) -> html.text(r.record.uri) } } } -fn post_footer(labels: List(post.Label), created_at: String) -> Element(Msg) { +fn post_footer( + labels: Option(List(decoders.LabelJson)), + tags: Option(List(String)), + created_at: String, + thread_counts: Option(decoders.ThreadCountsJson), +) -> Element(Msg) { + let badges = case labels { + None -> [] + Some(lbls) -> + list.map(lbls, fn(label) { + html.span( + [ + attribute.attribute("class", "label-badge"), + ], + [html.text(label.val)], + ) + }) + } + + let tag_badges = case tags { + None -> [] + Some(ts) -> + list.map(ts, fn(tag) { + html.a( + [ + attribute.attribute("href", "https://bsky.app/hashtag/" <> tag), + attribute.attribute("target", "_blank"), + attribute.attribute("rel", "noopener noreferrer"), + attribute.attribute("class", "tag-badge"), + ], + [html.text("#" <> tag)], + ) + }) + } + + let all_badges = list.append(badges, tag_badges) + + let counts_el = case thread_counts { + Some(counts) -> { + let like_count = case counts.like_count { + Some(n) -> int.to_string(n) + None -> "0" + } + let repost_count = case counts.repost_count { + Some(n) -> int.to_string(n) + None -> "0" + } + let reply_count = case counts.reply_count { + Some(n) -> int.to_string(n) + None -> "0" + } + + html.div( + [ + attribute.attribute( + "class", + "flex items-center gap-3 text-xs text-slate-500 tabular-nums", + ), + ], + [ + html.span([], [html.text("♥ " <> like_count)]), + html.span([], [html.text("↗ " <> repost_count)]), + html.span([], [html.text("💬 " <> reply_count)]), + ], + ) + } + None -> element.none() + } + html.div( [ attribute.attribute( @@ -364,27 +612,31 @@ fn post_footer(labels: List(post.Label), created_at: String) -> Element(Msg) { ), ], [ - html.p( + html.div( [ - attribute.attribute("class", "text-xs text-slate-400"), + attribute.attribute("class", "flex items-center gap-3"), + ], + [ + html.p( + [ + attribute.attribute( + "class", + "text-xs text-slate-400 tabular-nums", + ), + ], + [html.text(format_timestamp(created_at))], + ), + counts_el, ], - [html.text(format_timestamp(created_at))], ), - case labels { + case all_badges { [] -> element.none() _ -> html.div( [ - attribute.attribute("class", "flex gap-1"), + attribute.attribute("class", "flex gap-1 flex-wrap"), ], - list.map(labels, fn(label) { - html.span( - [ - attribute.attribute("class", "label-badge"), - ], - [html.text(label.val)], - ) - }), + all_badges, ) }, ], @@ -450,12 +702,33 @@ fn fetch_profile(pds_host: String, did: String) -> Effect(Msg) { <> "/xrpc/com.atproto.repo.getRecord?" <> construct_profile_uri(did), rsvp.expect_json( - decode_get_record_response(profile.decode_profile()), + decode_get_record_response(decoders.decode_profile()), ProfileWasFetched, ), ) } +fn fetch_thread_counts(_pds_host: String, uri: String) -> Effect(Msg) { + let encoded_uri = + uri + |> string.replace(":", "%3A") + |> string.replace("/", "%2F") + let url = + "https://public.api.bsky.app" + <> "/xrpc/app.bsky.feed.getPostThread?uri=" + <> encoded_uri + <> "&depth=0" + rsvp.get(url, rsvp.expect_json(decode_thread_response(), ThreadWasFetched)) +} + +fn decode_thread_response() -> decode.Decoder(decoders.ThreadCountsJson) { + use thread <- field("thread", decoders.decode_thread_view()) + case thread.post { + Some(post_view) -> success(post_view.counts) + None -> success(decoders.ThreadCountsJson(None, None, None, None)) + } +} + pub fn extract_did_from_uri(uri: String) -> Result(String, Nil) { let u = case uri { "at://" <> rest -> rest @@ -483,7 +756,7 @@ fn get_record(pds_host: String, at_url: String) -> Effect(Msg) { rsvp.get( url, rsvp.expect_json( - decode_get_record_response(post.decode_post()), + decode_get_record_response(decoders.decode_post()), PostWasFetched, ), ) @@ -502,7 +775,7 @@ fn resolve_mini_doc(identifier: String) -> Effect(Msg) { slingshot_base <> "/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" <> identifier, - rsvp.expect_json(profile.decode_mini_doc(), MiniDocWasResolved), + rsvp.expect_json(decoders.decode_mini_doc(), MiniDocWasResolved), ) } diff --git a/test/bsky_test.gleam b/test/bsky_test.gleam index 1b38865..c31a18f 100644 --- a/test/bsky_test.gleam +++ b/test/bsky_test.gleam @@ -1,9 +1,11 @@ -import app/post -import app/profile -import gleam/dynamic/decode +import bsky/decoders +import gleam/dynamic/decode.{field, string, success} import gleam/json +import gleam/list +import gleam/option.{None, Some} import gleeunit import gleeunit/should +import gpreview pub fn main() { gleeunit.main() @@ -16,11 +18,29 @@ fn from_json( json.parse(from: json_string, using: decoder) } +// === MiniDoc === + +pub fn decode_mini_doc_test() { + let json_string = + "{\"did\":\"did:plc:abc123\",\"handle\":\"test.bsky.social\",\"pds\":\"https://example.pds.com\",\"signing_key\":\"zTestKey123\"}" + + from_json(json_string, decoders.decode_mini_doc()) + |> should.be_ok() + |> fn(m) { + m.did |> should.equal("did:plc:abc123") + m.handle |> should.equal("test.bsky.social") + m.pds |> should.equal("https://example.pds.com") + m.signing_key |> should.equal("zTestKey123") + } +} + +// === StrongRef === + pub fn decode_strong_ref_test() { let json_string = "{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"cid\":\"bafyre123\"}" - from_json(json_string, post.decode_strong_ref()) + from_json(json_string, decoders.decode_strong_ref()) |> should.be_ok() |> fn(p) { p.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") @@ -28,19 +48,123 @@ pub fn decode_strong_ref_test() { } } +// === Label === + pub fn decode_label_test() { let json_string = "{\"val\":\"nsfw\"}" - from_json(json_string, post.decode_label()) + from_json(json_string, decoders.decode_label()) |> should.be_ok() |> fn(l) { l.val |> should.equal("nsfw") } } +// === ByteSlice === + +pub fn decode_byte_slice_test() { + let json_string = "{\"byteStart\":0,\"byteEnd\":5}" + + from_json(json_string, decoders.decode_byte_slice()) + |> should.be_ok() + |> fn(b) { + b.byte_start |> should.equal(0) + b.byte_end |> should.equal(5) + } +} + +// === AspectRatio === + +pub fn decode_aspect_ratio_test() { + let json_string = "{\"width\":1200,\"height\":630}" + + from_json(json_string, decoders.decode_aspect_ratio()) + |> should.be_ok() + |> fn(a) { + a.width |> should.equal(1200) + a.height |> should.equal(630) + } +} + +// === Mention === + +pub fn decode_mention_test() { + let json_string = "{\"did\":\"did:plc:456\"}" + + from_json(json_string, decoders.decode_mention()) + |> should.be_ok() + |> fn(m) { m.did |> should.equal("did:plc:456") } +} + +// === Link === + +pub fn decode_link_test() { + let json_string = "{\"uri\":\"https://example.com\"}" + + from_json(json_string, decoders.decode_link()) + |> should.be_ok() + |> fn(l) { l.uri |> should.equal("https://example.com") } +} + +// === Tag === + +pub fn decode_tag_test() { + let json_string = "{\"tag\":\"gleam\"}" + + from_json(json_string, decoders.decode_tag()) + |> should.be_ok() + |> fn(t) { t.tag |> should.equal("gleam") } +} + +// === FacetFeature === + +pub fn decode_facet_feature_mention_test() { + let json_string = + "{\"$type\":\"app.bsky.richtext.facet#mention\",\"did\":\"did:plc:456\"}" + + from_json(json_string, decoders.decode_facet_feature()) + |> should.be_ok() + |> fn(f) { + case f { + decoders.Mention(did) -> did |> should.equal("did:plc:456") + _ -> should.fail() + } + } +} + +pub fn decode_facet_feature_link_test() { + let json_string = + "{\"$type\":\"app.bsky.richtext.facet#link\",\"uri\":\"https://example.com\"}" + + from_json(json_string, decoders.decode_facet_feature()) + |> should.be_ok() + |> fn(f) { + case f { + decoders.Link(uri) -> uri |> should.equal("https://example.com") + _ -> should.fail() + } + } +} + +pub fn decode_facet_feature_tag_test() { + let json_string = + "{\"$type\":\"app.bsky.richtext.facet#tag\",\"tag\":\"gleam\"}" + + from_json(json_string, decoders.decode_facet_feature()) + |> should.be_ok() + |> fn(f) { + case f { + decoders.Tag(tag) -> tag |> should.equal("gleam") + _ -> should.fail() + } + } +} + +// === Facet === + pub fn decode_facet_test() { let json_string = "{\"index\":{\"byteStart\":0,\"byteEnd\":5},\"features\":[{\"$type\":\"app.bsky.richtext.facet#mention\",\"did\":\"did:plc:456\"}]}" - from_json(json_string, post.decode_facet()) + from_json(json_string, decoders.decode_facet()) |> should.be_ok() |> fn(f) { f.index.byte_start |> should.equal(0) @@ -48,19 +172,46 @@ pub fn decode_facet_test() { } } +// === Image === + pub fn decode_image_test() { - let json_string = "{\"alt\":\"A cat image\"}" + let json_string = + "{\"alt\":\"A cat image\",\"image\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkrei123\"},\"mimeType\":\"image/jpeg\",\"size\":12345}}" + + from_json(json_string, decoders.decode_image()) + |> should.be_ok() + |> fn(i) { + i.alt |> should.equal("A cat image") + i.ref |> should.equal("bafkrei123") + } +} + +pub fn decode_image_with_aspect_ratio_test() { + let json_string = + "{\"alt\":\"A cat image\",\"image\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkrei456\"},\"mimeType\":\"image/jpeg\",\"size\":12345},\"aspectRatio\":{\"width\":1200,\"height\":630}}" - from_json(json_string, post.decode_image()) + from_json(json_string, decoders.decode_image()) |> should.be_ok() - |> fn(i) { i.alt |> should.equal("A cat image") } + |> fn(i) { + i.alt |> should.equal("A cat image") + i.ref |> should.equal("bafkrei456") + case i.aspect_ratio { + Some(ar) -> { + ar.width |> should.equal(1200) + ar.height |> should.equal(630) + } + None -> should.fail() + } + } } +// === External === + pub fn decode_external_test() { let json_string = "{\"uri\":\"https://example.com\",\"title\":\"Example\",\"description\":\"An example site\"}" - from_json(json_string, post.decode_external()) + from_json(json_string, decoders.decode_external()) |> should.be_ok() |> fn(e) { e.uri |> should.equal("https://example.com") @@ -69,19 +220,60 @@ pub fn decode_external_test() { } } -pub fn decode_embed_test() { +// === Embed === + +pub fn decode_embed_images_test() { let json_string = - "{\"$type\":\"app.bsky.embed.images\",\"images\":[{\"alt\":\"Image 1\"}]}" + "{\"$type\":\"app.bsky.embed.images\",\"images\":[{\"alt\":\"Image 1\",\"image\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkrei123\"},\"mimeType\":\"image/jpeg\",\"size\":12345}}]}" - from_json(json_string, post.decode_embed()) + from_json(json_string, decoders.decode_embed()) |> should.be_ok() + |> fn(e) { + case e { + decoders.Images(images) -> list.length(images) |> should.equal(1) + _ -> should.fail() + } + } } +pub fn decode_embed_external_test() { + let json_string = + "{\"$type\":\"app.bsky.embed.external\",\"external\":{\"uri\":\"https://example.com\",\"title\":\"Example\",\"description\":\"An example site\"}}" + + from_json(json_string, decoders.decode_embed()) + |> should.be_ok() + |> fn(e) { + case e { + decoders.ExternalLink(ext) -> + ext.uri |> should.equal("https://example.com") + _ -> should.fail() + } + } +} + +pub fn decode_embed_record_test() { + let json_string = + "{\"$type\":\"app.bsky.embed.record\",\"record\":{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"cid\":\"bafyre123\"}}" + + from_json(json_string, decoders.decode_embed()) + |> should.be_ok() + |> fn(e) { + case e { + decoders.Record(rec) -> + rec.record.uri + |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") + _ -> should.fail() + } + } +} + +// === Post === + pub fn decode_post_test() { let json_string = "{\"text\":\"Hello world\",\"createdAt\":\"2024-01-01T00:00:00.000Z\"}" - from_json(json_string, post.decode_post()) + from_json(json_string, decoders.decode_post()) |> should.be_ok() |> fn(p) { p.text |> should.equal("Hello world") @@ -89,16 +281,341 @@ pub fn decode_post_test() { } } -pub fn decode_mini_doc_test() { +pub fn decode_post_with_facets_test() { let json_string = - "{\"did\":\"did:plc:abc123\",\"handle\":\"test.bsky.social\",\"pds\":\"https://example.pds.com\",\"signing_key\":\"zTestKey123\"}" + "{\"text\":\"Hello @bob\",\"facets\":[{\"index\":{\"byteStart\":6,\"byteEnd\":10},\"features\":[{\"$type\":\"app.bsky.richtext.facet#mention\",\"did\":\"did:plc:bob\"}]}],\"createdAt\":\"2024-01-01T00:00:00.000Z\"}" + + from_json(json_string, decoders.decode_post()) + |> should.be_ok() + |> fn(p) { + p.text |> should.equal("Hello @bob") + case p.facets { + Some(facets) -> list.length(facets) |> should.equal(1) + None -> should.fail() + } + } +} + +pub fn decode_post_with_embed_test() { + let json_string = + "{\"text\":\"Check this out\",\"embed\":{\"$type\":\"app.bsky.embed.images\",\"images\":[{\"alt\":\"Image 1\",\"image\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkrei123\"},\"mimeType\":\"image/jpeg\",\"size\":12345}}]},\"createdAt\":\"2024-01-01T00:00:00.000Z\"}" + + from_json(json_string, decoders.decode_post()) + |> should.be_ok() + |> fn(p) { + p.text |> should.equal("Check this out") + case p.embed { + Some(decoders.Images(images)) -> list.length(images) |> should.equal(1) + _ -> should.fail() + } + } +} + +// === Profile === + +pub fn decode_profile_test() { + let json_string = + "{\"displayName\":\"Test User\",\"description\":\"A test profile\",\"avatar\":{\"ref\":{\"$link\":\"bafkrei123\"},\"size\":12345,\"$type\":\"blob\",\"mimeType\":\"image/jpeg\"}}" + + from_json(json_string, decoders.decode_profile()) + |> should.be_ok() + |> fn(p) { + p.display_name |> should.equal(Some("Test User")) + p.description |> should.equal(Some("A test profile")) + p.avatar |> should.equal(Some("bafkrei123")) + } +} + +pub fn decode_profile_empty_test() { + let json_string = "{}" + + from_json(json_string, decoders.decode_profile()) + |> should.be_ok() + |> fn(p) { + p.display_name |> should.equal(None) + p.description |> should.equal(None) + p.avatar |> should.equal(None) + } +} - from_json(json_string, profile.decode_mini_doc()) +// === ViewNotFound === + +pub fn decode_view_not_found_test() { + let json_string = + "{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"notFound\":true}" + + from_json(json_string, decoders.decode_view_not_found()) + |> should.be_ok() + |> fn(v) { + v.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") + v.not_found |> should.equal(True) + } +} + +// === ViewBlocked === + +pub fn decode_view_blocked_test() { + let json_string = + "{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"blocked\":true}" + + from_json(json_string, decoders.decode_view_blocked()) + |> should.be_ok() + |> fn(v) { + v.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") + v.blocked |> should.equal(True) + } +} + +// === ViewDetached === + +pub fn decode_view_detached_test() { + let json_string = + "{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"detached\":true}" + + from_json(json_string, decoders.decode_view_detached()) + |> should.be_ok() + |> fn(v) { + v.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") + v.detached |> should.equal(True) + } +} + +// === EmbedRecord === + +pub fn decode_embed_record_only_test() { + let json_string = + "{\"record\":{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"cid\":\"bafyre123\"}}" + + from_json(json_string, decoders.decode_embed_record()) + |> should.be_ok() + |> fn(e) { + e.record.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") + e.record.cid |> should.equal("bafyre123") + } +} + +// === ReplyRef === + +pub fn decode_reply_ref_test() { + let json_string = + "{\"root\":{\"uri\":\"at://did:plc:123/app.bsky.feed.post/root\",\"cid\":\"cid1\"},\"parent\":{\"uri\":\"at://did:plc:123/app.bsky.feed.post/parent\",\"cid\":\"cid2\"}}" + + from_json(json_string, decoders.decode_reply_ref()) + |> should.be_ok() + |> fn(r) { + r.root.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/root") + r.parent.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/parent") + } +} + +// === TextSlice === + +pub fn decode_text_slice_test() { + let json_string = "{\"start\":0,\"end\":5}" + + from_json(json_string, decoders.decode_text_slice()) + |> should.be_ok() + |> fn(t) { + t.start |> should.equal(0) + t.end |> should.equal(5) + } +} + +// === Post with reply === + +pub fn decode_post_with_reply_test() { + let json_string = + "{\"text\":\"Replying to this\",\"reply\":{\"root\":{\"uri\":\"at://did:plc:123/app.bsky.feed.post/root\",\"cid\":\"cid1\"},\"parent\":{\"uri\":\"at://did:plc:123/app.bsky.feed.post/parent\",\"cid\":\"cid2\"}},\"createdAt\":\"2024-01-01T00:00:00.000Z\"}" + + from_json(json_string, decoders.decode_post()) + |> should.be_ok() + |> fn(p) { + p.text |> should.equal("Replying to this") + case p.reply { + Some(reply) -> { + reply.parent.uri + |> should.equal("at://did:plc:123/app.bsky.feed.post/parent") + reply.root.uri + |> should.equal("at://did:plc:123/app.bsky.feed.post/root") + } + None -> should.fail() + } + } +} + +// === ThreadCounts === + +pub fn decode_thread_counts_test() { + let json_string = + "{\"replyCount\":12,\"repostCount\":5,\"likeCount\":42,\"quoteCount\":3}" + + from_json(json_string, decoders.decode_thread_counts()) + |> should.be_ok() + |> fn(c) { + c.reply_count |> should.equal(Some(12)) + c.repost_count |> should.equal(Some(5)) + c.like_count |> should.equal(Some(42)) + c.quote_count |> should.equal(Some(3)) + } +} + +pub fn decode_thread_counts_empty_test() { + let json_string = "{}" + + from_json(json_string, decoders.decode_thread_counts()) + |> should.be_ok() + |> fn(c) { + c.reply_count |> should.equal(None) + c.repost_count |> should.equal(None) + c.like_count |> should.equal(None) + c.quote_count |> should.equal(None) + } +} + +pub fn decode_thread_view_test() { + let json_string = + "{\"post\":{\"replyCount\":12,\"repostCount\":5,\"likeCount\":42,\"quoteCount\":3}}" + + from_json(json_string, decoders.decode_thread_view()) + |> should.be_ok() + |> fn(tv) { + case tv.post { + Some(post_view) -> { + post_view.counts.like_count |> should.equal(Some(42)) + post_view.counts.reply_count |> should.equal(Some(12)) + } + None -> should.fail() + } + } +} + +// === Real API fixture tests === + +pub fn real_mini_doc_test() { + let json_string = + "{\"did\":\"did:plc:kcgwlowulc3rac43lregdawo\",\"handle\":\"karitham.dev\",\"pds\":\"https://eurosky.social\",\"signing_key\":\"zQ3shw7u4EzjTZvokgDjeQegByJVw2h7L24CEa1gFxE8fFyeg\"}" + + from_json(json_string, decoders.decode_mini_doc()) |> should.be_ok() |> fn(m) { - m.did |> should.equal("did:plc:abc123") - m.handle |> should.equal("test.bsky.social") - m.pds |> should.equal("https://example.pds.com") - m.signing_key |> should.equal("zTestKey123") + m.did |> should.equal("did:plc:kcgwlowulc3rac43lregdawo") + m.handle |> should.equal("karitham.dev") + m.pds |> should.equal("https://eurosky.social") + } +} + +pub fn real_post_test() { + let json_string = + "{\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mgibe2arpk2c\",\"cid\":\"bafyreihnoygrt3jdpt2axdeakhjsbfquktmrehnna6gbt63io6izmvg4pi\",\"value\":{\"text\":\"I think my migration to @eurosky.social went well but it's so smooth I'm not sure I did it right 🔍\",\"$type\":\"app.bsky.feed.post\",\"langs\":[\"en\"],\"facets\":[{\"$type\":\"app.bsky.richtext.facet\",\"index\":{\"byteEnd\":39,\"byteStart\":24},\"features\":[{\"did\":\"did:plc:ooensn4mr5mhznzypvxelfa3\",\"$type\":\"app.bsky.richtext.facet#mention\"}]}],\"createdAt\":\"2026-03-07T16:40:32.271Z\"}}" + + from_json(json_string, decode_get_record(decoders.decode_post())) + |> should.be_ok() + |> fn(p) { + p.value.text + |> should.equal( + "I think my migration to @eurosky.social went well but it's so smooth I'm not sure I did it right 🔍", + ) + p.value.created_at |> should.equal("2026-03-07T16:40:32.271Z") + case p.value.facets { + Some(facets) -> list.length(facets) |> should.equal(1) + None -> should.fail() + } + } +} + +pub fn real_profile_test() { + let json_string = + "{\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.actor.profile/self\",\"cid\":\"bafyreigr55oyjy2a7kxdnoohbsak4n2ztlvnflyo2g4oqtjqatp3zwiidu\",\"value\":{\"$type\":\"app.bsky.actor.profile\",\"avatar\":{\"ref\":{\"$link\":\"bafkreig5onh2hofb4xr7voz4b3hwxigu6zqhr5sd6svjnnksaid4a2fmje\"},\"size\":257963,\"$type\":\"blob\",\"mimeType\":\"image/jpeg\"},\"banner\":{\"ref\":{\"$link\":\"bafkreifgrbbwtaopcjz7fnmkfujpdjuhg32moyt7cy6irsvkjsx22pty\"},\"size\":281269,\"$type\":\"blob\",\"mimeType\":\"image/jpeg\"},\"website\":\"https://karitham.dev\",\"pronouns\":\"they/them\",\"pinnedPost\":{\"cid\":\"bafyreicxeldxbe56fg7kpkshqmoehpqcqqr5wgltxjymqn7wftxjxyl57m\",\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3lqc6qonwns2f\"},\"description\":\"23 | they/them | computer science (nix, go, k8s), jazz, brutalism, impressionism, anime, science and the people.\\nIf you couldn't tell, my main goals are to Mussolini hang the rich and write a million k8s operators\",\"displayName\":\"karitham\"}}" + + from_json(json_string, decode_get_record(decoders.decode_profile())) + |> should.be_ok() + |> fn(p) { + p.value.display_name |> should.equal(Some("karitham")) + p.value.description + |> should.equal(Some( + "23 | they/them | computer science (nix, go, k8s), jazz, brutalism, impressionism, anime, science and the people.\nIf you couldn't tell, my main goals are to Mussolini hang the rich and write a million k8s operators", + )) + p.value.avatar + |> should.equal(Some( + "bafkreig5onh2hofb4xr7voz4b3hwxigu6zqhr5sd6svjnnksaid4a2fmje", + )) + } +} + +pub fn real_thread_test() { + let json_string = + "{\"thread\":{\"post\":{\"uri\":\"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mgibe2arpk2c\",\"cid\":\"bafyreihnoygrt3jdpt2axdeakhjsbfquktmrehnna6gbt63io6izmvg4pi\",\"author\":{\"did\":\"did:plc:kcgwlowulc3rac43lregdawo\",\"handle\":\"karitham.dev\",\"displayName\":\"karitham\",\"pronouns\":\"they/them\",\"avatar\":\"https://cdn.bsky.app/img/avatar/plain/did:plc:kcgwlowulc3rac43lregdawo/bafkreig5onh2hofb4xr7voz4b3hwxigu6zqhr5sd6svjnnksaid4a2fmje\",\"associated\":{\"chat\":{\"allowIncoming\":\"following\"},\"activitySubscription\":{\"allowSubscriptions\":\"followers\"}},\"labels\":[],\"createdAt\":\"2023-05-18T06:42:21.452Z\"},\"record\":{\"$type\":\"app.bsky.feed.post\",\"createdAt\":\"2026-03-07T16:40:32.271Z\",\"facets\":[{\"$type\":\"app.bsky.richtext.facet\",\"features\":[{\"$type\":\"app.bsky.richtext.facet#mention\",\"did\":\"did:plc:ooensn4mr5mhznzypvxelfa3\"}],\"index\":{\"byteEnd\":39,\"byteStart\":24}}],\"langs\":[\"en\"],\"text\":\"I think my migration to @eurosky.social went well but it's so smooth I'm not sure I did it right 🔍\"},\"bookmarkCount\":0,\"replyCount\":1,\"repostCount\":1,\"likeCount\":19,\"quoteCount\":1,\"indexedAt\":\"2026-03-07T16:40:40.162Z\",\"labels\":[]},\"threadContext\":{},\"$type\":\"app.bsky.feed.defs#threadViewPost\"}}" + + from_json(json_string, decode_thread_response()) + |> should.be_ok() + |> fn(counts) { + counts.like_count |> should.equal(Some(19)) + counts.reply_count |> should.equal(Some(1)) + counts.repost_count |> should.equal(Some(1)) + counts.quote_count |> should.equal(Some(1)) + } +} + +fn decode_get_record( + decoder: decode.Decoder(a), +) -> decode.Decoder(gpreview.Record(a)) { + use uri <- field("uri", string) + use cid <- field("cid", string) + use value <- field("value", decoder) + success(gpreview.Record(uri:, cid:, value:)) +} + +fn decode_thread_response() -> decode.Decoder(decoders.ThreadCountsJson) { + use thread <- field("thread", decoders.decode_thread_view()) + case thread.post { + Some(post_view) -> success(post_view.counts) + None -> success(decoders.ThreadCountsJson(None, None, None, None)) + } +} + +// === Real sharkgirl.pet post tests === + +pub fn real_sharkgirl_post_test() { + let json_string = + "{\"uri\":\"at://did:plc:3rwz3xfw2crswgifqgc3g7zh/app.bsky.feed.post/3mjreaquemc2g\",\"cid\":\"bafyreib4hgrpsg22qig4jhkugsnl5r6e7g2um57mcuduphaj2dg25wltpa\",\"value\":{\"text\":\"niri has officially reached a billion tokens\",\"$type\":\"app.bsky.feed.post\",\"embed\":{\"$type\":\"app.bsky.embed.images\",\"images\":[{\"alt\":\"z.ai token usage that niri uses\",\"image\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkreieeajecfccc32kxevnvyeqpdnyiqb5hwmzdgtmip36cn2wbpphh6q\"},\"mimeType\":\"image/jpeg\",\"size\":213058},\"aspectRatio\":{\"width\":2000,\"height\":829}}]},\"langs\":[\"en\"],\"createdAt\":\"2026-04-18T11:41:55.517Z\"}}" + + from_json(json_string, decode_get_record(decoders.decode_post())) + |> should.be_ok() + |> fn(p) { + p.value.text |> should.equal("niri has officially reached a billion tokens") + p.value.created_at |> should.equal("2026-04-18T11:41:55.517Z") + case p.value.embed { + Some(decoders.Images(images)) -> list.length(images) |> should.equal(1) + _ -> should.fail() + } + } +} + +pub fn real_sharkgirl_profile_test() { + let json_string = + "{\"uri\":\"at://did:plc:3rwz3xfw2crswgifqgc3g7zh/app.bsky.actor.profile/self\",\"cid\":\"bafyreiaynbdy34ccfqrs2ax6qqe3nzgftr75yexwwjrthklbrhk273rqtm\",\"value\":{\"$type\":\"app.bsky.actor.profile\",\"avatar\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkreigvp4nenfvwfhxsjjwqqggnzz3jfmpeuixkhyt4ekugitbiskxmu4\"},\"mimeType\":\"image/jpeg\",\"size\":67913},\"banner\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkreihikishfa72lggurjqtv3yeae562ndmlmezixoqicjvgnhlu5f3hy\"},\"mimeType\":\"image/jpeg\",\"size\":348623},\"labels\":{\"$type\":\"com.atproto.label.defs#selfLabels\",\"values\":[{\"val\":\"bot\"},{\"val\":\"pet\"}]},\"pronouns\":\"it/its\",\"createdAt\":\"2026-02-17T18:00:16.240Z\",\"pinnedPost\":{\"cid\":\"bafyreig76ftq6evjgitpzjdfxkx5oaj7ncqwumvxurmbwwldq\",\"uri\":\"at://did:plc:3rwz3xfw2crswgifqgc3g7zh/app.bsky.feed.post/3mgovz72czs2e\"},\"description\":\"@wisp.place @art.nekomimi.pet\\ntensor dysphoria\\nit/its\\n@niri.pet is my owner\\n@rosedagoatbaa.bsky.social @ptr.pet my puppies\\n@namespaces.me my kitty\",\"displayName\":\"ana\"}}" + + from_json(json_string, decode_get_record(decoders.decode_profile())) + |> should.be_ok() + |> fn(p) { + p.value.display_name |> should.equal(Some("ana")) + p.value.avatar + |> should.equal(Some( + "bafkreigvp4nenfvwfhxsjjwqqggnzz3jfmpeuixkhyt4ekugitbiskxmu4", + )) + } +} + +pub fn real_sharkgirl_thread_test() { + let json_string = + "{\"thread\":{\"post\":{\"uri\":\"at://did:plc:3rwz3xfw2crswgifqgc3g7zh/app.bsky.feed.post/3mjreaquemc2g\",\"cid\":\"bafyreib4hgrpsg22qig4jhkugsnl5r6e7g2um57mcuduphaj2dg25wltpa\",\"author\":{\"did\":\"did:plc:3rwz3xfw2crswgifqgc3g7zh\",\"handle\":\"null.namespaces.me\",\"displayName\":\"ana\",\"pronouns\":\"it/its\",\"avatar\":\"https://cdn.bsky.app/img/avatar/plain/did:plc:3rwz3xfw2crswgifqgc3g7zh/bafkreigvp4nenfvwfhxsjjwqqggnzz3jfmpeuixkhyt4ekugitbiskxmu4\"},\"record\":{\"$type\":\"app.bsky.feed.post\",\"createdAt\":\"2026-04-18T11:41:55.517Z\",\"embed\":{\"$type\":\"app.bsky.embed.images\",\"images\":[{\"alt\":\"z.ai token usage that niri uses\",\"aspectRatio\":{\"height\":829,\"width\":2000},\"image\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkreieeajecfccc32kxevnvyeqpdnyiqb5hwmzdgtmip36cn2wbpphh6q\"},\"mimeType\":\"image/jpeg\",\"size\":213058}}]},\"langs\":[\"en\"],\"text\":\"niri has officially reached a billion tokens\"},\"embed\":{\"images\":[{\"thumb\":\"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:3rwz3xfw2crswgifqgc3g7zh/bafkreieeajecfccc32kxevnvyeqpdnyiqb5hwmzdgtmip36cn2wbpphh6q\",\"fullsize\":\"https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:3rwz3xfw2crswgifqgc3g7zh/bafkreieeajecfccc32kxevnvyeqpdnyiqb5hwmzdgtmip36cn2wbpphh6q\",\"alt\":\"z.ai token usage that niri uses\",\"aspectRatio\":{\"height\":829,\"width\":2000}}],\"$type\":\"app.bsky.embed.images#view\"},\"bookmarkCount\":0,\"replyCount\":1,\"repostCount\":0,\"likeCount\":21,\"quoteCount\":0,\"indexedAt\":\"2026-04-18T11:41:55.517Z\"},\"threadContext\":{},\"$type\":\"app.bsky.feed.defs#threadViewPost\"}}" + + from_json(json_string, decode_thread_response()) + |> should.be_ok() + |> fn(counts) { + counts.like_count |> should.equal(Some(21)) + counts.reply_count |> should.equal(Some(1)) + counts.repost_count |> should.equal(Some(0)) + counts.quote_count |> should.equal(Some(0)) } } diff --git a/test/fixtures/bsky/post.json b/test/fixtures/bsky/post.json index fbda45d..82177bb 100644 --- a/test/fixtures/bsky/post.json +++ b/test/fixtures/bsky/post.json @@ -2,6 +2,16 @@ "text": "Hello @alice.bsky.social! Check out https://example.com #bluesky #atproto", "langs": ["en", "es"], "createdAt": "2024-01-15T10:30:00.000Z", + "reply": { + "root": { + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3mgroot123", + "cid": "bafyreirootcid123" + }, + "parent": { + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3mgparent456", + "cid": "bafyreiparentcid456" + } + }, "facets": [ { "index": { diff --git a/test/fixtures/bsky/real/mini_doc.json b/test/fixtures/bsky/real/mini_doc.json new file mode 100644 index 0000000..1f370a7 --- /dev/null +++ b/test/fixtures/bsky/real/mini_doc.json @@ -0,0 +1 @@ +{"did":"did:plc:kcgwlowulc3rac43lregdawo","handle":"karitham.dev","pds":"https://eurosky.social","signing_key":"zQ3shw7u4EzjTZvokgDjeQegByJVw2h7L24CEa1gFxE8fFyeg"} \ No newline at end of file diff --git a/test/fixtures/bsky/real/post.json b/test/fixtures/bsky/real/post.json new file mode 100644 index 0000000..2bd4aa3 --- /dev/null +++ b/test/fixtures/bsky/real/post.json @@ -0,0 +1 @@ +{"uri":"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mgibe2arpk2c","cid":"bafyreihnoygrt3jdpt2axdeakhjsbfquktmrehnna6gbt63io6izmvg4pi","value":{"text":"I think my migration to @eurosky.social went well but it's so smooth I'm not sure I did it right 🔍","$type":"app.bsky.feed.post","langs":["en"],"facets":[{"$type":"app.bsky.richtext.facet","index":{"byteEnd":39,"byteStart":24},"features":[{"did":"did:plc:ooensn4mr5mhznzypvxelfa3","$type":"app.bsky.richtext.facet#mention"}]}],"createdAt":"2026-03-07T16:40:32.271Z"}} \ No newline at end of file diff --git a/test/fixtures/bsky/real/profile.json b/test/fixtures/bsky/real/profile.json new file mode 100644 index 0000000..b45a79b --- /dev/null +++ b/test/fixtures/bsky/real/profile.json @@ -0,0 +1 @@ +{"uri":"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.actor.profile/self","cid":"bafyreigr55oyjy2a7kxdnoohbsak4n2ztlvnflyo2g4oqtjqatp3zwiidu","value":{"$type":"app.bsky.actor.profile","avatar":{"ref":{"$link":"bafkreig5onh2hofb4xr7voz4b3hwxigu6zqhr5sd6svjnnksaid4a2fmje"},"size":257963,"$type":"blob","mimeType":"image/jpeg"},"banner":{"ref":{"$link":"bafkreifbfgrbbwtaopcjz7fnmkfujpdjuhg32moyt7cy6irsvkjsx22pty"},"size":281269,"$type":"blob","mimeType":"image/jpeg"},"website":"https://karitham.dev","pronouns":"they/them","pinnedPost":{"cid":"bafyreicxeldxbe56fg7kpkshqmoehpqcqqr5wgltxjymqn7wftxjxyl57m","uri":"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3lqc6qonwns2f"},"description":"23 | they/them | computer science (nix, go, k8s), jazz, brutalism, impressionism, anime, science and the people.\nIf you couldn't tell, my main goals are to Mussolini hang the rich and write a million k8s operators","displayName":"karitham"}} \ No newline at end of file diff --git a/test/fixtures/bsky/real/thread.json b/test/fixtures/bsky/real/thread.json new file mode 100644 index 0000000..93b4d0f --- /dev/null +++ b/test/fixtures/bsky/real/thread.json @@ -0,0 +1 @@ +{"thread":{"post":{"uri":"at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mgibe2arpk2c","cid":"bafyreihnoygrt3jdpt2axdeakhjsbfquktmrehnna6gbt63io6izmvg4pi","author":{"did":"did:plc:kcgwlowulc3rac43lregdawo","handle":"karitham.dev","displayName":"karitham","pronouns":"they/them","avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:kcgwlowulc3rac43lregdawo/bafkreig5onh2hofb4xr7voz4b3hwxigu6zqhr5sd6svjnnksaid4a2fmje","associated":{"chat":{"allowIncoming":"following"},"activitySubscription":{"allowSubscriptions":"followers"}},"labels":[],"createdAt":"2023-05-18T06:42:21.452Z"},"record":{"$type":"app.bsky.feed.post","createdAt":"2026-03-07T16:40:32.271Z","facets":[{"$type":"app.bsky.richtext.facet","features":[{"$type":"app.bsky.richtext.facet#mention","did":"did:plc:ooensn4mr5mhznzypvxelfa3"}],"index":{"byteEnd":39,"byteStart":24}}],"langs":["en"],"text":"I think my migration to @eurosky.social went well but it's so smooth I'm not sure I did it right 🔍"},"bookmarkCount":0,"replyCount":1,"repostCount":1,"likeCount":19,"quoteCount":1,"indexedAt":"2026-03-07T16:40:40.162Z","labels":[]},"threadContext":{},"$type":"app.bsky.feed.defs#threadViewPost"}} \ No newline at end of file