import app/post import app/profile import gleam/dynamic/decode.{field, string, success} import gleam/int import gleam/list import gleam/option.{type Option, None, Some} import gleam/string import gleam/uri import lustre import lustre/attribute import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/element/html import lustre/event import rsvp pub fn main() { let app = lustre.application(init, update, view) let assert Ok(_) = lustre.start(app, "#app", Nil) Nil } pub type Model { App( at_url: String, did_doc: Option(Result(profile.MiniDoc, String)), post: Option(Result(Record(post.Post), String)), profile: Option(Result(profile.Profile, String)), ) } fn init(_args) -> #(Model, Effect(Msg)) { #( App( "at://did:plc:kcgwlowulc3rac43lregdawo/app.bsky.feed.post/3mgibe2arpk2c", None, None, None, ), effect.none(), ) } pub type Msg { LinkWasSet(String) UserClickedShow MiniDocWasResolved(Result(profile.MiniDoc, rsvp.Error)) PostWasFetched(Result(Record(post.Post), rsvp.Error)) ProfileWasFetched(Result(Record(profile.Profile), rsvp.Error)) } pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case msg { LinkWasSet(url) -> #(App(..model, at_url: url), effect.none()) UserClickedShow -> case extract_did_from_uri(model.at_url) { Ok(did) -> #(model, resolve_mini_doc(did)) Error(_) -> #( App(..model, post: Some(Error("Invalid AT-URI"))), effect.none(), ) } MiniDocWasResolved(Ok(mini_doc)) -> #( App(..model, did_doc: Some(Ok(mini_doc))), get_record(mini_doc.pds, model.at_url), ) MiniDocWasResolved(Error(e)) -> #( App( ..model, post: Some(Error("Failed to resolve identity: " <> error_to_string(e))), ), effect.none(), ) PostWasFetched(Ok(p)) -> { case model.did_doc { Some(Ok(doc)) -> #( App(..model, post: Some(Ok(p))), fetch_profile(doc.pds, doc.did), ) _ -> #(App(..model, post: Some(Ok(p))), effect.none()) } } PostWasFetched(Error(e)) -> #( App(..model, post: Some(Error(error_to_string(e)))), effect.none(), ) ProfileWasFetched(Ok(p)) -> #( App(..model, profile: Some(Ok(p.value))), effect.none(), ) ProfileWasFetched(Error(e)) -> #( App( ..model, profile: Some(Error("Failed to fetch profile: " <> error_to_string(e))), ), effect.none(), ) } } pub fn error_to_string(e: rsvp.Error) -> String { case e { rsvp.BadBody -> "Invalid response body" rsvp.BadUrl(url) -> "Invalid URL: " <> url rsvp.HttpError(resp) -> "HTTP error: " <> int.to_string(resp.status) rsvp.JsonError(_) -> "Failed to parse JSON response" rsvp.NetworkError -> "Network error - check your connection" rsvp.UnhandledResponse(resp) -> "Unexpected response: " <> int.to_string(resp.status) } } fn view(model: Model) -> Element(Msg) { html.div( [ attribute.attribute("class", "min-h-screen p-6 sm:p-12"), ], [ html.div( [ attribute.attribute("class", "max-w-2xl mx-auto"), ], [ html.div( [ attribute.attribute("class", "mb-10"), ], [url_input(model.at_url, get_post_error(model.post))], ), display_post(model), ], ), ], ) } fn get_post_error( post: Option(Result(Record(post.Post), String)), ) -> Option(String) { case post { Some(Error(e)) -> Some(e) _ -> None } } fn display_post(model: Model) -> Element(Msg) { case model.post, model.profile { Some(_), Some(_) -> post_card(model.post, model.profile, model.did_doc) Some(Ok(_)), None -> loading_state() _, _ -> element.none() } } fn loading_state() -> Element(Msg) { html.div( [ attribute.attribute("class", "card"), ], [ html.div( [ attribute.attribute( "class", "flex items-center justify-center gap-2 text-slate-500 py-12", ), ], [ html.div( [ attribute.attribute("class", "loading-spinner h-6 w-6"), ], [], ), html.text("Loading profile..."), ], ), ], ) } fn post_card( post_opt: Option(Result(Record(post.Post), String)), profile_opt: Option(Result(profile.Profile, String)), did_doc: Option(Result(profile.MiniDoc, String)), ) -> Element(Msg) { case post_opt, profile_opt, did_doc { Some(Ok(Record(uri: _, cid: _, value: post))), Some(Ok(profile)), Some(Ok(doc)) -> html.div( [ attribute.attribute("class", "card"), ], [ post_header(profile, doc.did, doc.pds), html.div( [ attribute.attribute("class", "px-6 pb-4"), ], [ html.p( [ attribute.attribute("class", "post-content"), ], [html.text(render_post_with_facets(post.text, post.facets))], ), post_embed(post.embed), post_footer(post.labels, post.created_at), ], ), ], ) Some(Error(e)), _, _ | _, Some(Error(e)), _ | _, _, Some(Error(e)) -> html.text(e) _, _, _ -> element.none() } } fn post_header( profile: profile.Profile, did: String, pds_host: String, ) -> Element(Msg) { html.div( [ attribute.attribute( "class", "flex items-center gap-3 mb-4 px-6 pt-4 pb-2", ), ], [ case profile.avatar { Some(blob) -> html.img([ attribute.attribute( "src", profile.blob_ref_to_url(pds_host, did, blob), ), attribute.attribute("alt", "Avatar"), attribute.attribute("class", "avatar"), attribute.attribute("referrerpolicy", "no-referrer"), ]) None -> html.div( [ attribute.attribute("class", "avatar-fallback-sm"), ], [], ) }, html.div( [ attribute.attribute("class", "flex flex-col min-w-0"), ], [ html.div( [ attribute.attribute( "class", "font-semibold text-slate-900 text-base leading-tight", ), ], [ case profile.display_name { None -> element.none() Some(handle) -> html.div( [ attribute.attribute( "class", "text-sm text-slate-500 leading-tight mt-0.5", ), ], [html.text("@" <> handle)], ) }, ], ), ], ), ], ) } fn render_post_with_facets(text: String, _facets: List(post.Facet)) -> String { text } fn post_embed(embed: Option(post.Embed)) -> Element(Msg) { case embed { None -> element.none() Some(embed_obj) -> case embed_obj { post.Images(images) -> html.div( [ attribute.attribute("class", "image-grid"), ], list.map(images, fn(img) { html.img([ attribute.attribute( "src", "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Crect width='400' height='300' fill='%23e2e8f0'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em'%3EImage%3C/text%3E%3C/svg%3E", ), attribute.attribute("alt", img.alt), attribute.attribute("class", "w-full h-auto object-cover"), attribute.attribute("referrerpolicy", "no-referrer"), ]) }), ) post.ExternalLink(external) -> html.a( [ attribute.attribute("href", external.uri), attribute.attribute("target", "_blank"), attribute.attribute("rel", "noopener noreferrer"), attribute.attribute("class", "link-card"), ], [ case external.thumb { Some(thumb_url) -> html.img([ attribute.attribute("src", thumb_url), attribute.attribute("alt", external.title), attribute.attribute("class", "w-full h-48 object-cover"), attribute.attribute("referrerpolicy", "no-referrer"), ]) None -> element.none() }, html.div( [ attribute.attribute("class", "external-link-preview"), ], [ html.h3( [ attribute.attribute("class", "external-link-title"), ], [html.text(external.title)], ), html.p( [ attribute.attribute("class", "external-link-desc"), ], [html.text(external.description)], ), ], ), ], ) post.Record(r) -> html.text(r.uri) } } } fn post_footer(labels: List(post.Label), created_at: String) -> Element(Msg) { html.div( [ attribute.attribute( "class", "flex items-center justify-between mt-4 pt-4 border-t border-slate-100", ), ], [ html.p( [ attribute.attribute("class", "text-xs text-slate-400"), ], [html.text(format_timestamp(created_at))], ), case labels { [] -> element.none() _ -> html.div( [ attribute.attribute("class", "flex gap-1"), ], list.map(labels, fn(label) { html.span( [ attribute.attribute("class", "label-badge"), ], [html.text(label.val)], ) }), ) }, ], ) } fn format_timestamp(iso_timestamp: String) -> String { let parsed_date = iso_timestamp let parts = string.split(parsed_date, "T") case parts { [date_part, ..] -> date_part _ -> iso_timestamp } } fn url_input(at_url: String, error_string: Option(String)) -> Element(Msg) { html.div( [ attribute.attribute("class", "flex flex-col gap-4"), ], [ html.div( [ attribute.attribute("class", "flex gap-3 items-stretch"), ], [ html.input([ event.on_change(LinkWasSet), attribute.inputmode("text"), attribute.value(at_url), attribute.attribute( "placeholder", "at://did:plc:.../app.bsky.feed.post/...", ), attribute.attribute("class", "input-primary flex-grow min-w-0"), ]), html.button( [ event.on_click(UserClickedShow), attribute.attribute("class", "btn-primary flex-shrink-0"), ], [html.text("Show")], ), ], ), case error_string { None -> element.none() Some(s) -> html.p( [ attribute.attribute("class", "text-red-600 text-sm"), ], [html.text(s)], ) }, ], ) } fn fetch_profile(pds_host: String, did: String) -> Effect(Msg) { rsvp.get( pds_host <> "/xrpc/com.atproto.repo.getRecord?" <> construct_profile_uri(did), rsvp.expect_json( decode_get_record_response(profile.decode_profile()), ProfileWasFetched, ), ) } pub fn extract_did_from_uri(uri: String) -> Result(String, Nil) { let u = case uri { "at://" <> rest -> rest _ -> uri } case string.split(u, "/") { [did, ..] -> Ok(did) _ -> Error(Nil) } } pub fn construct_profile_uri(did: String) -> String { get_record_query(did, "app.bsky.actor.profile", "self") } fn get_record(pds_host: String, at_url: String) -> Effect(Msg) { case query_from_at_uri(at_url) { Error(Nil) -> { use dispatch <- effect.from dispatch(PostWasFetched(Error(rsvp.BadBody))) } Ok(query) -> { let url = pds_host <> "/xrpc/com.atproto.repo.getRecord?" <> query rsvp.get( url, rsvp.expect_json( decode_get_record_response(post.decode_post()), PostWasFetched, ), ) } } } pub type Record(a) { Record(uri: String, cid: String, value: a) } const slingshot_base = "https://slingshot.microcosm.blue" fn resolve_mini_doc(identifier: String) -> Effect(Msg) { rsvp.get( slingshot_base <> "/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" <> identifier, rsvp.expect_json(profile.decode_mini_doc(), MiniDocWasResolved), ) } fn decode_get_record_response( decoder: decode.Decoder(a), ) -> decode.Decoder(Record(a)) { use uri <- field("uri", string) use cid <- field("cid", string) use value <- field("value", decoder) success(Record(uri:, cid:, value:)) } pub fn query_from_at_uri(at_url: String) -> Result(String, Nil) { let u = case at_url { "at://" <> rest -> rest _ -> at_url } case string.split(u, "/") { [did, collection, rkey] -> Ok(get_record_query(did, collection, rkey)) _ -> Error(Nil) } } fn get_record_query(did, collection, rkey) -> String { uri.query_to_string([ #("repo", did), #("collection", collection), #("rkey", rkey), ]) }