small SPA gleam experiment to fetch and render a single bsky post

initial commit

+1975
+8
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump 5 + 6 + #Added automatically by Lustre Dev Tools 7 + /.lustre 8 + /dist
+27
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1772736753, 6 + "narHash": "sha256-au/m3+EuBLoSzWUCb64a/MZq6QUtOV8oC0D9tY2scPQ=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "917fec990948658ef1ccd07cef2a1ef060786846", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "NixOS", 14 + "ref": "nixpkgs-unstable", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "root": { 20 + "inputs": { 21 + "nixpkgs": "nixpkgs" 22 + } 23 + } 24 + }, 25 + "root": "root", 26 + "version": 7 27 + }
+37
flake.nix
··· 1 + { 2 + inputs = { 3 + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 4 + }; 5 + outputs = 6 + { nixpkgs, ... }: 7 + let 8 + lib = nixpkgs.lib; 9 + systems = [ 10 + "x86_64-linux" 11 + "aarch64-linux" 12 + "x86_64-darwin" 13 + "aarch64-darwin" 14 + ]; 15 + in 16 + { 17 + devShells = lib.genAttrs systems ( 18 + system: 19 + let 20 + pkgs = import nixpkgs { inherit system; }; 21 + in 22 + { 23 + default = pkgs.mkShell { 24 + packages = [ 25 + pkgs.gleam 26 + pkgs.erlang 27 + pkgs.bun 28 + pkgs.tailwindcss_4 29 + pkgs.elixir 30 + pkgs.nodejs 31 + pkgs.rebar3 32 + ]; 33 + }; 34 + } 35 + ); 36 + }; 37 + }
+31
gleam.toml
··· 1 + name = "gpreview" 2 + version = "1.0.0" 3 + target = "javascript" 4 + 5 + # Fill out these fields if you intend to generate HTML documentation or publish 6 + # your project to the Hex package manager. 7 + # 8 + # description = "" 9 + # licences = ["Apache-2.0"] 10 + # repository = { type = "github", user = "", repo = "" } 11 + # links = [{ title = "Website", href = "" }] 12 + # 13 + # For a full reference of all the available options, you can have a look at 14 + # https://gleam.run/writing-gleam/gleam-toml/. 15 + 16 + [dependencies] 17 + gleam_stdlib = ">= 0.44.0 and < 2.0.0" 18 + lustre = ">= 5.6.0 and < 6.0.0" 19 + rsvp = ">= 1.2.0 and < 2.0.0" 20 + gleam_json = ">= 3.1.0 and < 4.0.0" 21 + 22 + [dev-dependencies] 23 + gleeunit = ">= 1.0.0 and < 2.0.0" 24 + lustre_dev_tools = ">= 2.3.4 and < 3.0.0" 25 + 26 + [tools.lustre] 27 + [tools.lustre.bin] 28 + bun = "system" 29 + tailwindcss = "system" 30 + [tools.lustre.html] 31 + title = "GPreview - Bluesky Post Viewer"
+54
manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 + { name = "booklet", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "booklet", source = "hex", outer_checksum = "08E0FDB78DC4D8A5D3C80295B021505C7D2A2E7B6C6D5EAB7286C36F4A53C851" }, 7 + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, 8 + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, 9 + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, 10 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 11 + { name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" }, 12 + { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, 13 + { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, 14 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 15 + { name = "gleam_fetch", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "2CBF9F2E1C71AEBBFB13A9D5720CD8DB4263EB02FE60C5A7A1C6E17B0151C20C" }, 16 + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 17 + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, 18 + { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, 19 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 20 + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 21 + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 22 + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, 23 + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, 24 + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 25 + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 26 + { name = "glint", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "2214C7CEFDE457CEE62140C3D4899B964E05236DA74E4243DFADF4AF29C382BB" }, 27 + { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, 28 + { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, 29 + { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" }, 30 + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, 31 + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, 32 + { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 33 + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 34 + { name = "lustre", version = "5.6.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "EE558CD4DB9F09FCC16417ADF0183A3C2DAC3E4B21ED3AC0CAE859792AB810CA" }, 35 + { name = "lustre_dev_tools", version = "2.3.4", build_tools = ["gleam"], requirements = ["argv", "booklet", "filepath", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib", "glint", "group_registry", "justin", "lustre", "mist", "polly", "simplifile", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "5D5C479E465A3EA018205EFCD2F2FE430A9B9783CAC21670E6CB25703069407D" }, 36 + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 37 + { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, 38 + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 39 + { name = "polly", version = "3.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_erlang", "gleam_otp", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "51FB565D81FF6212FDF3306D44419601F2A7C4EDD1F00FC9DA5C376A00AED4FE" }, 40 + { name = "rsvp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "rsvp", source = "hex", outer_checksum = "40F9E0E662FF258E10C7041A9591261FE802D56625FB444B91510969644F7722" }, 41 + { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, 42 + { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" }, 43 + { name = "telemetry", version = "1.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "D1FF426F988AC1092F9D684D34D08E51042A70567C16BE793FBC8F399FD2E77D" }, 44 + { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" }, 45 + { name = "wisp", version = "2.2.0", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "655163D4DE19E3DD4AC75813A991BFD5523CB4FF2FC5F9F58FD6FB39D5D1806D" }, 46 + ] 47 + 48 + [requirements] 49 + gleam_json = { version = ">= 3.1.0 and < 4.0.0" } 50 + gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 51 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 52 + lustre = { version = ">= 5.6.0 and < 6.0.0" } 53 + lustre_dev_tools = { version = ">= 2.3.4 and < 3.0.0" } 54 + rsvp = { version = ">= 1.2.0 and < 2.0.0" }
+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 + }
+205
src/gpreview.css
··· 1 + @import "tailwindcss"; 2 + 3 + :root { 4 + --color-sky-50: #f0f9ff; 5 + --color-sky-100: #e0f2fe; 6 + --color-sky-200: #bae6fd; 7 + --color-sky-300: #7dd3fc; 8 + --color-sky-400: #38bdf8; 9 + --color-sky-500: #0ea5e9; 10 + --color-sky-600: #0284c7; 11 + --color-sky-700: #0369a1; 12 + --color-sky-800: #075985; 13 + --color-sky-900: #0c4a6e; 14 + } 15 + 16 + body { 17 + background-color: var(--color-sky-50); 18 + -webkit-font-smoothing: antialiased; 19 + -moz-osx-font-smoothing: grayscale; 20 + } 21 + 22 + .card { 23 + background-color: white; 24 + border-radius: 0.75rem; 25 + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 26 + border: 1px solid #e2e8f0; 27 + overflow: hidden; 28 + transition: box-shadow 0.2s ease; 29 + } 30 + 31 + .card:hover { 32 + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 33 + } 34 + 35 + .btn-primary { 36 + padding: 1rem 1.5rem; 37 + background-color: var(--color-sky-500); 38 + color: white; 39 + border-radius: 0.75rem; 40 + font-weight: 500; 41 + transition: background-color 0.2s ease, box-shadow 0.2s ease; 42 + white-space: nowrap; 43 + } 44 + 45 + .btn-primary:hover { 46 + background-color: var(--color-sky-600); 47 + } 48 + 49 + .btn-primary:focus { 50 + outline: none; 51 + box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.3); 52 + } 53 + 54 + .input-primary { 55 + padding: 1rem 1.25rem; 56 + border: 1px solid #cbd5e1; 57 + border-radius: 0.75rem; 58 + transition: all 0.2s ease; 59 + } 60 + 61 + .input-primary:focus { 62 + outline: none; 63 + border-color: var(--color-sky-500); 64 + box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1); 65 + } 66 + 67 + .input-primary::placeholder { 68 + color: #94a3b8; 69 + } 70 + 71 + .avatar { 72 + width: 3.5rem; 73 + height: 3.5rem; 74 + border-radius: 50%; 75 + object-fit: cover; 76 + flex-shrink: 0; 77 + box-shadow: 0 0 0 2px #e0f2fe, 0 0 0 4px white; 78 + } 79 + 80 + .avatar-fallback { 81 + width: 3.5rem; 82 + height: 3.5rem; 83 + border-radius: 50%; 84 + background: linear-gradient(135deg, var(--color-sky-400), var(--color-sky-600)); 85 + display: flex; 86 + align-items: center; 87 + justify-content: center; 88 + color: white; 89 + font-size: 1.25rem; 90 + font-weight: 700; 91 + flex-shrink: 0; 92 + box-shadow: 0 0 0 2px #e0f2fe, 0 0 0 4px white; 93 + } 94 + 95 + .avatar-fallback-sm { 96 + width: 3rem; 97 + height: 3rem; 98 + border-radius: 50%; 99 + background: linear-gradient(135deg, var(--color-sky-400), var(--color-sky-600)); 100 + display: flex; 101 + align-items: center; 102 + justify-content: center; 103 + color: white; 104 + font-size: 1.125rem; 105 + font-weight: 700; 106 + flex-shrink: 0; 107 + box-shadow: 0 0 0 2px #e0f2fe; 108 + } 109 + 110 + .loading-spinner { 111 + animation: spin 1s linear infinite; 112 + border-radius: 50%; 113 + border: 2px solid #e2e8f0; 114 + border-top-color: var(--color-sky-500); 115 + } 116 + 117 + @keyframes spin { 118 + to { 119 + transform: rotate(360deg); 120 + } 121 + } 122 + 123 + .link-card { 124 + display: block; 125 + border: 1px solid #e2e8f0; 126 + border-radius: 0.5rem; 127 + overflow: hidden; 128 + transition: all 0.2s ease; 129 + } 130 + 131 + .link-card:hover { 132 + border-color: #bae6fd; 133 + background-color: #f8fafc; 134 + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 135 + } 136 + 137 + .image-grid { 138 + display: grid; 139 + grid-template-columns: repeat(2, 1fr); 140 + gap: 0.5rem; 141 + border-radius: 0.5rem; 142 + overflow: hidden; 143 + margin-top: 1rem; 144 + } 145 + 146 + .image-grid img { 147 + width: 100%; 148 + height: auto; 149 + object-fit: cover; 150 + transition: transform 0.2s ease; 151 + } 152 + 153 + .image-grid img:hover { 154 + transform: scale(1.05); 155 + } 156 + 157 + .label-badge { 158 + display: inline-flex; 159 + align-items: center; 160 + padding: 0.125rem 0.625rem; 161 + border-radius: 9999px; 162 + font-size: 0.75rem; 163 + font-weight: 500; 164 + background-color: #fef3c7; 165 + color: #b45309; 166 + border: 1px solid #fcd34d; 167 + } 168 + 169 + .post-content { 170 + color: #334155; 171 + font-size: 1.125rem; 172 + line-height: 1.75; 173 + white-space: pre-wrap; 174 + word-break: break-word; 175 + } 176 + 177 + .external-link-preview { 178 + padding: 1rem; 179 + } 180 + 181 + .external-link-title { 182 + font-weight: 600; 183 + color: #0f172a; 184 + font-size: 1rem; 185 + margin-bottom: 0.25rem; 186 + } 187 + 188 + .external-link-desc { 189 + font-size: 0.875rem; 190 + color: #475569; 191 + display: -webkit-box; 192 + -webkit-line-clamp: 2; 193 + -webkit-box-orient: vertical; 194 + overflow: hidden; 195 + } 196 + 197 + @media (prefers-reduced-motion: reduce) { 198 + *, 199 + *::before, 200 + *::after { 201 + animation-duration: 0.01ms !important; 202 + animation-iteration-count: 1 !important; 203 + transition-duration: 0.01ms !important; 204 + } 205 + }
+536
src/gpreview.gleam
··· 1 + import app/post 2 + import app/profile 3 + import gleam/dynamic/decode.{field, string, success} 4 + import gleam/int 5 + import gleam/list 6 + import gleam/option.{type Option, None, Some} 7 + import gleam/string 8 + import gleam/uri 9 + import lustre 10 + import lustre/attribute 11 + import lustre/effect.{type Effect} 12 + import lustre/element.{type Element} 13 + import lustre/element/html 14 + import lustre/event 15 + import rsvp 16 + 17 + pub fn main() { 18 + let app = lustre.application(init, update, view) 19 + let assert Ok(_) = lustre.start(app, "#app", Nil) 20 + 21 + Nil 22 + } 23 + 24 + pub type Model { 25 + App( 26 + 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)), 30 + ) 31 + } 32 + 33 + fn init(_args) -> #(Model, Effect(Msg)) { 34 + #( 35 + App( 36 + "at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mgibe2arpk2c", 37 + None, 38 + None, 39 + None, 40 + ), 41 + effect.none(), 42 + ) 43 + } 44 + 45 + pub type Msg { 46 + LinkWasSet(String) 47 + UserClickedShow 48 + MiniDocWasResolved(Result(profile.MiniDoc, rsvp.Error)) 49 + PostWasFetched(Result(Record(post.Post), rsvp.Error)) 50 + ProfileWasFetched(Result(Record(profile.Profile), rsvp.Error)) 51 + } 52 + 53 + pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 54 + case msg { 55 + LinkWasSet(url) -> #(App(..model, at_url: url), effect.none()) 56 + UserClickedShow -> 57 + case extract_did_from_uri(model.at_url) { 58 + Ok(did) -> #(model, resolve_mini_doc(did)) 59 + Error(_) -> #( 60 + App(..model, post: Some(Error("Invalid AT-URI"))), 61 + effect.none(), 62 + ) 63 + } 64 + MiniDocWasResolved(Ok(mini_doc)) -> #( 65 + App(..model, did_doc: Some(Ok(mini_doc))), 66 + get_record(mini_doc.pds, model.at_url), 67 + ) 68 + MiniDocWasResolved(Error(e)) -> #( 69 + App( 70 + ..model, 71 + post: Some(Error("Failed to resolve identity: " <> error_to_string(e))), 72 + ), 73 + effect.none(), 74 + ) 75 + PostWasFetched(Ok(p)) -> { 76 + case model.did_doc { 77 + Some(Ok(doc)) -> #( 78 + App(..model, post: Some(Ok(p))), 79 + fetch_profile(doc.pds, doc.did), 80 + ) 81 + _ -> #(App(..model, post: Some(Ok(p))), effect.none()) 82 + } 83 + } 84 + PostWasFetched(Error(e)) -> #( 85 + App(..model, post: Some(Error(error_to_string(e)))), 86 + effect.none(), 87 + ) 88 + ProfileWasFetched(Ok(p)) -> #( 89 + App(..model, profile: Some(Ok(p.value))), 90 + effect.none(), 91 + ) 92 + ProfileWasFetched(Error(e)) -> #( 93 + App( 94 + ..model, 95 + profile: Some(Error("Failed to fetch profile: " <> error_to_string(e))), 96 + ), 97 + effect.none(), 98 + ) 99 + } 100 + } 101 + 102 + pub fn error_to_string(e: rsvp.Error) -> String { 103 + case e { 104 + rsvp.BadBody -> "Invalid response body" 105 + rsvp.BadUrl(url) -> "Invalid URL: " <> url 106 + rsvp.HttpError(resp) -> "HTTP error: " <> int.to_string(resp.status) 107 + rsvp.JsonError(_) -> "Failed to parse JSON response" 108 + rsvp.NetworkError -> "Network error - check your connection" 109 + rsvp.UnhandledResponse(resp) -> 110 + "Unexpected response: " <> int.to_string(resp.status) 111 + } 112 + } 113 + 114 + fn view(model: Model) -> Element(Msg) { 115 + html.div( 116 + [ 117 + attribute.attribute("class", "min-h-screen p-6 sm:p-12"), 118 + ], 119 + [ 120 + html.div( 121 + [ 122 + attribute.attribute("class", "max-w-2xl mx-auto"), 123 + ], 124 + [ 125 + html.div( 126 + [ 127 + attribute.attribute("class", "mb-10"), 128 + ], 129 + [url_input(model.at_url, get_post_error(model.post))], 130 + ), 131 + display_post(model), 132 + ], 133 + ), 134 + ], 135 + ) 136 + } 137 + 138 + fn get_post_error( 139 + post: Option(Result(Record(post.Post), String)), 140 + ) -> Option(String) { 141 + case post { 142 + Some(Error(e)) -> Some(e) 143 + _ -> None 144 + } 145 + } 146 + 147 + fn display_post(model: Model) -> Element(Msg) { 148 + case model.post, model.profile { 149 + Some(_), Some(_) -> post_card(model.post, model.profile, model.did_doc) 150 + Some(Ok(_)), None -> loading_state() 151 + _, _ -> element.none() 152 + } 153 + } 154 + 155 + fn loading_state() -> Element(Msg) { 156 + html.div( 157 + [ 158 + attribute.attribute("class", "card"), 159 + ], 160 + [ 161 + html.div( 162 + [ 163 + attribute.attribute( 164 + "class", 165 + "flex items-center justify-center gap-2 text-slate-500 py-12", 166 + ), 167 + ], 168 + [ 169 + html.div( 170 + [ 171 + attribute.attribute("class", "loading-spinner h-6 w-6"), 172 + ], 173 + [], 174 + ), 175 + html.text("Loading profile..."), 176 + ], 177 + ), 178 + ], 179 + ) 180 + } 181 + 182 + 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)), 186 + ) -> Element(Msg) { 187 + case post_opt, profile_opt, did_doc { 188 + Some(Ok(Record(uri: _, cid: _, value: post))), 189 + Some(Ok(profile)), 190 + Some(Ok(doc)) 191 + -> 192 + html.div( 193 + [ 194 + attribute.attribute("class", "card"), 195 + ], 196 + [ 197 + post_header(profile, doc.did, doc.pds), 198 + html.div( 199 + [ 200 + attribute.attribute("class", "px-6 pb-4"), 201 + ], 202 + [ 203 + html.p( 204 + [ 205 + attribute.attribute("class", "post-content"), 206 + ], 207 + [html.text(render_post_with_facets(post.text, post.facets))], 208 + ), 209 + post_embed(post.embed), 210 + post_footer(post.labels, post.created_at), 211 + ], 212 + ), 213 + ], 214 + ) 215 + Some(Error(e)), _, _ | _, Some(Error(e)), _ | _, _, Some(Error(e)) -> 216 + html.text(e) 217 + _, _, _ -> element.none() 218 + } 219 + } 220 + 221 + fn post_header( 222 + profile: profile.Profile, 223 + did: String, 224 + pds_host: String, 225 + ) -> Element(Msg) { 226 + html.div( 227 + [ 228 + attribute.attribute( 229 + "class", 230 + "flex items-center gap-3 mb-4 px-6 pt-4 pb-2", 231 + ), 232 + ], 233 + [ 234 + case profile.avatar { 235 + Some(blob) -> 236 + html.img([ 237 + attribute.attribute( 238 + "src", 239 + profile.blob_ref_to_url(pds_host, did, blob), 240 + ), 241 + attribute.attribute("alt", "Avatar"), 242 + attribute.attribute("class", "avatar"), 243 + attribute.attribute("referrerpolicy", "no-referrer"), 244 + ]) 245 + None -> 246 + html.div( 247 + [ 248 + attribute.attribute("class", "avatar-fallback-sm"), 249 + ], 250 + [], 251 + ) 252 + }, 253 + html.div( 254 + [ 255 + attribute.attribute("class", "flex flex-col min-w-0"), 256 + ], 257 + [ 258 + html.div( 259 + [ 260 + attribute.attribute( 261 + "class", 262 + "font-semibold text-slate-900 text-base leading-tight", 263 + ), 264 + ], 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 + ], 280 + ), 281 + ], 282 + ), 283 + ], 284 + ) 285 + } 286 + 287 + fn render_post_with_facets(text: String, _facets: List(post.Facet)) -> String { 288 + text 289 + } 290 + 291 + fn post_embed(embed: Option(post.Embed)) -> Element(Msg) { 292 + case embed { 293 + None -> element.none() 294 + Some(embed_obj) -> 295 + case embed_obj { 296 + post.Images(images) -> 297 + html.div( 298 + [ 299 + attribute.attribute("class", "image-grid"), 300 + ], 301 + list.map(images, fn(img) { 302 + html.img([ 303 + attribute.attribute( 304 + "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", 306 + ), 307 + attribute.attribute("alt", img.alt), 308 + attribute.attribute("class", "w-full h-auto object-cover"), 309 + attribute.attribute("referrerpolicy", "no-referrer"), 310 + ]) 311 + }), 312 + ) 313 + post.ExternalLink(external) -> 314 + html.a( 315 + [ 316 + attribute.attribute("href", external.uri), 317 + attribute.attribute("target", "_blank"), 318 + attribute.attribute("rel", "noopener noreferrer"), 319 + attribute.attribute("class", "link-card"), 320 + ], 321 + [ 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 + html.div( 333 + [ 334 + attribute.attribute("class", "external-link-preview"), 335 + ], 336 + [ 337 + html.h3( 338 + [ 339 + attribute.attribute("class", "external-link-title"), 340 + ], 341 + [html.text(external.title)], 342 + ), 343 + html.p( 344 + [ 345 + attribute.attribute("class", "external-link-desc"), 346 + ], 347 + [html.text(external.description)], 348 + ), 349 + ], 350 + ), 351 + ], 352 + ) 353 + post.Record(r) -> html.text(r.uri) 354 + } 355 + } 356 + } 357 + 358 + fn post_footer(labels: List(post.Label), created_at: String) -> Element(Msg) { 359 + html.div( 360 + [ 361 + attribute.attribute( 362 + "class", 363 + "flex items-center justify-between mt-4 pt-4 border-t border-slate-100", 364 + ), 365 + ], 366 + [ 367 + html.p( 368 + [ 369 + attribute.attribute("class", "text-xs text-slate-400"), 370 + ], 371 + [html.text(format_timestamp(created_at))], 372 + ), 373 + case labels { 374 + [] -> element.none() 375 + _ -> 376 + html.div( 377 + [ 378 + attribute.attribute("class", "flex gap-1"), 379 + ], 380 + list.map(labels, fn(label) { 381 + html.span( 382 + [ 383 + attribute.attribute("class", "label-badge"), 384 + ], 385 + [html.text(label.val)], 386 + ) 387 + }), 388 + ) 389 + }, 390 + ], 391 + ) 392 + } 393 + 394 + fn format_timestamp(iso_timestamp: String) -> String { 395 + let parsed_date = iso_timestamp 396 + let parts = string.split(parsed_date, "T") 397 + case parts { 398 + [date_part, ..] -> date_part 399 + _ -> iso_timestamp 400 + } 401 + } 402 + 403 + fn url_input(at_url: String, error_string: Option(String)) -> Element(Msg) { 404 + html.div( 405 + [ 406 + attribute.attribute("class", "flex flex-col gap-4"), 407 + ], 408 + [ 409 + html.div( 410 + [ 411 + attribute.attribute("class", "flex gap-3 items-stretch"), 412 + ], 413 + [ 414 + html.input([ 415 + event.on_change(LinkWasSet), 416 + attribute.inputmode("text"), 417 + attribute.value(at_url), 418 + attribute.attribute( 419 + "placeholder", 420 + "at://did:plc:.../app.bsky.feed.post/...", 421 + ), 422 + attribute.attribute("class", "input-primary flex-grow min-w-0"), 423 + ]), 424 + html.button( 425 + [ 426 + event.on_click(UserClickedShow), 427 + attribute.attribute("class", "btn-primary flex-shrink-0"), 428 + ], 429 + [html.text("Show")], 430 + ), 431 + ], 432 + ), 433 + case error_string { 434 + None -> element.none() 435 + Some(s) -> 436 + html.p( 437 + [ 438 + attribute.attribute("class", "text-red-600 text-sm"), 439 + ], 440 + [html.text(s)], 441 + ) 442 + }, 443 + ], 444 + ) 445 + } 446 + 447 + fn fetch_profile(pds_host: String, did: String) -> Effect(Msg) { 448 + rsvp.get( 449 + pds_host 450 + <> "/xrpc/com.atproto.repo.getRecord?" 451 + <> construct_profile_uri(did), 452 + rsvp.expect_json( 453 + decode_get_record_response(profile.decode_profile()), 454 + ProfileWasFetched, 455 + ), 456 + ) 457 + } 458 + 459 + pub fn extract_did_from_uri(uri: String) -> Result(String, Nil) { 460 + let u = case uri { 461 + "at://" <> rest -> rest 462 + _ -> uri 463 + } 464 + 465 + case string.split(u, "/") { 466 + [did, ..] -> Ok(did) 467 + _ -> Error(Nil) 468 + } 469 + } 470 + 471 + pub fn construct_profile_uri(did: String) -> String { 472 + get_record_query(did, "app.bsky.actor.profile", "self") 473 + } 474 + 475 + fn get_record(pds_host: String, at_url: String) -> Effect(Msg) { 476 + case query_from_at_uri(at_url) { 477 + Error(Nil) -> { 478 + use dispatch <- effect.from 479 + dispatch(PostWasFetched(Error(rsvp.BadBody))) 480 + } 481 + Ok(query) -> { 482 + let url = pds_host <> "/xrpc/com.atproto.repo.getRecord?" <> query 483 + rsvp.get( 484 + url, 485 + rsvp.expect_json( 486 + decode_get_record_response(post.decode_post()), 487 + PostWasFetched, 488 + ), 489 + ) 490 + } 491 + } 492 + } 493 + 494 + pub type Record(a) { 495 + Record(uri: String, cid: String, value: a) 496 + } 497 + 498 + const slingshot_base = "https://slingshot.microcosm.blue" 499 + 500 + fn resolve_mini_doc(identifier: String) -> Effect(Msg) { 501 + rsvp.get( 502 + slingshot_base 503 + <> "/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" 504 + <> identifier, 505 + rsvp.expect_json(profile.decode_mini_doc(), MiniDocWasResolved), 506 + ) 507 + } 508 + 509 + fn decode_get_record_response( 510 + decoder: decode.Decoder(a), 511 + ) -> decode.Decoder(Record(a)) { 512 + use uri <- field("uri", string) 513 + use cid <- field("cid", string) 514 + use value <- field("value", decoder) 515 + success(Record(uri:, cid:, value:)) 516 + } 517 + 518 + pub fn query_from_at_uri(at_url: String) -> Result(String, Nil) { 519 + let u = case at_url { 520 + "at://" <> rest -> rest 521 + _ -> at_url 522 + } 523 + 524 + case string.split(u, "/") { 525 + [did, collection, rkey] -> Ok(get_record_query(did, collection, rkey)) 526 + _ -> Error(Nil) 527 + } 528 + } 529 + 530 + fn get_record_query(did, collection, rkey) -> String { 531 + uri.query_to_string([ 532 + #("repo", did), 533 + #("collection", collection), 534 + #("rkey", rkey), 535 + ]) 536 + }
+60
src/lexicons/app/bsky/actor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.actor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A declaration of a Bluesky account profile.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "properties": { 12 + "displayName": { 13 + "type": "string", 14 + "maxGraphemes": 64, 15 + "maxLength": 640 16 + }, 17 + "description": { 18 + "type": "string", 19 + "description": "Free-form profile description text.", 20 + "maxGraphemes": 256, 21 + "maxLength": 2560 22 + }, 23 + "pronouns": { 24 + "type": "string", 25 + "description": "Free-form pronouns text.", 26 + "maxGraphemes": 20, 27 + "maxLength": 200 28 + }, 29 + "website": { "type": "string", "format": "uri" }, 30 + "avatar": { 31 + "type": "blob", 32 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'", 33 + "accept": ["image/png", "image/jpeg"], 34 + "maxSize": 1000000 35 + }, 36 + "banner": { 37 + "type": "blob", 38 + "description": "Larger horizontal image to display behind profile view.", 39 + "accept": ["image/png", "image/jpeg"], 40 + "maxSize": 1000000 41 + }, 42 + "labels": { 43 + "type": "union", 44 + "description": "Self-label values, specific to the Bluesky application, on the overall account.", 45 + "refs": ["com.atproto.label.defs#selfLabels"] 46 + }, 47 + "joinedViaStarterPack": { 48 + "type": "ref", 49 + "ref": "com.atproto.repo.strongRef" 50 + }, 51 + "pinnedPost": { 52 + "type": "ref", 53 + "ref": "com.atproto.repo.strongRef" 54 + }, 55 + "createdAt": { "type": "string", "format": "datetime" } 56 + } 57 + } 58 + } 59 + } 60 + }
+51
src/lexicons/app/bsky/embed/external.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.embed.external", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "A representation of some externally linked content (eg, a URL and 'card'), embedded in a Bluesky record (eg, a post).", 8 + "required": ["external"], 9 + "properties": { 10 + "external": { 11 + "type": "ref", 12 + "ref": "#external" 13 + } 14 + } 15 + }, 16 + "external": { 17 + "type": "object", 18 + "required": ["uri", "title", "description"], 19 + "properties": { 20 + "uri": { "type": "string", "format": "uri" }, 21 + "title": { "type": "string" }, 22 + "description": { "type": "string" }, 23 + "thumb": { 24 + "type": "blob", 25 + "accept": ["image/*"], 26 + "maxSize": 1000000 27 + } 28 + } 29 + }, 30 + "view": { 31 + "type": "object", 32 + "required": ["external"], 33 + "properties": { 34 + "external": { 35 + "type": "ref", 36 + "ref": "#viewExternal" 37 + } 38 + } 39 + }, 40 + "viewExternal": { 41 + "type": "object", 42 + "required": ["uri", "title", "description"], 43 + "properties": { 44 + "uri": { "type": "string", "format": "uri" }, 45 + "title": { "type": "string" }, 46 + "description": { "type": "string" }, 47 + "thumb": { "type": "string", "format": "uri" } 48 + } 49 + } 50 + } 51 + }
+72
src/lexicons/app/bsky/embed/images.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.embed.images", 4 + "description": "A set of images embedded in a Bluesky record (eg, a post).", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": ["images"], 9 + "properties": { 10 + "images": { 11 + "type": "array", 12 + "items": { "type": "ref", "ref": "#image" }, 13 + "maxLength": 4 14 + } 15 + } 16 + }, 17 + "image": { 18 + "type": "object", 19 + "required": ["image", "alt"], 20 + "properties": { 21 + "image": { 22 + "type": "blob", 23 + "accept": ["image/*"], 24 + "maxSize": 1000000 25 + }, 26 + "alt": { 27 + "type": "string", 28 + "description": "Alt text description of the image, for accessibility." 29 + }, 30 + "aspectRatio": { 31 + "type": "ref", 32 + "ref": "app.bsky.embed.defs#aspectRatio" 33 + } 34 + } 35 + }, 36 + "view": { 37 + "type": "object", 38 + "required": ["images"], 39 + "properties": { 40 + "images": { 41 + "type": "array", 42 + "items": { "type": "ref", "ref": "#viewImage" }, 43 + "maxLength": 4 44 + } 45 + } 46 + }, 47 + "viewImage": { 48 + "type": "object", 49 + "required": ["thumb", "fullsize", "alt"], 50 + "properties": { 51 + "thumb": { 52 + "type": "string", 53 + "format": "uri", 54 + "description": "Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View." 55 + }, 56 + "fullsize": { 57 + "type": "string", 58 + "format": "uri", 59 + "description": "Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View." 60 + }, 61 + "alt": { 62 + "type": "string", 63 + "description": "Alt text description of the image, for accessibility." 64 + }, 65 + "aspectRatio": { 66 + "type": "ref", 67 + "ref": "app.bsky.embed.defs#aspectRatio" 68 + } 69 + } 70 + } 71 + } 72 + }
+96
src/lexicons/app/bsky/embed/record.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.embed.record", 4 + "description": "A representation of a record embedded in a Bluesky record (eg, a post). For example, a quote-post, or sharing a feed generator record.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": ["record"], 9 + "properties": { 10 + "record": { "type": "ref", "ref": "com.atproto.repo.strongRef" } 11 + } 12 + }, 13 + "view": { 14 + "type": "object", 15 + "required": ["record"], 16 + "properties": { 17 + "record": { 18 + "type": "union", 19 + "refs": [ 20 + "#viewRecord", 21 + "#viewNotFound", 22 + "#viewBlocked", 23 + "#viewDetached", 24 + "app.bsky.feed.defs#generatorView", 25 + "app.bsky.graph.defs#listView", 26 + "app.bsky.labeler.defs#labelerView", 27 + "app.bsky.graph.defs#starterPackViewBasic" 28 + ] 29 + } 30 + } 31 + }, 32 + "viewRecord": { 33 + "type": "object", 34 + "required": ["uri", "cid", "author", "value", "indexedAt"], 35 + "properties": { 36 + "uri": { "type": "string", "format": "at-uri" }, 37 + "cid": { "type": "string", "format": "cid" }, 38 + "author": { 39 + "type": "ref", 40 + "ref": "app.bsky.actor.defs#profileViewBasic" 41 + }, 42 + "value": { 43 + "type": "unknown", 44 + "description": "The record data itself." 45 + }, 46 + "labels": { 47 + "type": "array", 48 + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 49 + }, 50 + "replyCount": { "type": "integer" }, 51 + "repostCount": { "type": "integer" }, 52 + "likeCount": { "type": "integer" }, 53 + "quoteCount": { "type": "integer" }, 54 + "embeds": { 55 + "type": "array", 56 + "items": { 57 + "type": "union", 58 + "refs": [ 59 + "app.bsky.embed.images#view", 60 + "app.bsky.embed.video#view", 61 + "app.bsky.embed.external#view", 62 + "app.bsky.embed.record#view", 63 + "app.bsky.embed.recordWithMedia#view" 64 + ] 65 + } 66 + }, 67 + "indexedAt": { "type": "string", "format": "datetime" } 68 + } 69 + }, 70 + "viewNotFound": { 71 + "type": "object", 72 + "required": ["uri", "notFound"], 73 + "properties": { 74 + "uri": { "type": "string", "format": "at-uri" }, 75 + "notFound": { "type": "boolean", "const": true } 76 + } 77 + }, 78 + "viewBlocked": { 79 + "type": "object", 80 + "required": ["uri", "blocked", "author"], 81 + "properties": { 82 + "uri": { "type": "string", "format": "at-uri" }, 83 + "blocked": { "type": "boolean", "const": true }, 84 + "author": { "type": "ref", "ref": "app.bsky.feed.defs#blockedAuthor" } 85 + } 86 + }, 87 + "viewDetached": { 88 + "type": "object", 89 + "required": ["uri", "detached"], 90 + "properties": { 91 + "uri": { "type": "string", "format": "at-uri" }, 92 + "detached": { "type": "boolean", "const": true } 93 + } 94 + } 95 + } 96 + }
+96
src/lexicons/app/bsky/feed/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record containing a Bluesky post.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["text", "createdAt"], 12 + "properties": { 13 + "text": { 14 + "type": "string", 15 + "maxLength": 3000, 16 + "maxGraphemes": 300, 17 + "description": "The primary post content. May be an empty string, if there are embeds." 18 + }, 19 + "entities": { 20 + "type": "array", 21 + "description": "DEPRECATED: replaced by app.bsky.richtext.facet.", 22 + "items": { "type": "ref", "ref": "#entity" } 23 + }, 24 + "facets": { 25 + "type": "array", 26 + "description": "Annotations of text (mentions, URLs, hashtags, etc)", 27 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 28 + }, 29 + "reply": { "type": "ref", "ref": "#replyRef" }, 30 + "embed": { 31 + "type": "union", 32 + "refs": [ 33 + "app.bsky.embed.images", 34 + "app.bsky.embed.video", 35 + "app.bsky.embed.external", 36 + "app.bsky.embed.record", 37 + "app.bsky.embed.recordWithMedia" 38 + ] 39 + }, 40 + "langs": { 41 + "type": "array", 42 + "description": "Indicates human language of post primary text content.", 43 + "maxLength": 3, 44 + "items": { "type": "string", "format": "language" } 45 + }, 46 + "labels": { 47 + "type": "union", 48 + "description": "Self-label values for this post. Effectively content warnings.", 49 + "refs": ["com.atproto.label.defs#selfLabels"] 50 + }, 51 + "tags": { 52 + "type": "array", 53 + "description": "Additional hashtags, in addition to any included in post text and facets.", 54 + "maxLength": 8, 55 + "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } 56 + }, 57 + "createdAt": { 58 + "type": "string", 59 + "format": "datetime", 60 + "description": "Client-declared timestamp when this post was originally created." 61 + } 62 + } 63 + } 64 + }, 65 + "replyRef": { 66 + "type": "object", 67 + "required": ["root", "parent"], 68 + "properties": { 69 + "root": { "type": "ref", "ref": "com.atproto.repo.strongRef" }, 70 + "parent": { "type": "ref", "ref": "com.atproto.repo.strongRef" } 71 + } 72 + }, 73 + "entity": { 74 + "type": "object", 75 + "description": "Deprecated: use facets instead.", 76 + "required": ["index", "type", "value"], 77 + "properties": { 78 + "index": { "type": "ref", "ref": "#textSlice" }, 79 + "type": { 80 + "type": "string", 81 + "description": "Expected values are 'mention' and 'link'." 82 + }, 83 + "value": { "type": "string" } 84 + } 85 + }, 86 + "textSlice": { 87 + "type": "object", 88 + "description": "Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings.", 89 + "required": ["start", "end"], 90 + "properties": { 91 + "start": { "type": "integer", "minimum": 0 }, 92 + "end": { "type": "integer", "minimum": 0 } 93 + } 94 + } 95 + } 96 + }
+51
src/lexicons/app/bsky/richtext/facet.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.richtext.facet", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Annotation of a sub-string within rich text.", 8 + "required": ["index", "features"], 9 + "properties": { 10 + "index": { "type": "ref", "ref": "#byteSlice" }, 11 + "features": { 12 + "type": "array", 13 + "items": { "type": "union", "refs": ["#mention", "#link", "#tag"] } 14 + } 15 + } 16 + }, 17 + "mention": { 18 + "type": "object", 19 + "description": "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.", 20 + "required": ["did"], 21 + "properties": { 22 + "did": { "type": "string", "format": "did" } 23 + } 24 + }, 25 + "link": { 26 + "type": "object", 27 + "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", 28 + "required": ["uri"], 29 + "properties": { 30 + "uri": { "type": "string", "format": "uri" } 31 + } 32 + }, 33 + "tag": { 34 + "type": "object", 35 + "description": "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').", 36 + "required": ["tag"], 37 + "properties": { 38 + "tag": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } 39 + } 40 + }, 41 + "byteSlice": { 42 + "type": "object", 43 + "description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.", 44 + "required": ["byteStart", "byteEnd"], 45 + "properties": { 46 + "byteStart": { "type": "integer", "minimum": 0 }, 47 + "byteEnd": { "type": "integer", "minimum": 0 } 48 + } 49 + } 50 + } 51 + }
+104
test/bsky_test.gleam
··· 1 + import app/post 2 + import app/profile 3 + import gleam/dynamic/decode 4 + import gleam/json 5 + import gleeunit 6 + import gleeunit/should 7 + 8 + pub fn main() { 9 + gleeunit.main() 10 + } 11 + 12 + fn from_json( 13 + json_string: String, 14 + decoder: decode.Decoder(a), 15 + ) -> Result(a, json.DecodeError) { 16 + json.parse(from: json_string, using: decoder) 17 + } 18 + 19 + pub fn decode_strong_ref_test() { 20 + let json_string = 21 + "{\"uri\":\"at://did:plc:123/app.bsky.feed.post/abc\",\"cid\":\"bafyre123\"}" 22 + 23 + from_json(json_string, post.decode_strong_ref()) 24 + |> should.be_ok() 25 + |> fn(p) { 26 + p.uri |> should.equal("at://did:plc:123/app.bsky.feed.post/abc") 27 + p.cid |> should.equal("bafyre123") 28 + } 29 + } 30 + 31 + pub fn decode_label_test() { 32 + let json_string = "{\"val\":\"nsfw\"}" 33 + 34 + from_json(json_string, post.decode_label()) 35 + |> should.be_ok() 36 + |> fn(l) { l.val |> should.equal("nsfw") } 37 + } 38 + 39 + pub fn decode_facet_test() { 40 + let json_string = 41 + "{\"index\":{\"byteStart\":0,\"byteEnd\":5},\"features\":[{\"$type\":\"app.bsky.richtext.facet#mention\",\"did\":\"did:plc:456\"}]}" 42 + 43 + from_json(json_string, post.decode_facet()) 44 + |> should.be_ok() 45 + |> fn(f) { 46 + f.index.byte_start |> should.equal(0) 47 + f.index.byte_end |> should.equal(5) 48 + } 49 + } 50 + 51 + pub fn decode_image_test() { 52 + let json_string = "{\"alt\":\"A cat image\"}" 53 + 54 + from_json(json_string, post.decode_image()) 55 + |> should.be_ok() 56 + |> fn(i) { i.alt |> should.equal("A cat image") } 57 + } 58 + 59 + pub fn decode_external_test() { 60 + let json_string = 61 + "{\"uri\":\"https://example.com\",\"title\":\"Example\",\"description\":\"An example site\"}" 62 + 63 + from_json(json_string, post.decode_external()) 64 + |> should.be_ok() 65 + |> fn(e) { 66 + e.uri |> should.equal("https://example.com") 67 + e.title |> should.equal("Example") 68 + e.description |> should.equal("An example site") 69 + } 70 + } 71 + 72 + pub fn decode_embed_test() { 73 + let json_string = 74 + "{\"$type\":\"app.bsky.embed.images\",\"images\":[{\"alt\":\"Image 1\"}]}" 75 + 76 + from_json(json_string, post.decode_embed()) 77 + |> should.be_ok() 78 + } 79 + 80 + pub fn decode_post_test() { 81 + let json_string = 82 + "{\"text\":\"Hello world\",\"createdAt\":\"2024-01-01T00:00:00.000Z\"}" 83 + 84 + from_json(json_string, post.decode_post()) 85 + |> should.be_ok() 86 + |> fn(p) { 87 + p.text |> should.equal("Hello world") 88 + p.created_at |> should.equal("2024-01-01T00:00:00.000Z") 89 + } 90 + } 91 + 92 + pub fn decode_mini_doc_test() { 93 + let json_string = 94 + "{\"did\":\"did:plc:abc123\",\"handle\":\"test.bsky.social\",\"pds\":\"https://example.pds.com\",\"signing_key\":\"zTestKey123\"}" 95 + 96 + from_json(json_string, profile.decode_mini_doc()) 97 + |> should.be_ok() 98 + |> 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") 103 + } 104 + }
+113
test/fixtures/bsky/embeds.json
··· 1 + { 2 + "images": { 3 + "$type": "app.bsky.embed.images", 4 + "images": [ 5 + { 6 + "image": { 7 + "$link": "bafkreihdilz3zvh3gkv7cm7g7l5v2g6g6g6g6g6g6g6g6g6g6g6g6g6g6g6g6g6g" 8 + }, 9 + "alt": "A beautiful sunset over the mountains with orange and pink hues", 10 + "aspectRatio": { 11 + "width": 1920, 12 + "height": 1080 13 + } 14 + }, 15 + { 16 + "image": { 17 + "$link": "bafkreigh5aer2g4h4h4h4h4h4h4h4h4h4h4h4h4h4h4h4h4h4" 18 + }, 19 + "alt": "A cat sleeping peacefully on a couch", 20 + "aspectRatio": { 21 + "width": 1200, 22 + "height": 800 23 + } 24 + }, 25 + { 26 + "image": { 27 + "$link": "bafkreifj3k4l5m6n7o8p9q0r1s2t3u4v5w6x7y8z9a0b1c2d3e4f5g6" 28 + }, 29 + "alt": "City skyline at night with lights", 30 + "aspectRatio": { 31 + "width": 1600, 32 + "height": 900 33 + } 34 + } 35 + ] 36 + }, 37 + "external": { 38 + "$type": "app.bsky.embed.external", 39 + "external": { 40 + "uri": "https://example.com/article", 41 + "title": "The Future of Bluesky", 42 + "description": "An in-depth look at the AT Protocol and how Bluesky is building a better social network.", 43 + "thumb": { 44 + "$link": "bafkreiacmno4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6" 45 + } 46 + } 47 + }, 48 + "external_no_thumb": { 49 + "$type": "app.bsky.embed.external", 50 + "external": { 51 + "uri": "https://bsky.app", 52 + "title": "Bluesky", 53 + "description": "Welcome to Bluesky" 54 + } 55 + }, 56 + "record": { 57 + "$type": "app.bsky.embed.record", 58 + "record": { 59 + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3k5d7f6nvccl2", 60 + "cid": "bafyreihn2k6sqf65g7cmv7g7l5v2g6g6g6g6g6g6g6g6g6g6g6g6g6g" 61 + } 62 + }, 63 + "images_view": { 64 + "$type": "app.bsky.embed.images", 65 + "images": [ 66 + { 67 + "thumb": "https://cdn.bsky.app/img/thing/fullsize/bafkreihdilz3zvh3gkv7cm7g7l5v2g", 68 + "fullsize": "https://cdn.bsky.app/img/thing/fullsize/bafkreihdilz3zvh3gkv7cm7g7l5v2g", 69 + "alt": "A beautiful sunset", 70 + "aspectRatio": { 71 + "width": 1920, 72 + "height": 1080 73 + } 74 + } 75 + ] 76 + }, 77 + "external_view": { 78 + "$type": "app.bsky.embed.external", 79 + "external": { 80 + "uri": "https://example.com/article", 81 + "title": "The Future of Bluesky", 82 + "description": "An in-depth look at the AT Protocol", 83 + "thumb": "https://cdn.bsky.app/img/thing/fullsize/bafkreiacmno4d5e6f7g8h9i0j1k2l3" 84 + } 85 + }, 86 + "record_view": { 87 + "$type": "app.bsky.embed.record", 88 + "record": { 89 + "$type": "app.bsky.embed.record#viewRecord", 90 + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3k5d7f6nvccl2", 91 + "cid": "bafyreihn2k6sqf65g7cmv7g7l5v2g6g6g6g6g6g6g6g6g6g6g6g6g6g", 92 + "author": { 93 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", 94 + "handle": "alice.bsky.social", 95 + "displayName": "Alice Smith", 96 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/avatar.png" 97 + }, 98 + "value": { 99 + "$type": "app.bsky.feed.post", 100 + "text": "This is a quoted post!", 101 + "createdAt": "2024-01-10T12:00:00.000Z" 102 + }, 103 + "labels": { 104 + "values": [] 105 + }, 106 + "replyCount": 5, 107 + "repostCount": 10, 108 + "likeCount": 42, 109 + "quoteCount": 3, 110 + "indexedAt": "2024-01-10T12:00:00.000Z" 111 + } 112 + } 113 + }
+104
test/fixtures/bsky/facets.json
··· 1 + { 2 + "mention": { 3 + "index": { 4 + "byteStart": 0, 5 + "byteEnd": 15 6 + }, 7 + "features": [ 8 + { 9 + "$type": "app.bsky.richtext.facet#mention", 10 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur" 11 + } 12 + ] 13 + }, 14 + "link": { 15 + "index": { 16 + "byteStart": 20, 17 + "byteEnd": 40 18 + }, 19 + "features": [ 20 + { 21 + "$type": "app.bsky.richtext.facet#link", 22 + "uri": "https://example.com/very-long-url-path" 23 + } 24 + ] 25 + }, 26 + "tag": { 27 + "index": { 28 + "byteStart": 50, 29 + "byteEnd": 58 30 + }, 31 + "features": [ 32 + { 33 + "$type": "app.bsky.richtext.facet#tag", 34 + "tag": "bluesky" 35 + } 36 + ] 37 + }, 38 + "multiple_features": { 39 + "index": { 40 + "byteStart": 0, 41 + "byteEnd": 30 42 + }, 43 + "features": [ 44 + { 45 + "$type": "app.bsky.richtext.facet#mention", 46 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur" 47 + }, 48 + { 49 + "$type": "app.bsky.richtext.facet#tag", 50 + "tag": "featured" 51 + } 52 + ] 53 + }, 54 + "array": [ 55 + { 56 + "index": { 57 + "byteStart": 0, 58 + "byteEnd": 10 59 + }, 60 + "features": [ 61 + { 62 + "$type": "app.bsky.richtext.facet#mention", 63 + "did": "did:plc:abc123def456" 64 + } 65 + ] 66 + }, 67 + { 68 + "index": { 69 + "byteStart": 12, 70 + "byteEnd": 28 71 + }, 72 + "features": [ 73 + { 74 + "$type": "app.bsky.richtext.facet#link", 75 + "uri": "https://bsky.app" 76 + } 77 + ] 78 + }, 79 + { 80 + "index": { 81 + "byteStart": 30, 82 + "byteEnd": 38 83 + }, 84 + "features": [ 85 + { 86 + "$type": "app.bsky.richtext.facet#tag", 87 + "tag": "atproto" 88 + } 89 + ] 90 + }, 91 + { 92 + "index": { 93 + "byteStart": 40, 94 + "byteEnd": 49 95 + }, 96 + "features": [ 97 + { 98 + "$type": "app.bsky.richtext.facet#tag", 99 + "tag": "fediverse" 100 + } 101 + ] 102 + } 103 + ] 104 + }
+86
test/fixtures/bsky/post.json
··· 1 + { 2 + "text": "Hello @alice.bsky.social! Check out https://example.com #bluesky #atproto", 3 + "langs": ["en", "es"], 4 + "createdAt": "2024-01-15T10:30:00.000Z", 5 + "facets": [ 6 + { 7 + "index": { 8 + "byteStart": 7, 9 + "byteEnd": 22 10 + }, 11 + "features": [ 12 + { 13 + "$type": "app.bsky.richtext.facet#mention", 14 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur" 15 + } 16 + ] 17 + }, 18 + { 19 + "index": { 20 + "byteStart": 34, 21 + "byteEnd": 53 22 + }, 23 + "features": [ 24 + { 25 + "$type": "app.bsky.richtext.facet#link", 26 + "uri": "https://example.com" 27 + } 28 + ] 29 + }, 30 + { 31 + "index": { 32 + "byteStart": 55, 33 + "byteEnd": 63 34 + }, 35 + "features": [ 36 + { 37 + "$type": "app.bsky.richtext.facet#tag", 38 + "tag": "bluesky" 39 + } 40 + ] 41 + }, 42 + { 43 + "index": { 44 + "byteStart": 64, 45 + "byteEnd": 73 46 + }, 47 + "features": [ 48 + { 49 + "$type": "app.bsky.richtext.facet#tag", 50 + "tag": "atproto" 51 + } 52 + ] 53 + } 54 + ], 55 + "labels": { 56 + "values": [ 57 + { "val": "nsfw" } 58 + ] 59 + }, 60 + "tags": ["bluesky", "atproto"], 61 + "embed": { 62 + "$type": "app.bsky.embed.images", 63 + "images": [ 64 + { 65 + "image": { 66 + "$link": "bafkreihdilz3zvh3gkv7cm7g7l5v2g6g6g6g6g6g6g6g6g6g6g6g6g6g" 67 + }, 68 + "alt": "A beautiful sunset over the mountains", 69 + "aspectRatio": { 70 + "width": 1920, 71 + "height": 1080 72 + } 73 + }, 74 + { 75 + "image": { 76 + "$link": "bafkreigh5aer2g4h4h4h4h4h4h4h4h4h4h4h4h4h4h4h4h4h4" 77 + }, 78 + "alt": "A cat sleeping on a couch", 79 + "aspectRatio": { 80 + "width": 1200, 81 + "height": 800 82 + } 83 + } 84 + ] 85 + } 86 + }
+22
test/fixtures/bsky/profile.json
··· 1 + { 2 + "displayName": "Alice Smith", 3 + "description": "Software engineer and Bluesky enthusiast. Building the future of social media.", 4 + "pronouns": "she/her", 5 + "website": "https://alice.example.com", 6 + "avatar": { 7 + "$link": "bafkreiava3mwvt7lmp7s25z7a5x7s5z7a5x7s5z7a5x7s5z7a5x7s5z7" 8 + }, 9 + "banner": { 10 + "$link": "bafkreihc2d7dwnm4z4h4h4h4h4h4h4h4h4h4h4h4h4h4h4h4h4h4" 11 + }, 12 + "labels": { 13 + "values": [ 14 + { "val": "developer" } 15 + ] 16 + }, 17 + "pinnedPost": { 18 + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3k5d7f6nvccl2", 19 + "cid": "bafyreihn2k6sqf65g7cmv7g7l5v2g6g6g6g6g6g6g6g6g6g6g6g6g6g" 20 + }, 21 + "createdAt": "2023-07-20T14:30:00.000Z" 22 + }
+5
test/gpreview_test.gleam
··· 1 + import gleeunit 2 + 3 + pub fn main() { 4 + gleeunit.main() 5 + }