small SPA gleam experiment to fetch and render a single bsky post
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}