small SPA gleam experiment to fetch and render a single bsky post
at main 536 lines 14 kB view raw
1import app/post 2import app/profile 3import gleam/dynamic/decode.{field, string, success} 4import gleam/int 5import gleam/list 6import gleam/option.{type Option, None, Some} 7import gleam/string 8import gleam/uri 9import lustre 10import lustre/attribute 11import lustre/effect.{type Effect} 12import lustre/element.{type Element} 13import lustre/element/html 14import lustre/event 15import rsvp 16 17pub fn main() { 18 let app = lustre.application(init, update, view) 19 let assert Ok(_) = lustre.start(app, "#app", Nil) 20 21 Nil 22} 23 24pub 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 33fn 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 45pub 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 53pub 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 102pub 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 114fn 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 138fn 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 147fn 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 155fn 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 182fn 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 221fn 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 287fn render_post_with_facets(text: String, _facets: List(post.Facet)) -> String { 288 text 289} 290 291fn 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 358fn 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 394fn 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 403fn 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 447fn 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 459pub 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 471pub fn construct_profile_uri(did: String) -> String { 472 get_record_query(did, "app.bsky.actor.profile", "self") 473} 474 475fn 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 494pub type Record(a) { 495 Record(uri: String, cid: String, value: a) 496} 497 498const slingshot_base = "https://slingshot.microcosm.blue" 499 500fn 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 509fn 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 518pub 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 530fn get_record_query(did, collection, rkey) -> String { 531 uri.query_to_string([ 532 #("repo", did), 533 #("collection", collection), 534 #("rkey", rkey), 535 ]) 536}