small constellation + pds based little profile viewer karitham.tngl.io/gpreview?user=karitham.dev
gleam bsky-profile
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

decoders

+1559 -325
+153
lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "label": { 6 + "type": "object", 7 + "description": "Metadata tag on an atproto resource (eg, repo or record).", 8 + "required": ["src", "uri", "val", "cts"], 9 + "properties": { 10 + "ver": { 11 + "type": "integer", 12 + "description": "The AT Protocol version of the label object." 13 + }, 14 + "src": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the actor who created this label." 18 + }, 19 + "uri": { 20 + "type": "string", 21 + "format": "uri", 22 + "description": "AT URI of the record, repository (account), or other resource that this label applies to." 23 + }, 24 + "cid": { 25 + "type": "string", 26 + "format": "cid", 27 + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 28 + }, 29 + "val": { 30 + "type": "string", 31 + "maxLength": 128, 32 + "description": "The short string name of the value or type of this label." 33 + }, 34 + "neg": { 35 + "type": "boolean", 36 + "description": "If true, this is a negation label, overwriting a previous label." 37 + }, 38 + "cts": { 39 + "type": "string", 40 + "format": "datetime", 41 + "description": "Timestamp when this label was created." 42 + }, 43 + "exp": { 44 + "type": "string", 45 + "format": "datetime", 46 + "description": "Timestamp at which this label expires (no longer applies)." 47 + }, 48 + "sig": { 49 + "type": "bytes", 50 + "description": "Signature of dag-cbor encoded label." 51 + } 52 + } 53 + }, 54 + "selfLabels": { 55 + "type": "object", 56 + "description": "Metadata tags on an atproto record, published by the author within the record.", 57 + "required": ["values"], 58 + "properties": { 59 + "values": { 60 + "type": "array", 61 + "items": { "type": "ref", "ref": "#selfLabel" }, 62 + "maxLength": 10 63 + } 64 + } 65 + }, 66 + "selfLabel": { 67 + "type": "object", 68 + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.", 69 + "required": ["val"], 70 + "properties": { 71 + "val": { 72 + "type": "string", 73 + "maxLength": 128, 74 + "description": "The short string name of the value or type of this label." 75 + } 76 + } 77 + }, 78 + "labelValueDefinition": { 79 + "type": "object", 80 + "description": "Declares a label value and its expected interpretations and behaviors.", 81 + "required": ["identifier", "severity", "blurs", "locales"], 82 + "properties": { 83 + "identifier": { 84 + "type": "string", 85 + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 86 + "maxLength": 100, 87 + "maxGraphemes": 100 88 + }, 89 + "severity": { 90 + "type": "string", 91 + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 92 + "knownValues": ["inform", "alert", "none"] 93 + }, 94 + "blurs": { 95 + "type": "string", 96 + "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.", 97 + "knownValues": ["content", "media", "none"] 98 + }, 99 + "defaultSetting": { 100 + "type": "string", 101 + "description": "The default setting for this label.", 102 + "knownValues": ["ignore", "warn", "hide"], 103 + "default": "warn" 104 + }, 105 + "adultOnly": { 106 + "type": "boolean", 107 + "description": "Does the user need to have adult content enabled in order to configure this label?" 108 + }, 109 + "locales": { 110 + "type": "array", 111 + "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } 112 + } 113 + } 114 + }, 115 + "labelValueDefinitionStrings": { 116 + "type": "object", 117 + "description": "Strings which describe the label in the UI, localized into a specific language.", 118 + "required": ["lang", "name", "description"], 119 + "properties": { 120 + "lang": { 121 + "type": "string", 122 + "description": "The code of the language these strings are written in.", 123 + "format": "language" 124 + }, 125 + "name": { 126 + "type": "string", 127 + "description": "A short human-readable name for the label.", 128 + "maxGraphemes": 64, 129 + "maxLength": 640 130 + }, 131 + "description": { 132 + "type": "string", 133 + "description": "A longer description of what the label means and why it might be applied.", 134 + "maxGraphemes": 10000, 135 + "maxLength": 100000 136 + } 137 + } 138 + }, 139 + "labelValue": { 140 + "type": "string", 141 + "knownValues": [ 142 + "!hide", 143 + "!warn", 144 + "!no-unauthenticated", 145 + "porn", 146 + "sexual", 147 + "nudity", 148 + "graphic-media", 149 + "bot" 150 + ] 151 + } 152 + } 153 + }
+15
lexicons/com/atproto/repo/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": ["uri", "cid"], 9 + "properties": { 10 + "uri": { "type": "string", "format": "at-uri" }, 11 + "cid": { "type": "string", "format": "cid" } 12 + } 13 + } 14 + } 15 + }
-157
src/app/post.gleam
··· 1 - import gleam/dynamic/decode.{field, int, list, string, success} 2 - import gleam/option.{type Option, None} 3 - 4 - pub type StrongRef { 5 - StrongRef(uri: String, cid: String) 6 - } 7 - 8 - pub fn decode_strong_ref() -> decode.Decoder(StrongRef) { 9 - use uri <- field("uri", string) 10 - use cid <- field("cid", string) 11 - success(StrongRef(uri:, cid:)) 12 - } 13 - 14 - pub type Label { 15 - Label(val: String) 16 - } 17 - 18 - pub fn decode_label() -> decode.Decoder(Label) { 19 - use val <- field("val", string) 20 - success(Label(val:)) 21 - } 22 - 23 - pub type ByteSlice { 24 - ByteSlice(byte_start: Int, byte_end: Int) 25 - } 26 - 27 - pub fn decode_byte_slice() -> decode.Decoder(ByteSlice) { 28 - use byte_start <- field("byteStart", int) 29 - use byte_end <- field("byteEnd", int) 30 - success(ByteSlice(byte_start:, byte_end:)) 31 - } 32 - 33 - pub type FacetFeature { 34 - Mention(did: String) 35 - Link(uri: String) 36 - Tag(tag: String) 37 - } 38 - 39 - fn decode_facet_feature() -> decode.Decoder(FacetFeature) { 40 - use type_ <- field("$type", string) 41 - case type_ { 42 - "app.bsky.richtext.facet#mention" -> { 43 - use did <- field("did", string) 44 - success(Mention(did:)) 45 - } 46 - "app.bsky.richtext.facet#link" -> { 47 - use uri <- field("uri", string) 48 - success(Link(uri:)) 49 - } 50 - "app.bsky.richtext.facet#tag" -> { 51 - use tag <- field("tag", string) 52 - success(Tag(tag:)) 53 - } 54 - _ -> decode.failure(Mention(""), expected: "FacetFeature") 55 - } 56 - } 57 - 58 - pub type Facet { 59 - Facet(index: ByteSlice, features: List(FacetFeature)) 60 - } 61 - 62 - pub fn decode_facet() -> decode.Decoder(Facet) { 63 - use index <- field("index", decode_byte_slice()) 64 - use features <- field("features", list(of: decode_facet_feature())) 65 - success(Facet(index:, features:)) 66 - } 67 - 68 - pub type AspectRatio { 69 - AspectRatio(width: Int, height: Int) 70 - } 71 - 72 - pub fn decode_aspect_ratio() -> decode.Decoder(AspectRatio) { 73 - use width <- field("width", int) 74 - use height <- field("height", int) 75 - success(AspectRatio(width:, height:)) 76 - } 77 - 78 - pub type Image { 79 - Image( 80 - alt: String, 81 - thumb: Option(String), 82 - fullsize: Option(String), 83 - aspect_ratio: Option(AspectRatio), 84 - ) 85 - } 86 - 87 - pub fn decode_image() -> decode.Decoder(Image) { 88 - use alt <- field("alt", string) 89 - success(Image(alt:, thumb: None, fullsize: None, aspect_ratio: None)) 90 - } 91 - 92 - pub type External { 93 - External( 94 - uri: String, 95 - title: String, 96 - description: String, 97 - thumb: Option(String), 98 - ) 99 - } 100 - 101 - pub fn decode_external() -> decode.Decoder(External) { 102 - use uri <- field("uri", string) 103 - use title <- field("title", string) 104 - use description <- field("description", string) 105 - success(External(uri:, title:, description:, thumb: None)) 106 - } 107 - 108 - pub type Embed { 109 - Images(images: List(Image)) 110 - ExternalLink(external: External) 111 - Record(StrongRef) 112 - } 113 - 114 - pub fn decode_embed() -> decode.Decoder(Embed) { 115 - use type_ <- field("$type", string) 116 - case type_ { 117 - "app.bsky.embed.images" -> { 118 - use images <- field("images", list(of: decode_image())) 119 - success(Images(images:)) 120 - } 121 - "app.bsky.embed.external" -> { 122 - use external <- field("external", decode_external()) 123 - success(ExternalLink(external:)) 124 - } 125 - "app.bsky.embed.record" -> { 126 - use record <- field("record", decode_strong_ref()) 127 - success(Record(record)) 128 - } 129 - _ -> decode.failure(Images(images: []), expected: "Embed type") 130 - } 131 - } 132 - 133 - pub type Post { 134 - Post( 135 - text: String, 136 - facets: List(Facet), 137 - embed: Option(Embed), 138 - langs: List(String), 139 - labels: List(Label), 140 - tags: List(String), 141 - created_at: String, 142 - ) 143 - } 144 - 145 - pub fn decode_post() -> decode.Decoder(Post) { 146 - use text <- field("text", string) 147 - use created_at <- field("createdAt", string) 148 - success(Post( 149 - text:, 150 - facets: [], 151 - embed: None, 152 - langs: [], 153 - labels: [], 154 - tags: [], 155 - created_at:, 156 - )) 157 - }
-60
src/app/profile.gleam
··· 1 - import gleam/dynamic/decode.{at, field, map, optional, string, success} 2 - import gleam/option.{type Option} 3 - 4 - pub type BlobRef { 5 - BlobRef(link: String) 6 - } 7 - 8 - pub fn decode_blob_ref() -> decode.Decoder(BlobRef) { 9 - at(["ref", "$link"], string) 10 - |> map(BlobRef) 11 - } 12 - 13 - pub fn blob_ref_to_url(pds_host: String, did: String, blob: BlobRef) -> String { 14 - pds_host 15 - <> "/xrpc/com.atproto.sync.getBlob?did=" 16 - <> did 17 - <> "&cid=" 18 - <> blob.link 19 - } 20 - 21 - pub type StrongRef { 22 - StrongRef(uri: String, cid: String) 23 - } 24 - 25 - pub fn decode_strong_ref() -> decode.Decoder(StrongRef) { 26 - use uri <- field("uri", string) 27 - use cid <- field("cid", string) 28 - success(StrongRef(uri:, cid:)) 29 - } 30 - 31 - pub type Label { 32 - Label(val: String) 33 - } 34 - 35 - pub fn decode_label() -> decode.Decoder(Label) { 36 - use val <- field("val", string) 37 - success(Label(val:)) 38 - } 39 - 40 - pub type Profile { 41 - Profile(display_name: Option(String), avatar: Option(BlobRef)) 42 - } 43 - 44 - pub type MiniDoc { 45 - MiniDoc(did: String, handle: String, pds: String, signing_key: String) 46 - } 47 - 48 - pub fn decode_mini_doc() -> decode.Decoder(MiniDoc) { 49 - use did <- field("did", string) 50 - use handle <- field("handle", string) 51 - use pds <- field("pds", string) 52 - use signing_key <- field("signing_key", string) 53 - success(MiniDoc(did:, handle:, pds:, signing_key:)) 54 - } 55 - 56 - pub fn decode_profile() -> decode.Decoder(Profile) { 57 - use display_name <- field("displayName", optional(string)) 58 - use avatar <- field("avatar", optional(decode_blob_ref())) 59 - success(Profile(display_name:, avatar:)) 60 - }
+470
src/bsky/decoders.gleam
··· 1 + import gleam/dynamic as dyn 2 + import gleam/dynamic/decode.{ 3 + at, bool, dynamic as decode_dyn, failure, field, int, list, map, one_of, 4 + optional_field, string, success, 5 + } 6 + import gleam/option.{type Option, None, Some} 7 + 8 + // === Simple decoders === 9 + 10 + pub type StrongRefJson { 11 + StrongRefJson(uri: String, cid: String) 12 + } 13 + 14 + pub fn decode_strong_ref() -> decode.Decoder(StrongRefJson) { 15 + use uri <- field("uri", string) 16 + use cid <- field("cid", string) 17 + success(StrongRefJson(uri:, cid:)) 18 + } 19 + 20 + pub type LabelJson { 21 + LabelJson(val: String) 22 + } 23 + 24 + pub fn decode_label() -> decode.Decoder(LabelJson) { 25 + use val <- field("val", string) 26 + success(LabelJson(val:)) 27 + } 28 + 29 + pub type ByteSliceJson { 30 + ByteSliceJson(byte_start: Int, byte_end: Int) 31 + } 32 + 33 + pub fn decode_byte_slice() -> decode.Decoder(ByteSliceJson) { 34 + use byte_start <- field("byteStart", int) 35 + use byte_end <- field("byteEnd", int) 36 + success(ByteSliceJson(byte_start:, byte_end:)) 37 + } 38 + 39 + pub type MentionJson { 40 + MentionJson(did: String) 41 + } 42 + 43 + pub fn decode_mention() -> decode.Decoder(MentionJson) { 44 + use did <- field("did", string) 45 + success(MentionJson(did:)) 46 + } 47 + 48 + pub type LinkJson { 49 + LinkJson(uri: String) 50 + } 51 + 52 + pub fn decode_link() -> decode.Decoder(LinkJson) { 53 + use uri <- field("uri", string) 54 + success(LinkJson(uri:)) 55 + } 56 + 57 + pub type TagJson { 58 + TagJson(tag: String) 59 + } 60 + 61 + pub fn decode_tag() -> decode.Decoder(TagJson) { 62 + use tag <- field("tag", string) 63 + success(TagJson(tag:)) 64 + } 65 + 66 + pub type AspectRatioJson { 67 + AspectRatioJson(width: Int, height: Int) 68 + } 69 + 70 + pub fn decode_aspect_ratio() -> decode.Decoder(AspectRatioJson) { 71 + use width <- field("width", int) 72 + use height <- field("height", int) 73 + success(AspectRatioJson(width:, height:)) 74 + } 75 + 76 + fn decode_blob_ref() -> decode.Decoder(String) { 77 + at(["ref", "$link"], string) 78 + } 79 + 80 + /// Decodes an avatar field that may be either a plain string URL 81 + /// (as in ProfileViewBasic) or a blob object with ref.$link 82 + /// (as in getRecord responses for app.bsky.actor.profile). 83 + fn decode_avatar() -> decode.Decoder(String) { 84 + let blob_decoder = at(["ref", "$link"], string) 85 + one_of(string, [blob_decoder]) 86 + } 87 + 88 + pub type ProfileJson { 89 + ProfileJson( 90 + display_name: Option(String), 91 + description: Option(String), 92 + avatar: Option(String), 93 + ) 94 + } 95 + 96 + pub fn decode_profile() -> decode.Decoder(ProfileJson) { 97 + use display_name <- optional_field("displayName", None, map(string, Some)) 98 + use description <- optional_field("description", None, map(string, Some)) 99 + use avatar <- optional_field("avatar", None, map(decode_avatar(), Some)) 100 + success(ProfileJson(display_name:, description:, avatar:)) 101 + } 102 + 103 + pub type ImageJson { 104 + ImageJson(alt: String, ref: String, aspect_ratio: Option(AspectRatioJson)) 105 + } 106 + 107 + pub fn decode_image() -> decode.Decoder(ImageJson) { 108 + use alt <- field("alt", string) 109 + use ref_field <- field("image", decode_blob_ref()) 110 + use aspect_ratio <- optional_field( 111 + "aspectRatio", 112 + None, 113 + map(decode_aspect_ratio(), Some), 114 + ) 115 + success(ImageJson(alt:, ref: ref_field, aspect_ratio:)) 116 + } 117 + 118 + pub type ViewImageJson { 119 + ViewImageJson( 120 + thumb: String, 121 + fullsize: String, 122 + alt: String, 123 + aspect_ratio: Option(AspectRatioJson), 124 + ) 125 + } 126 + 127 + pub fn decode_view_image() -> decode.Decoder(ViewImageJson) { 128 + use thumb <- field("thumb", string) 129 + use fullsize <- field("fullsize", string) 130 + use alt <- field("alt", string) 131 + use aspect_ratio <- optional_field( 132 + "aspectRatio", 133 + None, 134 + map(decode_aspect_ratio(), Some), 135 + ) 136 + success(ViewImageJson(thumb:, fullsize:, alt:, aspect_ratio:)) 137 + } 138 + 139 + pub type ExternalJson { 140 + ExternalJson(uri: String, title: String, description: String) 141 + } 142 + 143 + pub fn decode_external() -> decode.Decoder(ExternalJson) { 144 + use uri <- field("uri", string) 145 + use title <- field("title", string) 146 + use description <- field("description", string) 147 + success(ExternalJson(uri:, title:, description:)) 148 + } 149 + 150 + pub type ViewExternalJson { 151 + ViewExternalJson( 152 + uri: String, 153 + title: String, 154 + description: String, 155 + thumb: Option(String), 156 + ) 157 + } 158 + 159 + pub fn decode_view_external() -> decode.Decoder(ViewExternalJson) { 160 + use uri <- field("uri", string) 161 + use title <- field("title", string) 162 + use description <- field("description", string) 163 + use thumb <- optional_field("thumb", None, map(string, Some)) 164 + success(ViewExternalJson(uri:, title:, description:, thumb:)) 165 + } 166 + 167 + pub type EmbedRecordJson { 168 + EmbedRecordJson(record: StrongRefJson) 169 + } 170 + 171 + pub fn decode_embed_record() -> decode.Decoder(EmbedRecordJson) { 172 + use record <- field("record", decode_strong_ref()) 173 + success(EmbedRecordJson(record:)) 174 + } 175 + 176 + pub type ViewNotFoundJson { 177 + ViewNotFoundJson(uri: String, not_found: Bool) 178 + } 179 + 180 + pub fn decode_view_not_found() -> decode.Decoder(ViewNotFoundJson) { 181 + use uri <- field("uri", string) 182 + use not_found <- field("notFound", bool) 183 + success(ViewNotFoundJson(uri:, not_found:)) 184 + } 185 + 186 + pub type ViewBlockedJson { 187 + ViewBlockedJson(uri: String, blocked: Bool) 188 + } 189 + 190 + pub fn decode_view_blocked() -> decode.Decoder(ViewBlockedJson) { 191 + use uri <- field("uri", string) 192 + use blocked <- field("blocked", bool) 193 + success(ViewBlockedJson(uri:, blocked:)) 194 + } 195 + 196 + pub type ViewDetachedJson { 197 + ViewDetachedJson(uri: String, detached: Bool) 198 + } 199 + 200 + pub fn decode_view_detached() -> decode.Decoder(ViewDetachedJson) { 201 + use uri <- field("uri", string) 202 + use detached <- field("detached", bool) 203 + success(ViewDetachedJson(uri:, detached:)) 204 + } 205 + 206 + pub type ReplyRefJson { 207 + ReplyRefJson(root: StrongRefJson, parent: StrongRefJson) 208 + } 209 + 210 + pub fn decode_reply_ref() -> decode.Decoder(ReplyRefJson) { 211 + use root <- field("root", decode_strong_ref()) 212 + use parent <- field("parent", decode_strong_ref()) 213 + success(ReplyRefJson(root:, parent:)) 214 + } 215 + 216 + pub type TextSliceJson { 217 + TextSliceJson(start: Int, end: Int) 218 + } 219 + 220 + pub fn decode_text_slice() -> decode.Decoder(TextSliceJson) { 221 + use start <- field("start", int) 222 + use end <- field("end", int) 223 + success(TextSliceJson(start:, end:)) 224 + } 225 + 226 + pub type MiniDocJson { 227 + MiniDocJson(did: String, handle: String, pds: String, signing_key: String) 228 + } 229 + 230 + pub fn decode_mini_doc() -> decode.Decoder(MiniDocJson) { 231 + use did <- field("did", string) 232 + use handle <- field("handle", string) 233 + use pds <- field("pds", string) 234 + use signing_key <- field("signing_key", string) 235 + success(MiniDocJson(did:, handle:, pds:, signing_key:)) 236 + } 237 + 238 + // === Union types === 239 + 240 + pub type FacetFeature { 241 + Mention(did: String) 242 + Link(uri: String) 243 + Tag(tag: String) 244 + } 245 + 246 + pub fn decode_facet_feature() -> decode.Decoder(FacetFeature) { 247 + use type_ <- field("$type", string) 248 + case type_ { 249 + "app.bsky.richtext.facet#mention" -> { 250 + use did <- field("did", string) 251 + success(Mention(did:)) 252 + } 253 + "app.bsky.richtext.facet#link" -> { 254 + use uri <- field("uri", string) 255 + success(Link(uri:)) 256 + } 257 + "app.bsky.richtext.facet#tag" -> { 258 + use tag <- field("tag", string) 259 + success(Tag(tag:)) 260 + } 261 + _ -> failure(Mention(""), "unknown facet feature type") 262 + } 263 + } 264 + 265 + pub type FacetJson { 266 + FacetJson(index: ByteSliceJson, features: List(FacetFeature)) 267 + } 268 + 269 + pub fn decode_facet() -> decode.Decoder(FacetJson) { 270 + use index <- field("index", decode_byte_slice()) 271 + use features <- field("features", list(decode_facet_feature())) 272 + success(FacetJson(index:, features:)) 273 + } 274 + 275 + pub type Embed { 276 + Images(List(ImageJson)) 277 + ExternalLink(ExternalJson) 278 + Record(EmbedRecordJson) 279 + } 280 + 281 + pub fn decode_embed() -> decode.Decoder(Embed) { 282 + use type_ <- field("$type", string) 283 + case type_ { 284 + "app.bsky.embed.images" -> { 285 + use images <- field("images", list(decode_image())) 286 + success(Images(images)) 287 + } 288 + "app.bsky.embed.external" -> { 289 + use external <- field("external", decode_external()) 290 + success(ExternalLink(external)) 291 + } 292 + "app.bsky.embed.record" -> map(decode_embed_record(), Record) 293 + _ -> failure(Images([]), "unknown embed type") 294 + } 295 + } 296 + 297 + pub type PostJson { 298 + PostJson( 299 + text: String, 300 + facets: Option(List(FacetJson)), 301 + reply: Option(ReplyRefJson), 302 + embed: Option(Embed), 303 + langs: Option(List(String)), 304 + labels: Option(List(LabelJson)), 305 + tags: Option(List(String)), 306 + created_at: String, 307 + ) 308 + } 309 + 310 + pub fn decode_post() -> decode.Decoder(PostJson) { 311 + use text <- field("text", string) 312 + use facets <- optional_field("facets", None, map(list(decode_facet()), Some)) 313 + use reply <- optional_field("reply", None, map(decode_reply_ref(), Some)) 314 + use embed <- optional_field("embed", None, map(decode_embed(), Some)) 315 + use langs <- optional_field("langs", None, map(list(string), Some)) 316 + use labels <- optional_field("labels", None, map(list(decode_label()), Some)) 317 + use tags <- optional_field("tags", None, map(list(string), Some)) 318 + use created_at <- field("createdAt", string) 319 + success(PostJson( 320 + text:, 321 + facets:, 322 + reply:, 323 + embed:, 324 + langs:, 325 + labels:, 326 + tags:, 327 + created_at:, 328 + )) 329 + } 330 + 331 + // === EmbedRecordView union === 332 + 333 + pub type ProfileViewBasicJson { 334 + ProfileViewBasicJson( 335 + did: String, 336 + handle: String, 337 + display_name: Option(String), 338 + avatar: Option(String), 339 + ) 340 + } 341 + 342 + pub fn decode_profile_view_basic() -> decode.Decoder(ProfileViewBasicJson) { 343 + use did <- field("did", string) 344 + use handle <- field("handle", string) 345 + use display_name <- optional_field("displayName", None, map(string, Some)) 346 + use avatar <- optional_field("avatar", None, map(string, Some)) 347 + success(ProfileViewBasicJson(did:, handle:, display_name:, avatar:)) 348 + } 349 + 350 + pub type EmbedRecordView { 351 + ViewRecord(ViewRecordJson) 352 + NotFound(ViewNotFoundJson) 353 + Blocked(ViewBlockedJson) 354 + Detached(ViewDetachedJson) 355 + } 356 + 357 + pub type ViewRecordJson { 358 + ViewRecordJson( 359 + uri: String, 360 + cid: String, 361 + author: ProfileViewBasicJson, 362 + value: dyn.Dynamic, 363 + labels: Option(List(LabelJson)), 364 + reply_count: Option(Int), 365 + repost_count: Option(Int), 366 + like_count: Option(Int), 367 + quote_count: Option(Int), 368 + embeds: Option(List(EmbedRecordView)), 369 + indexed_at: String, 370 + ) 371 + } 372 + 373 + pub fn decode_embed_record_view() -> decode.Decoder(EmbedRecordView) { 374 + use type_ <- field("$type", string) 375 + case type_ { 376 + "app.bsky.embed.record#viewRecord" -> map(decode_view_record(), ViewRecord) 377 + "app.bsky.embed.record#viewNotFound" -> 378 + map(decode_view_not_found(), NotFound) 379 + "app.bsky.embed.record#viewBlocked" -> map(decode_view_blocked(), Blocked) 380 + "app.bsky.embed.record#viewDetached" -> 381 + map(decode_view_detached(), Detached) 382 + _ -> 383 + failure( 384 + ViewRecord(ViewRecordJson( 385 + uri: "", 386 + cid: "", 387 + author: ProfileViewBasicJson("", "", None, None), 388 + value: dyn.int(0), 389 + labels: None, 390 + reply_count: None, 391 + repost_count: None, 392 + like_count: None, 393 + quote_count: None, 394 + embeds: None, 395 + indexed_at: "", 396 + )), 397 + "unknown embed record view type", 398 + ) 399 + } 400 + } 401 + 402 + pub fn decode_view_record() -> decode.Decoder(ViewRecordJson) { 403 + use uri <- field("uri", string) 404 + use cid <- field("cid", string) 405 + use author <- field("author", decode_profile_view_basic()) 406 + use value <- field("value", decode_dyn) 407 + use labels <- optional_field("labels", None, map(list(decode_label()), Some)) 408 + use reply_count <- optional_field("replyCount", None, map(int, Some)) 409 + use repost_count <- optional_field("repostCount", None, map(int, Some)) 410 + use like_count <- optional_field("likeCount", None, map(int, Some)) 411 + use quote_count <- optional_field("quoteCount", None, map(int, Some)) 412 + use embeds <- optional_field( 413 + "embeds", 414 + None, 415 + map(list(decode_embed_record_view()), Some), 416 + ) 417 + use indexed_at <- field("indexedAt", string) 418 + success(ViewRecordJson( 419 + uri:, 420 + cid:, 421 + author:, 422 + value:, 423 + labels:, 424 + reply_count:, 425 + repost_count:, 426 + like_count:, 427 + quote_count:, 428 + embeds:, 429 + indexed_at:, 430 + )) 431 + } 432 + 433 + pub type ThreadCountsJson { 434 + ThreadCountsJson( 435 + reply_count: Option(Int), 436 + repost_count: Option(Int), 437 + like_count: Option(Int), 438 + quote_count: Option(Int), 439 + ) 440 + } 441 + 442 + pub fn decode_thread_counts() -> decode.Decoder(ThreadCountsJson) { 443 + use reply_count <- optional_field("replyCount", None, map(int, Some)) 444 + use repost_count <- optional_field("repostCount", None, map(int, Some)) 445 + use like_count <- optional_field("likeCount", None, map(int, Some)) 446 + use quote_count <- optional_field("quoteCount", None, map(int, Some)) 447 + success(ThreadCountsJson( 448 + reply_count:, 449 + repost_count:, 450 + like_count:, 451 + quote_count:, 452 + )) 453 + } 454 + 455 + pub type ThreadViewJson { 456 + ThreadViewJson(post: Option(ThreadPostViewJson)) 457 + } 458 + 459 + pub type ThreadPostViewJson { 460 + ThreadPostViewJson(counts: ThreadCountsJson) 461 + } 462 + 463 + pub fn decode_thread_view() -> decode.Decoder(ThreadViewJson) { 464 + use post <- optional_field("post", None, map(decode_thread_post_view(), Some)) 465 + success(ThreadViewJson(post:)) 466 + } 467 + 468 + pub fn decode_thread_post_view() -> decode.Decoder(ThreadPostViewJson) { 469 + map(decode_thread_counts(), ThreadPostViewJson) 470 + }
+17 -9
src/gpreview.css
··· 22 22 .card { 23 23 background-color: white; 24 24 border-radius: 0.75rem; 25 - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 26 - border: 1px solid #e2e8f0; 25 + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06); 27 26 overflow: hidden; 28 27 transition: box-shadow 0.2s ease; 29 28 } ··· 38 37 color: white; 39 38 border-radius: 0.75rem; 40 39 font-weight: 500; 41 - transition: background-color 0.2s ease, box-shadow 0.2s ease; 40 + transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.1s ease; 42 41 white-space: nowrap; 43 42 } 44 43 ··· 46 45 background-color: var(--color-sky-600); 47 46 } 48 47 48 + .btn-primary:active { 49 + transform: scale(0.96); 50 + } 51 + 49 52 .btn-primary:focus { 50 53 outline: none; 51 54 box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.3); ··· 55 58 padding: 1rem 1.25rem; 56 59 border: 1px solid #cbd5e1; 57 60 border-radius: 0.75rem; 58 - transition: all 0.2s ease; 61 + transition: border-color 0.2s ease, box-shadow 0.2s ease; 59 62 } 60 63 61 64 .input-primary:focus { ··· 122 125 123 126 .link-card { 124 127 display: block; 125 - border: 1px solid #e2e8f0; 126 128 border-radius: 0.5rem; 127 129 overflow: hidden; 128 - transition: all 0.2s ease; 130 + transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; 129 131 } 130 132 131 133 .link-card:hover { 132 - border-color: #bae6fd; 134 + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04); 133 135 background-color: #f8fafc; 134 - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 135 136 } 136 137 137 138 .image-grid { ··· 148 149 height: auto; 149 150 object-fit: cover; 150 151 transition: transform 0.2s ease; 152 + outline: 1px solid rgba(0, 0, 0, 0.08); 151 153 } 152 154 153 155 .image-grid img:hover { ··· 163 165 font-weight: 500; 164 166 background-color: #fef3c7; 165 167 color: #b45309; 166 - border: 1px solid #fcd34d; 168 + box-shadow: 0 0 0 1px rgba(252, 211, 77, 0.4); 167 169 } 168 170 169 171 .post-content { ··· 172 174 line-height: 1.75; 173 175 white-space: pre-wrap; 174 176 word-break: break-word; 177 + text-wrap: pretty; 175 178 } 176 179 177 180 .external-link-preview { ··· 183 186 color: #0f172a; 184 187 font-size: 1rem; 185 188 margin-bottom: 0.25rem; 189 + text-wrap: balance; 186 190 } 187 191 188 192 .external-link-desc { ··· 192 196 -webkit-line-clamp: 2; 193 197 -webkit-box-orient: vertical; 194 198 overflow: hidden; 199 + } 200 + 201 + .tabular-nums { 202 + font-variant-numeric: tabular-nums; 195 203 } 196 204 197 205 @media (prefers-reduced-motion: reduce) {
+350 -77
src/gpreview.gleam
··· 1 - import app/post 2 - import app/profile 1 + import bsky/decoders 3 2 import gleam/dynamic/decode.{field, string, success} 4 3 import gleam/int 5 4 import gleam/list ··· 24 23 pub type Model { 25 24 App( 26 25 at_url: String, 27 - did_doc: Option(Result(profile.MiniDoc, String)), 28 - post: Option(Result(Record(post.Post), String)), 29 - profile: Option(Result(profile.Profile, String)), 26 + did_doc: Option(Result(decoders.MiniDocJson, String)), 27 + post: Option(Result(Record(decoders.PostJson), String)), 28 + profile: Option(Result(decoders.ProfileJson, String)), 29 + thread_counts: Option(decoders.ThreadCountsJson), 30 30 ) 31 31 } 32 32 ··· 34 34 #( 35 35 App( 36 36 "at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mgibe2arpk2c", 37 + None, 37 38 None, 38 39 None, 39 40 None, ··· 45 46 pub type Msg { 46 47 LinkWasSet(String) 47 48 UserClickedShow 48 - MiniDocWasResolved(Result(profile.MiniDoc, rsvp.Error)) 49 - PostWasFetched(Result(Record(post.Post), rsvp.Error)) 50 - ProfileWasFetched(Result(Record(profile.Profile), rsvp.Error)) 49 + MiniDocWasResolved(Result(decoders.MiniDocJson, rsvp.Error)) 50 + PostWasFetched(Result(Record(decoders.PostJson), rsvp.Error)) 51 + ProfileWasFetched(Result(Record(decoders.ProfileJson), rsvp.Error)) 52 + ThreadWasFetched(Result(decoders.ThreadCountsJson, rsvp.Error)) 51 53 } 52 54 53 55 pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ··· 85 87 App(..model, post: Some(Error(error_to_string(e)))), 86 88 effect.none(), 87 89 ) 88 - ProfileWasFetched(Ok(p)) -> #( 89 - App(..model, profile: Some(Ok(p.value))), 90 - effect.none(), 91 - ) 90 + ProfileWasFetched(Ok(p)) -> { 91 + case model.post { 92 + Some(Ok(Record(uri: uri, ..))) -> 93 + case model.did_doc { 94 + Some(Ok(doc)) -> #( 95 + App(..model, profile: Some(Ok(p.value))), 96 + fetch_thread_counts(doc.pds, uri), 97 + ) 98 + _ -> #(App(..model, profile: Some(Ok(p.value))), effect.none()) 99 + } 100 + _ -> #(App(..model, profile: Some(Ok(p.value))), effect.none()) 101 + } 102 + } 92 103 ProfileWasFetched(Error(e)) -> #( 93 104 App( 94 105 ..model, ··· 96 107 ), 97 108 effect.none(), 98 109 ) 110 + ThreadWasFetched(Ok(counts)) -> #( 111 + App(..model, thread_counts: Some(counts)), 112 + effect.none(), 113 + ) 114 + ThreadWasFetched(Error(_e)) -> #(model, effect.none()) 99 115 } 100 116 } 101 117 ··· 136 152 } 137 153 138 154 fn get_post_error( 139 - post: Option(Result(Record(post.Post), String)), 155 + post: Option(Result(Record(decoders.PostJson), String)), 140 156 ) -> Option(String) { 141 157 case post { 142 158 Some(Error(e)) -> Some(e) ··· 146 162 147 163 fn display_post(model: Model) -> Element(Msg) { 148 164 case model.post, model.profile { 149 - Some(_), Some(_) -> post_card(model.post, model.profile, model.did_doc) 165 + Some(_), Some(_) -> 166 + post_card(model.post, model.profile, model.did_doc, model.thread_counts) 150 167 Some(Ok(_)), None -> loading_state() 151 168 _, _ -> element.none() 152 169 } ··· 180 197 } 181 198 182 199 fn post_card( 183 - post_opt: Option(Result(Record(post.Post), String)), 184 - profile_opt: Option(Result(profile.Profile, String)), 185 - did_doc: Option(Result(profile.MiniDoc, String)), 200 + post_opt: Option(Result(Record(decoders.PostJson), String)), 201 + profile_opt: Option(Result(decoders.ProfileJson, String)), 202 + did_doc: Option(Result(decoders.MiniDocJson, String)), 203 + thread_counts: Option(decoders.ThreadCountsJson), 186 204 ) -> Element(Msg) { 187 205 case post_opt, profile_opt, did_doc { 188 206 Some(Ok(Record(uri: _, cid: _, value: post))), 189 207 Some(Ok(profile)), 190 208 Some(Ok(doc)) 191 - -> 209 + -> { 210 + let reply_context = case post.reply { 211 + Some(reply) -> { 212 + let parent_did = 213 + reply.parent.uri 214 + |> extract_did_from_uri 215 + |> fn(r) { 216 + case r { 217 + Ok(d) -> d 218 + Error(_) -> "unknown" 219 + } 220 + } 221 + let short_did = string.slice(parent_did, 0, 20) <> "…" 222 + html.div( 223 + [ 224 + attribute.attribute("class", "px-6 pb-2 text-xs text-slate-500"), 225 + ], 226 + [ 227 + html.span( 228 + [ 229 + attribute.attribute("class", "text-slate-400"), 230 + ], 231 + [html.text("Replying to " <> short_did)], 232 + ), 233 + ], 234 + ) 235 + } 236 + None -> element.none() 237 + } 238 + 192 239 html.div( 193 240 [ 194 241 attribute.attribute("class", "card"), 195 242 ], 196 243 [ 197 - post_header(profile, doc.did, doc.pds), 244 + post_header(profile, doc.handle, doc.did, doc.pds), 198 245 html.div( 199 246 [ 200 247 attribute.attribute("class", "px-6 pb-4"), 201 248 ], 202 249 [ 250 + reply_context, 203 251 html.p( 204 252 [ 205 253 attribute.attribute("class", "post-content"), 254 + attribute.attribute("style", "text-wrap: pretty"), 206 255 ], 207 - [html.text(render_post_with_facets(post.text, post.facets))], 256 + render_post_with_facets(post.text, post.facets, doc.pds), 208 257 ), 209 - post_embed(post.embed), 210 - post_footer(post.labels, post.created_at), 258 + post_embed(post.embed, doc.pds, doc.did), 259 + post_footer( 260 + post.labels, 261 + post.tags, 262 + post.created_at, 263 + thread_counts, 264 + ), 211 265 ], 212 266 ), 213 267 ], 214 268 ) 269 + } 215 270 Some(Error(e)), _, _ | _, Some(Error(e)), _ | _, _, Some(Error(e)) -> 216 271 html.text(e) 217 272 _, _, _ -> element.none() ··· 219 274 } 220 275 221 276 fn post_header( 222 - profile: profile.Profile, 277 + profile: decoders.ProfileJson, 278 + handle: String, 223 279 did: String, 224 280 pds_host: String, 225 281 ) -> Element(Msg) { 282 + let display_name_el = case profile.display_name { 283 + Some(name) -> 284 + html.span( 285 + [ 286 + attribute.attribute( 287 + "class", 288 + "font-semibold text-slate-900 text-base leading-tight", 289 + ), 290 + ], 291 + [html.text(name)], 292 + ) 293 + None -> element.none() 294 + } 295 + 296 + let description_el = case profile.description { 297 + Some(desc) -> 298 + html.p( 299 + [ 300 + attribute.attribute( 301 + "class", 302 + "text-xs text-slate-500 leading-tight mt-0.5 truncate", 303 + ), 304 + ], 305 + [html.text(desc)], 306 + ) 307 + None -> element.none() 308 + } 309 + 226 310 html.div( 227 311 [ 228 312 attribute.attribute( ··· 234 318 case profile.avatar { 235 319 Some(blob) -> 236 320 html.img([ 237 - attribute.attribute( 238 - "src", 239 - profile.blob_ref_to_url(pds_host, did, blob), 240 - ), 321 + attribute.attribute("src", blob_ref_to_url(pds_host, did, blob)), 241 322 attribute.attribute("alt", "Avatar"), 242 323 attribute.attribute("class", "avatar"), 243 324 attribute.attribute("referrerpolicy", "no-referrer"), ··· 255 336 attribute.attribute("class", "flex flex-col min-w-0"), 256 337 ], 257 338 [ 339 + display_name_el, 258 340 html.div( 259 341 [ 260 342 attribute.attribute( 261 343 "class", 262 - "font-semibold text-slate-900 text-base leading-tight", 344 + "text-sm text-slate-500 leading-tight mt-0.5", 263 345 ), 264 346 ], 265 - [ 266 - case profile.display_name { 267 - None -> element.none() 268 - Some(handle) -> 269 - html.div( 270 - [ 271 - attribute.attribute( 272 - "class", 273 - "text-sm text-slate-500 leading-tight mt-0.5", 274 - ), 275 - ], 276 - [html.text("@" <> handle)], 277 - ) 278 - }, 279 - ], 347 + [html.text("@" <> handle)], 280 348 ), 349 + description_el, 281 350 ], 282 351 ), 283 352 ], 284 353 ) 285 354 } 286 355 287 - fn render_post_with_facets(text: String, _facets: List(post.Facet)) -> String { 288 - text 356 + fn blob_ref_to_url(pds_host: String, did: String, blob: String) -> String { 357 + pds_host <> "/xrpc/com.atproto.sync.getBlob?did=" <> did <> "&cid=" <> blob 358 + } 359 + 360 + fn render_post_with_facets( 361 + text: String, 362 + facets: Option(List(decoders.FacetJson)), 363 + pds_host: String, 364 + ) -> List(Element(Msg)) { 365 + case facets { 366 + None -> [html.text(text)] 367 + Some(f) -> { 368 + let sorted = 369 + list.sort(f, fn(a, b) { 370 + int.compare(a.index.byte_start, b.index.byte_start) 371 + }) 372 + build_facet_elements(text, sorted, 0, pds_host) 373 + } 374 + } 375 + } 376 + 377 + fn build_facet_elements( 378 + text: String, 379 + facets: List(decoders.FacetJson), 380 + byte_offset: Int, 381 + pds_host: String, 382 + ) -> List(Element(Msg)) { 383 + case facets { 384 + [] -> { 385 + let remaining = 386 + string.slice(text, byte_offset, string.length(text) - byte_offset) 387 + case string.is_empty(remaining) { 388 + True -> [] 389 + False -> [html.text(remaining)] 390 + } 391 + } 392 + [facet, ..rest] -> { 393 + let start = facet.index.byte_start 394 + let end = facet.index.byte_end 395 + 396 + let before = case start > byte_offset { 397 + True -> { 398 + let segment = string.slice(text, byte_offset, start - byte_offset) 399 + case string.is_empty(segment) { 400 + True -> [] 401 + False -> [html.text(segment)] 402 + } 403 + } 404 + False -> [] 405 + } 406 + 407 + let facet_text = string.slice(text, start, end - start) 408 + let facet_elements = 409 + build_facet_element(facet.features, facet_text, pds_host) 410 + 411 + let after = build_facet_elements(text, rest, end, pds_host) 412 + 413 + list.append(before, list.append(facet_elements, after)) 414 + } 415 + } 416 + } 417 + 418 + fn build_facet_element( 419 + features: List(decoders.FacetFeature), 420 + text: String, 421 + _pds_host: String, 422 + ) -> List(Element(Msg)) { 423 + case features { 424 + [] -> [html.text(text)] 425 + [feature, ..] -> { 426 + case feature { 427 + decoders.Mention(did) -> { 428 + let profile_url = "https://bsky.app/profile/" <> did 429 + [ 430 + html.a( 431 + [ 432 + attribute.attribute("href", profile_url), 433 + attribute.attribute("target", "_blank"), 434 + attribute.attribute("rel", "noopener noreferrer"), 435 + attribute.attribute("class", "mention-link"), 436 + ], 437 + [html.text(text)], 438 + ), 439 + ] 440 + } 441 + decoders.Link(uri) -> [ 442 + html.a( 443 + [ 444 + attribute.attribute("href", uri), 445 + attribute.attribute("target", "_blank"), 446 + attribute.attribute("rel", "noopener noreferrer"), 447 + attribute.attribute("class", "link-facet"), 448 + ], 449 + [html.text(text)], 450 + ), 451 + ] 452 + decoders.Tag(tag) -> { 453 + let tag_url = "https://bsky.app/hashtag/" <> tag 454 + [ 455 + html.span( 456 + [ 457 + attribute.attribute("class", "tag-link"), 458 + ], 459 + [ 460 + html.a( 461 + [ 462 + attribute.attribute("href", tag_url), 463 + attribute.attribute("target", "_blank"), 464 + attribute.attribute("rel", "noopener noreferrer"), 465 + ], 466 + [html.text("#" <> tag)], 467 + ), 468 + ], 469 + ), 470 + ] 471 + } 472 + } 473 + } 474 + } 289 475 } 290 476 291 - fn post_embed(embed: Option(post.Embed)) -> Element(Msg) { 477 + fn post_embed( 478 + embed: Option(decoders.Embed), 479 + pds_host: String, 480 + did: String, 481 + ) -> Element(Msg) { 292 482 case embed { 293 483 None -> element.none() 294 484 Some(embed_obj) -> 295 485 case embed_obj { 296 - post.Images(images) -> 486 + decoders.Images(images) -> 297 487 html.div( 298 488 [ 299 489 attribute.attribute("class", "image-grid"), ··· 302 492 html.img([ 303 493 attribute.attribute( 304 494 "src", 305 - "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", 495 + blob_ref_to_url(pds_host, did, img.ref), 306 496 ), 307 497 attribute.attribute("alt", img.alt), 308 498 attribute.attribute("class", "w-full h-auto object-cover"), ··· 310 500 ]) 311 501 }), 312 502 ) 313 - post.ExternalLink(external) -> 503 + decoders.ExternalLink(external) -> 314 504 html.a( 315 505 [ 316 506 attribute.attribute("href", external.uri), ··· 319 509 attribute.attribute("class", "link-card"), 320 510 ], 321 511 [ 322 - case external.thumb { 323 - Some(thumb_url) -> 324 - html.img([ 325 - attribute.attribute("src", thumb_url), 326 - attribute.attribute("alt", external.title), 327 - attribute.attribute("class", "w-full h-48 object-cover"), 328 - attribute.attribute("referrerpolicy", "no-referrer"), 329 - ]) 330 - None -> element.none() 331 - }, 332 512 html.div( 333 513 [ 334 514 attribute.attribute("class", "external-link-preview"), ··· 350 530 ), 351 531 ], 352 532 ) 353 - post.Record(r) -> html.text(r.uri) 533 + decoders.Record(r) -> html.text(r.record.uri) 354 534 } 355 535 } 356 536 } 357 537 358 - fn post_footer(labels: List(post.Label), created_at: String) -> Element(Msg) { 538 + fn post_footer( 539 + labels: Option(List(decoders.LabelJson)), 540 + tags: Option(List(String)), 541 + created_at: String, 542 + thread_counts: Option(decoders.ThreadCountsJson), 543 + ) -> Element(Msg) { 544 + let badges = case labels { 545 + None -> [] 546 + Some(lbls) -> 547 + list.map(lbls, fn(label) { 548 + html.span( 549 + [ 550 + attribute.attribute("class", "label-badge"), 551 + ], 552 + [html.text(label.val)], 553 + ) 554 + }) 555 + } 556 + 557 + let tag_badges = case tags { 558 + None -> [] 559 + Some(ts) -> 560 + list.map(ts, fn(tag) { 561 + html.a( 562 + [ 563 + attribute.attribute("href", "https://bsky.app/hashtag/" <> tag), 564 + attribute.attribute("target", "_blank"), 565 + attribute.attribute("rel", "noopener noreferrer"), 566 + attribute.attribute("class", "tag-badge"), 567 + ], 568 + [html.text("#" <> tag)], 569 + ) 570 + }) 571 + } 572 + 573 + let all_badges = list.append(badges, tag_badges) 574 + 575 + let counts_el = case thread_counts { 576 + Some(counts) -> { 577 + let like_count = case counts.like_count { 578 + Some(n) -> int.to_string(n) 579 + None -> "0" 580 + } 581 + let repost_count = case counts.repost_count { 582 + Some(n) -> int.to_string(n) 583 + None -> "0" 584 + } 585 + let reply_count = case counts.reply_count { 586 + Some(n) -> int.to_string(n) 587 + None -> "0" 588 + } 589 + 590 + html.div( 591 + [ 592 + attribute.attribute( 593 + "class", 594 + "flex items-center gap-3 text-xs text-slate-500 tabular-nums", 595 + ), 596 + ], 597 + [ 598 + html.span([], [html.text("♥ " <> like_count)]), 599 + html.span([], [html.text("↗ " <> repost_count)]), 600 + html.span([], [html.text("💬 " <> reply_count)]), 601 + ], 602 + ) 603 + } 604 + None -> element.none() 605 + } 606 + 359 607 html.div( 360 608 [ 361 609 attribute.attribute( ··· 364 612 ), 365 613 ], 366 614 [ 367 - html.p( 615 + html.div( 368 616 [ 369 - attribute.attribute("class", "text-xs text-slate-400"), 617 + attribute.attribute("class", "flex items-center gap-3"), 370 618 ], 371 - [html.text(format_timestamp(created_at))], 619 + [ 620 + html.p( 621 + [ 622 + attribute.attribute( 623 + "class", 624 + "text-xs text-slate-400 tabular-nums", 625 + ), 626 + ], 627 + [html.text(format_timestamp(created_at))], 628 + ), 629 + counts_el, 630 + ], 372 631 ), 373 - case labels { 632 + case all_badges { 374 633 [] -> element.none() 375 634 _ -> 376 635 html.div( 377 636 [ 378 - attribute.attribute("class", "flex gap-1"), 637 + attribute.attribute("class", "flex gap-1 flex-wrap"), 379 638 ], 380 - list.map(labels, fn(label) { 381 - html.span( 382 - [ 383 - attribute.attribute("class", "label-badge"), 384 - ], 385 - [html.text(label.val)], 386 - ) 387 - }), 639 + all_badges, 388 640 ) 389 641 }, 390 642 ], ··· 450 702 <> "/xrpc/com.atproto.repo.getRecord?" 451 703 <> construct_profile_uri(did), 452 704 rsvp.expect_json( 453 - decode_get_record_response(profile.decode_profile()), 705 + decode_get_record_response(decoders.decode_profile()), 454 706 ProfileWasFetched, 455 707 ), 456 708 ) 457 709 } 458 710 711 + fn fetch_thread_counts(_pds_host: String, uri: String) -> Effect(Msg) { 712 + let encoded_uri = 713 + uri 714 + |> string.replace(":", "%3A") 715 + |> string.replace("/", "%2F") 716 + let url = 717 + "https://public.api.bsky.app" 718 + <> "/xrpc/app.bsky.feed.getPostThread?uri=" 719 + <> encoded_uri 720 + <> "&depth=0" 721 + rsvp.get(url, rsvp.expect_json(decode_thread_response(), ThreadWasFetched)) 722 + } 723 + 724 + fn decode_thread_response() -> decode.Decoder(decoders.ThreadCountsJson) { 725 + use thread <- field("thread", decoders.decode_thread_view()) 726 + case thread.post { 727 + Some(post_view) -> success(post_view.counts) 728 + None -> success(decoders.ThreadCountsJson(None, None, None, None)) 729 + } 730 + } 731 + 459 732 pub fn extract_did_from_uri(uri: String) -> Result(String, Nil) { 460 733 let u = case uri { 461 734 "at://" <> rest -> rest ··· 483 756 rsvp.get( 484 757 url, 485 758 rsvp.expect_json( 486 - decode_get_record_response(post.decode_post()), 759 + decode_get_record_response(decoders.decode_post()), 487 760 PostWasFetched, 488 761 ), 489 762 ) ··· 502 775 slingshot_base 503 776 <> "/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" 504 777 <> identifier, 505 - rsvp.expect_json(profile.decode_mini_doc(), MiniDocWasResolved), 778 + rsvp.expect_json(decoders.decode_mini_doc(), MiniDocWasResolved), 506 779 ) 507 780 } 508 781
src/lexicons/app/bsky/actor/profile.json lexicons/app/bsky/actor/profile.json
src/lexicons/app/bsky/embed/external.json lexicons/app/bsky/embed/external.json
+2 -1
src/lexicons/app/bsky/embed/images.json lexicons/app/bsky/embed/images.json
··· 20 20 "properties": { 21 21 "image": { 22 22 "type": "blob", 23 + "description": "The raw image file. May be up to 2 MB, formerly limited to 1 MB.", 23 24 "accept": ["image/*"], 24 - "maxSize": 1000000 25 + "maxSize": 2000000 25 26 }, 26 27 "alt": { 27 28 "type": "string",
src/lexicons/app/bsky/embed/record.json lexicons/app/bsky/embed/record.json
src/lexicons/app/bsky/feed/post.json lexicons/app/bsky/feed/post.json
src/lexicons/app/bsky/richtext/facet.json lexicons/app/bsky/richtext/facet.json
+538 -21
test/bsky_test.gleam
··· 1 - import app/post 2 - import app/profile 3 - import gleam/dynamic/decode 1 + import bsky/decoders 2 + import gleam/dynamic/decode.{field, string, success} 4 3 import gleam/json 4 + import gleam/list 5 + import gleam/option.{None, Some} 5 6 import gleeunit 6 7 import gleeunit/should 8 + import gpreview 7 9 8 10 pub fn main() { 9 11 gleeunit.main() ··· 16 18 json.parse(from: json_string, using: decoder) 17 19 } 18 20 21 + // === MiniDoc === 22 + 23 + pub fn decode_mini_doc_test() { 24 + let json_string = 25 + "{\"did\":\"did:plc:abc123\",\"handle\":\"test.bsky.social\",\"pds\":\"https://example.pds.com\",\"signing_key\":\"zTestKey123\"}" 26 + 27 + from_json(json_string, decoders.decode_mini_doc()) 28 + |> should.be_ok() 29 + |> fn(m) { 30 + m.did |> should.equal("did:plc:abc123") 31 + m.handle |> should.equal("test.bsky.social") 32 + m.pds |> should.equal("https://example.pds.com") 33 + m.signing_key |> should.equal("zTestKey123") 34 + } 35 + } 36 + 37 + // === StrongRef === 38 + 19 39 pub fn decode_strong_ref_test() { 20 40 let json_string = 21 41 "{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"cid\":\"bafyre123\"}" 22 42 23 - from_json(json_string, post.decode_strong_ref()) 43 + from_json(json_string, decoders.decode_strong_ref()) 24 44 |> should.be_ok() 25 45 |> fn(p) { 26 46 p.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") ··· 28 48 } 29 49 } 30 50 51 + // === Label === 52 + 31 53 pub fn decode_label_test() { 32 54 let json_string = "{\"val\":\"nsfw\"}" 33 55 34 - from_json(json_string, post.decode_label()) 56 + from_json(json_string, decoders.decode_label()) 35 57 |> should.be_ok() 36 58 |> fn(l) { l.val |> should.equal("nsfw") } 37 59 } 38 60 61 + // === ByteSlice === 62 + 63 + pub fn decode_byte_slice_test() { 64 + let json_string = "{\"byteStart\":0,\"byteEnd\":5}" 65 + 66 + from_json(json_string, decoders.decode_byte_slice()) 67 + |> should.be_ok() 68 + |> fn(b) { 69 + b.byte_start |> should.equal(0) 70 + b.byte_end |> should.equal(5) 71 + } 72 + } 73 + 74 + // === AspectRatio === 75 + 76 + pub fn decode_aspect_ratio_test() { 77 + let json_string = "{\"width\":1200,\"height\":630}" 78 + 79 + from_json(json_string, decoders.decode_aspect_ratio()) 80 + |> should.be_ok() 81 + |> fn(a) { 82 + a.width |> should.equal(1200) 83 + a.height |> should.equal(630) 84 + } 85 + } 86 + 87 + // === Mention === 88 + 89 + pub fn decode_mention_test() { 90 + let json_string = "{\"did\":\"did:plc:456\"}" 91 + 92 + from_json(json_string, decoders.decode_mention()) 93 + |> should.be_ok() 94 + |> fn(m) { m.did |> should.equal("did:plc:456") } 95 + } 96 + 97 + // === Link === 98 + 99 + pub fn decode_link_test() { 100 + let json_string = "{\"uri\":\"https://example.com\"}" 101 + 102 + from_json(json_string, decoders.decode_link()) 103 + |> should.be_ok() 104 + |> fn(l) { l.uri |> should.equal("https://example.com") } 105 + } 106 + 107 + // === Tag === 108 + 109 + pub fn decode_tag_test() { 110 + let json_string = "{\"tag\":\"gleam\"}" 111 + 112 + from_json(json_string, decoders.decode_tag()) 113 + |> should.be_ok() 114 + |> fn(t) { t.tag |> should.equal("gleam") } 115 + } 116 + 117 + // === FacetFeature === 118 + 119 + pub fn decode_facet_feature_mention_test() { 120 + let json_string = 121 + "{\"$type\":\"app.bsky.richtext.facet#mention\",\"did\":\"did:plc:456\"}" 122 + 123 + from_json(json_string, decoders.decode_facet_feature()) 124 + |> should.be_ok() 125 + |> fn(f) { 126 + case f { 127 + decoders.Mention(did) -> did |> should.equal("did:plc:456") 128 + _ -> should.fail() 129 + } 130 + } 131 + } 132 + 133 + pub fn decode_facet_feature_link_test() { 134 + let json_string = 135 + "{\"$type\":\"app.bsky.richtext.facet#link\",\"uri\":\"https://example.com\"}" 136 + 137 + from_json(json_string, decoders.decode_facet_feature()) 138 + |> should.be_ok() 139 + |> fn(f) { 140 + case f { 141 + decoders.Link(uri) -> uri |> should.equal("https://example.com") 142 + _ -> should.fail() 143 + } 144 + } 145 + } 146 + 147 + pub fn decode_facet_feature_tag_test() { 148 + let json_string = 149 + "{\"$type\":\"app.bsky.richtext.facet#tag\",\"tag\":\"gleam\"}" 150 + 151 + from_json(json_string, decoders.decode_facet_feature()) 152 + |> should.be_ok() 153 + |> fn(f) { 154 + case f { 155 + decoders.Tag(tag) -> tag |> should.equal("gleam") 156 + _ -> should.fail() 157 + } 158 + } 159 + } 160 + 161 + // === Facet === 162 + 39 163 pub fn decode_facet_test() { 40 164 let json_string = 41 165 "{\"index\":{\"byteStart\":0,\"byteEnd\":5},\"features\":[{\"$type\":\"app.bsky.richtext.facet#mention\",\"did\":\"did:plc:456\"}]}" 42 166 43 - from_json(json_string, post.decode_facet()) 167 + from_json(json_string, decoders.decode_facet()) 44 168 |> should.be_ok() 45 169 |> fn(f) { 46 170 f.index.byte_start |> should.equal(0) ··· 48 172 } 49 173 } 50 174 175 + // === Image === 176 + 51 177 pub fn decode_image_test() { 52 - let json_string = "{\"alt\":\"A cat image\"}" 178 + let json_string = 179 + "{\"alt\":\"A cat image\",\"image\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkrei123\"},\"mimeType\":\"image/jpeg\",\"size\":12345}}" 53 180 54 - from_json(json_string, post.decode_image()) 181 + from_json(json_string, decoders.decode_image()) 55 182 |> should.be_ok() 56 - |> fn(i) { i.alt |> should.equal("A cat image") } 183 + |> fn(i) { 184 + i.alt |> should.equal("A cat image") 185 + i.ref |> should.equal("bafkrei123") 186 + } 187 + } 188 + 189 + pub fn decode_image_with_aspect_ratio_test() { 190 + let json_string = 191 + "{\"alt\":\"A cat image\",\"image\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkrei456\"},\"mimeType\":\"image/jpeg\",\"size\":12345},\"aspectRatio\":{\"width\":1200,\"height\":630}}" 192 + 193 + from_json(json_string, decoders.decode_image()) 194 + |> should.be_ok() 195 + |> fn(i) { 196 + i.alt |> should.equal("A cat image") 197 + i.ref |> should.equal("bafkrei456") 198 + case i.aspect_ratio { 199 + Some(ar) -> { 200 + ar.width |> should.equal(1200) 201 + ar.height |> should.equal(630) 202 + } 203 + None -> should.fail() 204 + } 205 + } 57 206 } 58 207 208 + // === External === 209 + 59 210 pub fn decode_external_test() { 60 211 let json_string = 61 212 "{\"uri\":\"https://example.com\",\"title\":\"Example\",\"description\":\"An example site\"}" 62 213 63 - from_json(json_string, post.decode_external()) 214 + from_json(json_string, decoders.decode_external()) 64 215 |> should.be_ok() 65 216 |> fn(e) { 66 217 e.uri |> should.equal("https://example.com") ··· 69 220 } 70 221 } 71 222 72 - pub fn decode_embed_test() { 223 + // === Embed === 224 + 225 + pub fn decode_embed_images_test() { 73 226 let json_string = 74 - "{\"$type\":\"app.bsky.embed.images\",\"images\":[{\"alt\":\"Image 1\"}]}" 227 + "{\"$type\":\"app.bsky.embed.images\",\"images\":[{\"alt\":\"Image 1\",\"image\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkrei123\"},\"mimeType\":\"image/jpeg\",\"size\":12345}}]}" 75 228 76 - from_json(json_string, post.decode_embed()) 229 + from_json(json_string, decoders.decode_embed()) 77 230 |> should.be_ok() 231 + |> fn(e) { 232 + case e { 233 + decoders.Images(images) -> list.length(images) |> should.equal(1) 234 + _ -> should.fail() 235 + } 236 + } 78 237 } 79 238 239 + pub fn decode_embed_external_test() { 240 + let json_string = 241 + "{\"$type\":\"app.bsky.embed.external\",\"external\":{\"uri\":\"https://example.com\",\"title\":\"Example\",\"description\":\"An example site\"}}" 242 + 243 + from_json(json_string, decoders.decode_embed()) 244 + |> should.be_ok() 245 + |> fn(e) { 246 + case e { 247 + decoders.ExternalLink(ext) -> 248 + ext.uri |> should.equal("https://example.com") 249 + _ -> should.fail() 250 + } 251 + } 252 + } 253 + 254 + pub fn decode_embed_record_test() { 255 + let json_string = 256 + "{\"$type\":\"app.bsky.embed.record\",\"record\":{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"cid\":\"bafyre123\"}}" 257 + 258 + from_json(json_string, decoders.decode_embed()) 259 + |> should.be_ok() 260 + |> fn(e) { 261 + case e { 262 + decoders.Record(rec) -> 263 + rec.record.uri 264 + |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") 265 + _ -> should.fail() 266 + } 267 + } 268 + } 269 + 270 + // === Post === 271 + 80 272 pub fn decode_post_test() { 81 273 let json_string = 82 274 "{\"text\":\"Hello world\",\"createdAt\":\"2024-01-01T00:00:00.000Z\"}" 83 275 84 - from_json(json_string, post.decode_post()) 276 + from_json(json_string, decoders.decode_post()) 85 277 |> should.be_ok() 86 278 |> fn(p) { 87 279 p.text |> should.equal("Hello world") ··· 89 281 } 90 282 } 91 283 92 - pub fn decode_mini_doc_test() { 284 + pub fn decode_post_with_facets_test() { 285 + let json_string = 286 + "{\"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\"}" 287 + 288 + from_json(json_string, decoders.decode_post()) 289 + |> should.be_ok() 290 + |> fn(p) { 291 + p.text |> should.equal("Hello @bob") 292 + case p.facets { 293 + Some(facets) -> list.length(facets) |> should.equal(1) 294 + None -> should.fail() 295 + } 296 + } 297 + } 298 + 299 + pub fn decode_post_with_embed_test() { 300 + let json_string = 301 + "{\"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\"}" 302 + 303 + from_json(json_string, decoders.decode_post()) 304 + |> should.be_ok() 305 + |> fn(p) { 306 + p.text |> should.equal("Check this out") 307 + case p.embed { 308 + Some(decoders.Images(images)) -> list.length(images) |> should.equal(1) 309 + _ -> should.fail() 310 + } 311 + } 312 + } 313 + 314 + // === Profile === 315 + 316 + pub fn decode_profile_test() { 317 + let json_string = 318 + "{\"displayName\":\"Test User\",\"description\":\"A test profile\",\"avatar\":{\"ref\":{\"$link\":\"bafkrei123\"},\"size\":12345,\"$type\":\"blob\",\"mimeType\":\"image/jpeg\"}}" 319 + 320 + from_json(json_string, decoders.decode_profile()) 321 + |> should.be_ok() 322 + |> fn(p) { 323 + p.display_name |> should.equal(Some("Test User")) 324 + p.description |> should.equal(Some("A test profile")) 325 + p.avatar |> should.equal(Some("bafkrei123")) 326 + } 327 + } 328 + 329 + pub fn decode_profile_empty_test() { 330 + let json_string = "{}" 331 + 332 + from_json(json_string, decoders.decode_profile()) 333 + |> should.be_ok() 334 + |> fn(p) { 335 + p.display_name |> should.equal(None) 336 + p.description |> should.equal(None) 337 + p.avatar |> should.equal(None) 338 + } 339 + } 340 + 341 + // === ViewNotFound === 342 + 343 + pub fn decode_view_not_found_test() { 344 + let json_string = 345 + "{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"notFound\":true}" 346 + 347 + from_json(json_string, decoders.decode_view_not_found()) 348 + |> should.be_ok() 349 + |> fn(v) { 350 + v.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") 351 + v.not_found |> should.equal(True) 352 + } 353 + } 354 + 355 + // === ViewBlocked === 356 + 357 + pub fn decode_view_blocked_test() { 358 + let json_string = 359 + "{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"blocked\":true}" 360 + 361 + from_json(json_string, decoders.decode_view_blocked()) 362 + |> should.be_ok() 363 + |> fn(v) { 364 + v.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") 365 + v.blocked |> should.equal(True) 366 + } 367 + } 368 + 369 + // === ViewDetached === 370 + 371 + pub fn decode_view_detached_test() { 372 + let json_string = 373 + "{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"detached\":true}" 374 + 375 + from_json(json_string, decoders.decode_view_detached()) 376 + |> should.be_ok() 377 + |> fn(v) { 378 + v.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") 379 + v.detached |> should.equal(True) 380 + } 381 + } 382 + 383 + // === EmbedRecord === 384 + 385 + pub fn decode_embed_record_only_test() { 386 + let json_string = 387 + "{\"record\":{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"cid\":\"bafyre123\"}}" 388 + 389 + from_json(json_string, decoders.decode_embed_record()) 390 + |> should.be_ok() 391 + |> fn(e) { 392 + e.record.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") 393 + e.record.cid |> should.equal("bafyre123") 394 + } 395 + } 396 + 397 + // === ReplyRef === 398 + 399 + pub fn decode_reply_ref_test() { 400 + let json_string = 401 + "{\"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\"}}" 402 + 403 + from_json(json_string, decoders.decode_reply_ref()) 404 + |> should.be_ok() 405 + |> fn(r) { 406 + r.root.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/root") 407 + r.parent.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/parent") 408 + } 409 + } 410 + 411 + // === TextSlice === 412 + 413 + pub fn decode_text_slice_test() { 414 + let json_string = "{\"start\":0,\"end\":5}" 415 + 416 + from_json(json_string, decoders.decode_text_slice()) 417 + |> should.be_ok() 418 + |> fn(t) { 419 + t.start |> should.equal(0) 420 + t.end |> should.equal(5) 421 + } 422 + } 423 + 424 + // === Post with reply === 425 + 426 + pub fn decode_post_with_reply_test() { 427 + let json_string = 428 + "{\"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\"}" 429 + 430 + from_json(json_string, decoders.decode_post()) 431 + |> should.be_ok() 432 + |> fn(p) { 433 + p.text |> should.equal("Replying to this") 434 + case p.reply { 435 + Some(reply) -> { 436 + reply.parent.uri 437 + |> should.equal("at://did:plc:123/app.bsky.feed.post/parent") 438 + reply.root.uri 439 + |> should.equal("at://did:plc:123/app.bsky.feed.post/root") 440 + } 441 + None -> should.fail() 442 + } 443 + } 444 + } 445 + 446 + // === ThreadCounts === 447 + 448 + pub fn decode_thread_counts_test() { 449 + let json_string = 450 + "{\"replyCount\":12,\"repostCount\":5,\"likeCount\":42,\"quoteCount\":3}" 451 + 452 + from_json(json_string, decoders.decode_thread_counts()) 453 + |> should.be_ok() 454 + |> fn(c) { 455 + c.reply_count |> should.equal(Some(12)) 456 + c.repost_count |> should.equal(Some(5)) 457 + c.like_count |> should.equal(Some(42)) 458 + c.quote_count |> should.equal(Some(3)) 459 + } 460 + } 461 + 462 + pub fn decode_thread_counts_empty_test() { 463 + let json_string = "{}" 464 + 465 + from_json(json_string, decoders.decode_thread_counts()) 466 + |> should.be_ok() 467 + |> fn(c) { 468 + c.reply_count |> should.equal(None) 469 + c.repost_count |> should.equal(None) 470 + c.like_count |> should.equal(None) 471 + c.quote_count |> should.equal(None) 472 + } 473 + } 474 + 475 + pub fn decode_thread_view_test() { 476 + let json_string = 477 + "{\"post\":{\"replyCount\":12,\"repostCount\":5,\"likeCount\":42,\"quoteCount\":3}}" 478 + 479 + from_json(json_string, decoders.decode_thread_view()) 480 + |> should.be_ok() 481 + |> fn(tv) { 482 + case tv.post { 483 + Some(post_view) -> { 484 + post_view.counts.like_count |> should.equal(Some(42)) 485 + post_view.counts.reply_count |> should.equal(Some(12)) 486 + } 487 + None -> should.fail() 488 + } 489 + } 490 + } 491 + 492 + // === Real API fixture tests === 493 + 494 + pub fn real_mini_doc_test() { 93 495 let json_string = 94 - "{\"did\":\"did:plc:abc123\",\"handle\":\"test.bsky.social\",\"pds\":\"https://example.pds.com\",\"signing_key\":\"zTestKey123\"}" 496 + "{\"did\":\"did:plc:kcgwlowulc3rac43lregdawo\",\"handle\":\"karitham.dev\",\"pds\":\"https://eurosky.social\",\"signing_key\":\"zQ3shw7u4EzjTZvokgDjeQegByJVw2h7L24CEa1gFxE8fFyeg\"}" 95 497 96 - from_json(json_string, profile.decode_mini_doc()) 498 + from_json(json_string, decoders.decode_mini_doc()) 97 499 |> should.be_ok() 98 500 |> fn(m) { 99 - m.did |> should.equal("did:plc:abc123") 100 - m.handle |> should.equal("test.bsky.social") 101 - m.pds |> should.equal("https://example.pds.com") 102 - m.signing_key |> should.equal("zTestKey123") 501 + m.did |> should.equal("did:plc:kcgwlowulc3rac43lregdawo") 502 + m.handle |> should.equal("karitham.dev") 503 + m.pds |> should.equal("https://eurosky.social") 504 + } 505 + } 506 + 507 + pub fn real_post_test() { 508 + let json_string = 509 + "{\"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\"}}" 510 + 511 + from_json(json_string, decode_get_record(decoders.decode_post())) 512 + |> should.be_ok() 513 + |> fn(p) { 514 + p.value.text 515 + |> should.equal( 516 + "I think my migration to @eurosky.social went well but it's so smooth I'm not sure I did it right 🔍", 517 + ) 518 + p.value.created_at |> should.equal("2026-03-07T16:40:32.271Z") 519 + case p.value.facets { 520 + Some(facets) -> list.length(facets) |> should.equal(1) 521 + None -> should.fail() 522 + } 523 + } 524 + } 525 + 526 + pub fn real_profile_test() { 527 + let json_string = 528 + "{\"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\"}}" 529 + 530 + from_json(json_string, decode_get_record(decoders.decode_profile())) 531 + |> should.be_ok() 532 + |> fn(p) { 533 + p.value.display_name |> should.equal(Some("karitham")) 534 + p.value.description 535 + |> should.equal(Some( 536 + "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", 537 + )) 538 + p.value.avatar 539 + |> should.equal(Some( 540 + "bafkreig5onh2hofb4xr7voz4b3hwxigu6zqhr5sd6svjnnksaid4a2fmje", 541 + )) 542 + } 543 + } 544 + 545 + pub fn real_thread_test() { 546 + let json_string = 547 + "{\"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\"}}" 548 + 549 + from_json(json_string, decode_thread_response()) 550 + |> should.be_ok() 551 + |> fn(counts) { 552 + counts.like_count |> should.equal(Some(19)) 553 + counts.reply_count |> should.equal(Some(1)) 554 + counts.repost_count |> should.equal(Some(1)) 555 + counts.quote_count |> should.equal(Some(1)) 556 + } 557 + } 558 + 559 + fn decode_get_record( 560 + decoder: decode.Decoder(a), 561 + ) -> decode.Decoder(gpreview.Record(a)) { 562 + use uri <- field("uri", string) 563 + use cid <- field("cid", string) 564 + use value <- field("value", decoder) 565 + success(gpreview.Record(uri:, cid:, value:)) 566 + } 567 + 568 + fn decode_thread_response() -> decode.Decoder(decoders.ThreadCountsJson) { 569 + use thread <- field("thread", decoders.decode_thread_view()) 570 + case thread.post { 571 + Some(post_view) -> success(post_view.counts) 572 + None -> success(decoders.ThreadCountsJson(None, None, None, None)) 573 + } 574 + } 575 + 576 + // === Real sharkgirl.pet post tests === 577 + 578 + pub fn real_sharkgirl_post_test() { 579 + let json_string = 580 + "{\"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\"}}" 581 + 582 + from_json(json_string, decode_get_record(decoders.decode_post())) 583 + |> should.be_ok() 584 + |> fn(p) { 585 + p.value.text |> should.equal("niri has officially reached a billion tokens") 586 + p.value.created_at |> should.equal("2026-04-18T11:41:55.517Z") 587 + case p.value.embed { 588 + Some(decoders.Images(images)) -> list.length(images) |> should.equal(1) 589 + _ -> should.fail() 590 + } 591 + } 592 + } 593 + 594 + pub fn real_sharkgirl_profile_test() { 595 + let json_string = 596 + "{\"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\"}}" 597 + 598 + from_json(json_string, decode_get_record(decoders.decode_profile())) 599 + |> should.be_ok() 600 + |> fn(p) { 601 + p.value.display_name |> should.equal(Some("ana")) 602 + p.value.avatar 603 + |> should.equal(Some( 604 + "bafkreigvp4nenfvwfhxsjjwqqggnzz3jfmpeuixkhyt4ekugitbiskxmu4", 605 + )) 606 + } 607 + } 608 + 609 + pub fn real_sharkgirl_thread_test() { 610 + let json_string = 611 + "{\"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\"}}" 612 + 613 + from_json(json_string, decode_thread_response()) 614 + |> should.be_ok() 615 + |> fn(counts) { 616 + counts.like_count |> should.equal(Some(21)) 617 + counts.reply_count |> should.equal(Some(1)) 618 + counts.repost_count |> should.equal(Some(0)) 619 + counts.quote_count |> should.equal(Some(0)) 103 620 } 104 621 }
+10
test/fixtures/bsky/post.json
··· 2 2 "text": "Hello @alice.bsky.social! Check out https://example.com #bluesky #atproto", 3 3 "langs": ["en", "es"], 4 4 "createdAt": "2024-01-15T10:30:00.000Z", 5 + "reply": { 6 + "root": { 7 + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3mgroot123", 8 + "cid": "bafyreirootcid123" 9 + }, 10 + "parent": { 11 + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3mgparent456", 12 + "cid": "bafyreiparentcid456" 13 + } 14 + }, 5 15 "facets": [ 6 16 { 7 17 "index": {
+1
test/fixtures/bsky/real/mini_doc.json
··· 1 + {"did":"did:plc:kcgwlowulc3rac43lregdawo","handle":"karitham.dev","pds":"https://eurosky.social","signing_key":"zQ3shw7u4EzjTZvokgDjeQegByJVw2h7L24CEa1gFxE8fFyeg"}
+1
test/fixtures/bsky/real/post.json
··· 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"}}
+1
test/fixtures/bsky/real/profile.json
··· 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"}}
+1
test/fixtures/bsky/real/thread.json
··· 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"}}