Allows you to use Mastodon and Bluesky comments on your Lustre blog hexdocs.pm/chilp/
blog gleam lustre indieweb mastodon bluesky comments
1
fork

Configure Feed

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

The ergonomic (/component) method is now the only method.


Signed-off-by: MLC Bloeiman <mar@strawmelonjuice.com>

+1405 -1428
+2 -2
gleam.toml
··· 1 1 name = "chilp" 2 2 description = "Allows you to use Mastodon and Bluesky comments on your Lustre blog." 3 - version = "2.0.1-rc" 4 - gleam = ">= 1.15.0" 3 + version = "2.0.1-rc1" 4 + gleam = ">= 1.15.4" 5 5 licences = ["Apache-2.0"] 6 6 repository = { type = "tangled", user = "did:plc:jgtfsmv25thfs4zmydtbccnn", repo = "chilp" } 7 7 documentation.pages = [
+1403 -15
src/chilp.gleam
··· 1 - import chilp/widget 2 - import chilp/widget/anchors 3 - import gleam/option.{type Option} 1 + // IMPORTS --------------------------------------------------------------------- 2 + import gleam/bool 3 + import gleam/dynamic/decode 4 + import gleam/int 5 + import gleam/json 6 + import gleam/list 7 + import gleam/option.{type Option, None, Some} 8 + import gleam/order 9 + import gleam/pair 10 + import gleam/result 11 + import gleam/string 12 + import gleam/time/calendar 13 + import gleam/time/duration 14 + import gleam/time/timestamp 15 + import gleam/uri 16 + import html_parser 17 + import lustre 18 + import lustre/attribute.{attribute} 19 + import lustre/component 20 + import lustre/effect.{type Effect} 21 + import lustre/element.{type Element} 22 + import lustre/element/html 23 + import lustre/element/svg 24 + import lustre/event 25 + import rsvp 26 + 27 + // PUBLIC APIS ---------------------------------------------------------------------------------------------------------- 4 28 29 + /// # Anchor to Bluesky 30 + /// 5 31 /// An anchor to the Bluesky post you want to 6 32 /// fetch comments from. 7 33 /// | Parameter | Description | ··· 9 35 /// | `did` | Your DID, for `@strawmelonjuice.com`, this looks like `"did:plc:jgtfsmv25thfs4zmydtbccnn"`.<br><br>Not sure how to find your DID? <https://bsky-did.neocities.org> is one of the many places where you can easily find it. | 10 36 /// | `post_id` | A post id to bind to, you'll find this in a post url `https://bsky.app/profile/<your-username-or-did>/post/[postid]` | 11 37 /// 12 - /// 13 - /// You can also construct this directly if you import `chilp/widget/anchors`. 14 38 pub fn bluesky(did did: String, post_id postid: String) { 15 - anchors.Bluesky(did:, postid:) 39 + Bluesky(did:, postid:) 16 40 } 17 41 18 - /// 42 + /// # Anchor to Fediverse 43 + /// 19 44 /// An anchor to the Fediverse post you want to 20 45 /// fetch comments from. 21 46 /// | Parameter | Description | ··· 24 49 /// | `post_id` | A post id to bind to, you'll find this in a post url `https://instance.social/@<username>/[postid]`. | 25 50 /// | | On Sharkey or Misskey this is a note id, found `https://instance.social/notes/[note-id]` | 26 51 /// 27 - /// You can also construct this directly if you import `chilp/widget/anchors`. 28 52 pub fn mastodon(instance instance: String, post_id postid: String) { 29 - anchors.Mastodon(instance:, postid:) 53 + Mastodon(instance:, postid:) 30 54 } 31 55 32 - /// Widget component! 33 - /// Before adding this component make sure to call `widget.register()` to register it! 56 + /// # The widget 57 + /// 58 + /// Before adding this component make sure to call `chilp.register()` to register it! 34 59 /// 35 60 /// Little example: 36 61 /// ```gleam ··· 42 67 /// ) 43 68 /// ``` 44 69 /// 45 - /// You can also construct this directly if you import `chilp/widget`. 46 70 pub fn widget( 47 - bluesky bsky: Option(anchors.Bluesky), 48 - mastodon masto: Option(anchors.Mastodon), 71 + mastodon mastodon_anchor: Option(Mastodon), 72 + bluesky bsky_anchor: Option(Bluesky), 73 + ) -> Element(msg) { 74 + element.element( 75 + "comment-widget", 76 + [ 77 + attribute.attribute("mastodon-anchor", case mastodon_anchor { 78 + Some(anchor) -> anchor.instance <> "\\" <> anchor.postid 79 + None -> "" 80 + }), 81 + attribute.attribute("bluesky-anchor", case bsky_anchor { 82 + Some(anchor) -> anchor.did <> "\\" <> anchor.postid 83 + None -> "" 84 + }), 85 + ], 86 + [], 87 + ) 88 + } 89 + 90 + /// # Widget activation 91 + /// 92 + /// You should run this register function before using the widget component, so that Chilp can 93 + /// listen for it and load the component contents. 94 + /// 95 + pub fn register() -> Result(Nil, lustre.Error) { 96 + let component = 97 + lustre.component(init, update, view, [ 98 + component.on_attribute_change("mastodon-anchor", fn(value) { 99 + use <- bool.guard(when: value == "", return: Ok(MastodonUnAnchored)) 100 + value 101 + |> string.split_once("\\") 102 + |> result.map(fn(a) { MastodonAnchored(a.0, a.1) }) 103 + }), 104 + component.on_attribute_change("bluesky-anchor", fn(value) { 105 + use <- bool.guard(when: value == "", return: Ok(BskyUnAnchored)) 106 + value 107 + |> string.split_once("\\") 108 + |> result.map(fn(a) { BskyAnchored(a.0, a.1) }) 109 + }), 110 + ]) 111 + 112 + lustre.register(component, "comment-widget") 113 + } 114 + 115 + // MODEL ----------------------------------------------------------------------- 116 + 117 + type Model { 118 + Model( 119 + mastodon_anchor: Option(Mastodon), 120 + cached_mastodon_descendants: List(MastodonDescendant), 121 + mastodon_op_username_and_posturl: #(String, String), 122 + bluesky_anchor: Option(Bluesky), 123 + cached_bluesky_replies: List(BskyThreadReply), 124 + bsky_op_handle: String, 125 + all_stopping_error: Option(String), 126 + cached_coalesced_view: List(CoalescedView), 127 + /// For those not using DaisyUI, using it's hacky way of creating tabs is... hacky. 128 + /// So we do this the old school way. Tabs in DOM. 129 + /// This value will be ignored if the model only has one anchor. 130 + open_tab: Int, 131 + ) 132 + } 133 + 134 + @internal 135 + pub opaque type Mastodon { 136 + Mastodon(instance: String, postid: String) 137 + } 138 + 139 + @internal 140 + pub opaque type Bluesky { 141 + Bluesky(did: String, postid: String) 142 + } 143 + 144 + fn init(_) -> #(Model, Effect(Msg)) { 145 + #( 146 + Model( 147 + mastodon_anchor: None, 148 + cached_mastodon_descendants: [], 149 + mastodon_op_username_and_posturl: #("", ""), 150 + bluesky_anchor: None, 151 + cached_bluesky_replies: [], 152 + bsky_op_handle: "", 153 + all_stopping_error: None, 154 + cached_coalesced_view: [], 155 + open_tab: [1, 2] |> list.shuffle() |> list.first() |> result.unwrap(1), 156 + ), 157 + effect.none(), 158 + ) 159 + } 160 + 161 + // UPDATE ---------------------------------------------------------------------- 162 + 163 + type Msg { 164 + MastodonUnAnchored 165 + MastodonAnchored(instance: String, post: String) 166 + BskyUnAnchored 167 + BskyAnchored(did: String, post: String) 168 + AllStoppingError(String) 169 + BskyIncomingThreadView(BskyThreadView) 170 + IncomingCoalescedView(List(CoalescedView)) 171 + MastodonIncomingStatus(MastodonStatusContext) 172 + MastodonIncomingOpUsername(#(String, String)) 173 + SetTab(Int) 174 + MastodonAnswer(instance: String) 175 + MastodonErrorFetchingOpUsernameRetryForMisskey 176 + } 177 + 178 + fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 179 + case msg { 180 + AllStoppingError(msg) -> #( 181 + Model(..model, all_stopping_error: Some(msg)), 182 + effect.none(), 183 + ) 184 + MastodonUnAnchored -> #( 185 + Model(..model, mastodon_anchor: None), 186 + effect.none(), 187 + ) 188 + MastodonAnswer(instance:) -> { 189 + case model.mastodon_anchor { 190 + Some(..) if instance != "" -> { 191 + #( 192 + model, 193 + browse({ 194 + "https://" 195 + <> instance 196 + <> "/authorize_interaction?uri=" 197 + <> uri.percent_encode(model.mastodon_op_username_and_posturl.1) 198 + }), 199 + ) 200 + } 201 + _ -> #(model, effect.none()) 202 + } 203 + } 204 + 205 + MastodonAnchored(instance:, post:) -> { 206 + let model = 207 + Model(..model, mastodon_anchor: Some(Mastodon(instance:, postid: post))) 208 + #( 209 + model, 210 + effect.batch([get_username_mastodon(model), refresh_mastodon(model)]), 211 + ) 212 + } 213 + MastodonIncomingOpUsername(mastodon_op_username_and_posturl) -> #( 214 + Model(..model, mastodon_op_username_and_posturl:), 215 + effect.none(), 216 + ) 217 + MastodonIncomingStatus(status) -> { 218 + #( 219 + Model(..model, cached_mastodon_descendants: status.descendants), 220 + new_coalesced_view(model.cached_bluesky_replies, status.descendants), 221 + ) 222 + } 223 + 224 + BskyUnAnchored -> #(Model(..model, bluesky_anchor: None), effect.none()) 225 + BskyAnchored(did:, post:) -> { 226 + let model = 227 + Model(..model, bluesky_anchor: Some(Bluesky(did:, postid: post))) 228 + #(model, effect.batch([refresh_bsky(model)])) 229 + } 230 + BskyIncomingThreadView(threadview) -> { 231 + #( 232 + Model(..model, cached_bluesky_replies: threadview.replies), 233 + new_coalesced_view( 234 + threadview.replies, 235 + model.cached_mastodon_descendants, 236 + ), 237 + ) 238 + } 239 + SetTab(open_tab) -> #(Model(..model, open_tab:), effect.none()) 240 + // And then... everything comes together! 241 + IncomingCoalescedView(new) -> #( 242 + Model(..model, cached_coalesced_view: new), 243 + effect.none(), 244 + ) 245 + MastodonErrorFetchingOpUsernameRetryForMisskey -> #( 246 + model, 247 + get_username_miskey(model), 248 + ) 249 + } 250 + } 251 + 252 + // VIEW ------------------------------------------------------------------------ 253 + 254 + fn view(model: Model) -> Element(Msg) { 255 + case model.all_stopping_error { 256 + None -> view_normal(model) 257 + Some(error) -> error_view(error) 258 + } 259 + } 260 + 261 + fn view_normal(model: Model) -> Element(Msg) { 262 + let about_linkie = 263 + html.div([], [ 264 + html.a( 265 + [ 266 + attribute.class("float-right link link-secondary-content/40 text-xs"), 267 + ..case 268 + bool.exclusive_or( 269 + option.is_some(model.bluesky_anchor), 270 + option.is_some(model.mastodon_anchor), 271 + ), 272 + model.open_tab == 3 273 + { 274 + True, True -> [event.on_click(SetTab(1))] 275 + True, False -> [event.on_click(SetTab(3))] 276 + False, False -> [ 277 + event.on_click(SetTab(3)), 278 + attribute.class("absolute right-2 top-1"), 279 + ] 280 + False, True -> [ 281 + attribute.class("hidden chilp-oat-enhance-hide"), 282 + ] 283 + } 284 + ], 285 + [ 286 + html.text(case model.open_tab == 3 { 287 + False -> "About" 288 + True -> "×" 289 + }), 290 + ], 291 + ), 292 + ]) 293 + 294 + let #(linkedto, respond_here) = case 295 + model.bluesky_anchor, 296 + model.mastodon_anchor 297 + { 298 + Some(bsky), Some(masto) -> { 299 + #( 300 + [ 301 + html.text("Linked to "), 302 + case 303 + model.mastodon_op_username_and_posturl.1 304 + |> string.contains("/notes/") 305 + { 306 + True -> 307 + html.a( 308 + [ 309 + attribute.href(model.mastodon_op_username_and_posturl.1), 310 + attribute.class("link text-[#3da0c8]"), 311 + ], 312 + [html.text("this note on Sharkey")], 313 + ) 314 + False -> 315 + html.a( 316 + [ 317 + attribute.href(model.mastodon_op_username_and_posturl.1), 318 + attribute.class("link text-[#595aff]"), 319 + ], 320 + [html.text("this post on Mastodon")], 321 + ) 322 + }, 323 + html.text(", and to "), 324 + html.a( 325 + [ 326 + attribute.href( 327 + "https://bsky.app/profile/" 328 + <> bsky.did 329 + <> "/post/" 330 + <> bsky.postid, 331 + ), 332 + attribute.class("link text-[#006aff]"), 333 + ], 334 + [html.text("this post on Bluesky")], 335 + ), 336 + html.text("."), 337 + ] 338 + |> element.fragment, 339 + [ 340 + html.div( 341 + [ 342 + attribute.class("tabs tabs-box border-b-2 border-base-300 h-full"), 343 + attribute.role("tablist"), 344 + ], 345 + [ 346 + html.button( 347 + [ 348 + attribute.role("tab"), 349 + event.on_click(SetTab(1)), 350 + attribute.classes([ 351 + #("tab", True), 352 + #("tab-active outline", model.open_tab == 1), 353 + ]), 354 + ], 355 + [ 356 + html.text("Mastodon"), 357 + ], 358 + ), 359 + html.button( 360 + [ 361 + attribute.role("tab"), 362 + event.on_click(SetTab(2)), 363 + attribute.classes([ 364 + #("tab", True), 365 + #("tab-active outline", model.open_tab == 2), 366 + ]), 367 + ], 368 + [html.text("Bluesky")], 369 + ), 370 + ], 371 + ), 372 + html.br([]), 373 + case model.open_tab { 374 + 2 -> 375 + view_respond_on_bsky( 376 + "https://bsky.app/profile/" 377 + <> bsky.did 378 + <> "/post/" 379 + <> bsky.postid, 380 + ) 381 + 3 -> view_about_chilp() 382 + _ -> view_mastodon_respond_form(masto) 383 + }, 384 + ] 385 + |> element.fragment(), 386 + ) 387 + } 388 + None, Some(masto) -> { 389 + #( 390 + case model.open_tab { 391 + 3 -> view_about_chilp() 392 + _ -> 393 + [ 394 + html.text("Linked to "), 395 + case 396 + model.mastodon_op_username_and_posturl.1 397 + |> string.contains("/notes/") 398 + { 399 + True -> 400 + html.a( 401 + [ 402 + attribute.href(model.mastodon_op_username_and_posturl.1), 403 + attribute.class("link text-[#3da0c8]"), 404 + ], 405 + [html.text("this note on Sharkey")], 406 + ) 407 + False -> 408 + html.a( 409 + [ 410 + attribute.href(model.mastodon_op_username_and_posturl.1), 411 + attribute.class("link text-[#595aff]"), 412 + ], 413 + [html.text("this post on Mastodon")], 414 + ) 415 + }, 416 + 417 + html.text("."), 418 + ] 419 + |> element.fragment 420 + }, 421 + view_mastodon_respond_form(masto), 422 + ) 423 + } 424 + Some(bsky), None -> { 425 + #( 426 + case model.open_tab { 427 + 3 -> view_about_chilp() 428 + _ -> 429 + [ 430 + html.text("Linked to "), 431 + html.a( 432 + [ 433 + attribute.href( 434 + "https://bsky.app/profile/" 435 + <> bsky.did 436 + <> "/post/" 437 + <> bsky.postid, 438 + ), 439 + attribute.class("link link-[#006aff]"), 440 + ], 441 + [html.text("this post on Bluesky")], 442 + ), 443 + html.text("."), 444 + ] 445 + |> element.fragment 446 + }, 447 + view_respond_on_bsky( 448 + "https://bsky.app/profile/" <> bsky.did <> "/post/" <> bsky.postid, 449 + ), 450 + ) 451 + } 452 + None, None -> #( 453 + error_view("No comment backends are configured for this widget."), 454 + element.none(), 455 + ) 456 + } 457 + html.div([attribute.class("comment-widget")], [ 458 + html.div( 459 + [ 460 + attribute.class( 461 + "widget card bg-base-100 shadow-xl border border-base-200 p-10", 462 + ), 463 + ], 464 + [ 465 + html.h1([attribute.class("text-2xl font-extrabold text-base-content")], [ 466 + html.text("Comments"), 467 + ]), 468 + html.p([attribute.class("text-sm text-base-content/70")], [ 469 + about_linkie, 470 + linkedto, 471 + ]), 472 + html.div( 473 + [ 474 + attribute.class("card card-dash bg-base-200/70 border-base-300"), 475 + ], 476 + [ 477 + respond_here, 478 + ], 479 + ), 480 + html.section( 481 + [attribute.class("pt-5 space-y-10")], 482 + list.sort(model.cached_coalesced_view, sort_comments) 483 + |> list.map(view_rendered_comment( 484 + _, 485 + option.map(model.bluesky_anchor, fn(bsky) { 486 + "https://bsky.app/profile/" <> bsky.did 487 + }), 488 + option.map(model.mastodon_anchor, fn(masto) { 489 + "https://" 490 + <> masto.instance 491 + <> "/@" 492 + <> model.mastodon_op_username_and_posturl.0 493 + }), 494 + )), 495 + ), 496 + ], 497 + ), 498 + ]) 499 + } 500 + 501 + fn view_rendered_comment( 502 + comment: CoalescedView, 503 + bsky_op_profile: Option(String), 504 + mastodon_op_profile: Option(String), 505 + ) -> Element(Msg) { 506 + let is_by_op = { 507 + case comment.source, bsky_op_profile, mastodon_op_profile { 508 + "Mastodon", _, Some(op) -> op == comment.author_profile_link 509 + 510 + "Bluesky", Some(op), _ -> { 511 + // Had some mismatches here, but decided it is of no importance what hostname we use. 512 + { 513 + op 514 + |> string.replace("https://bsky.app", "") 515 + |> string.replace("https://bluesky.app", "") 516 + } 517 + == { 518 + comment.author_profile_link 519 + |> string.replace("https://bsky.app", "") 520 + |> string.replace("https://bluesky.app", "") 521 + } 522 + } 523 + _, _, _ -> False 524 + } 525 + } 526 + let commands = 527 + list.filter_map(comment.children, fn(child) { 528 + case 529 + { 530 + case comment.source, bsky_op_profile, mastodon_op_profile { 531 + "Mastodon", _, Some(op) -> op == child.author_profile_link 532 + 533 + "Bluesky", Some(op), _ -> { 534 + // Had some mismatches here, but decided it is of no importance what hostname we use. 535 + { 536 + op 537 + |> string.replace("https://bsky.app", "") 538 + |> string.replace("https://bluesky.app", "") 539 + } 540 + == { 541 + child.author_profile_link 542 + |> string.replace("https://bsky.app", "") 543 + |> string.replace("https://bluesky.app", "") 544 + } 545 + } 546 + _, _, _ -> False 547 + } 548 + } 549 + { 550 + // Not by op 551 + False -> Error(Nil) 552 + True -> { 553 + let content = 554 + element.to_readable_string(child.content) |> string.lowercase() 555 + case string.starts_with(content, "-chilp ") { 556 + True -> Ok(content) 557 + False -> Error(Nil) 558 + } 559 + } 560 + } 561 + }) 562 + let is_hidden = list.any(commands, string.starts_with(_, "-chilp hide")) 563 + let is_silenced = list.any(commands, string.starts_with(_, "-chilp silence")) 564 + // Is the comment we're currently trying to parse a command? Even unauthorised commands will not be rendered. 565 + let is_command = { 566 + string.starts_with( 567 + element.to_readable_string(comment.content) |> string.lowercase(), 568 + "-chilp ", 569 + ) 570 + } 571 + use <- bool.guard(when: is_hidden, return: element.none()) 572 + use <- bool.guard(when: is_command, return: element.none()) 573 + html.article([attribute.class("comment mt-2")], [ 574 + html.header([attribute.class("flex")], [ 575 + html.figure( 576 + [ 577 + attribute("aria-label", comment.author_username), 578 + attribute.class("small"), 579 + attribute("data-variant", "avatar"), 580 + ], 581 + [ 582 + html.img([ 583 + attribute.src(comment.author_avatar_url), 584 + attribute.class( 585 + "avatar w-[45px] h-[45px] mask mask-squircle flex-none", 586 + ), 587 + attribute.alt("@"), 588 + ]), 589 + ], 590 + ), 591 + html.div([attribute.class("meta pl-4 max-h-[45px]")], [ 592 + html.span( 593 + [attribute.class("display-name chilp-oat-enhance-inset-1em")], 594 + [ 595 + html.text(comment.displayname), 596 + case comment.source { 597 + "Mastodon" -> 598 + html.div( 599 + [ 600 + attribute("data-variant", "secondary"), 601 + attribute.class( 602 + "badge badge-sm badge-info ms-2 bg-[#595aff] text-white", 603 + ), 604 + ], 605 + [ 606 + element.text("Fediverse"), 607 + ], 608 + ) 609 + "Bluesky" -> 610 + html.div( 611 + [ 612 + attribute("data-variant", "secondary"), 613 + attribute.class( 614 + "badge badge-sm badge-info ms-2 bg-[#006aff] text-white", 615 + ), 616 + ], 617 + [ 618 + element.text("Bluesky"), 619 + ], 620 + ) 621 + _ -> element.none() 622 + }, 623 + case is_by_op { 624 + True -> 625 + html.div( 626 + [ 627 + attribute("data-variant", "outline"), 628 + attribute.class("badge badge-sm badge-accent ms-2"), 629 + ], 630 + [ 631 + element.text("Author"), 632 + ], 633 + ) 634 + False -> element.none() 635 + }, 636 + ], 637 + ), 638 + html.p([attribute.class("text-xs")], [ 639 + html.a( 640 + [ 641 + attribute.href(comment.author_profile_link), 642 + attribute.class("link link-secondary-content link-sm"), 643 + ], 644 + [element.text("@" <> comment.author_username)], 645 + ), 646 + element.text(" • "), 647 + html.time( 648 + [ 649 + attribute( 650 + "datetime", 651 + comment.created_at |> timestamp.to_rfc3339(calendar.utc_offset), 652 + ), 653 + ], 654 + [ 655 + element.text({ 656 + let b = 657 + case 658 + timestamp.difference( 659 + comment.created_at, 660 + timestamp.system_time(), 661 + ) 662 + |> duration.approximate 663 + |> pair.map_second(fn(d) { 664 + case d { 665 + duration.Nanosecond -> "nanosecond" 666 + duration.Microsecond -> "microsecond" 667 + duration.Millisecond -> "millisecond" 668 + duration.Second -> "second" 669 + duration.Minute -> "minute" 670 + duration.Hour -> "hour" 671 + duration.Day -> "day" 672 + duration.Week -> "week" 673 + duration.Month -> "month" 674 + duration.Year -> "year" 675 + } 676 + }) 677 + { 678 + #(1, x) -> #(1, x) 679 + #(x, d) -> #(x, d <> "s") 680 + } 681 + |> pair.map_first(int.to_string) 682 + 683 + b.0 <> " " <> b.1 <> " ago." 684 + }), 685 + ], 686 + ), 687 + ]), 688 + ]), 689 + ]), 690 + html.section([attribute.class("content mt-5")], [ 691 + html.span([], [comment.content]), 692 + ]), 693 + html.footer([], [ 694 + html.div([attribute.class("my-5")], [ 695 + html.a( 696 + [ 697 + attribute.class("btn btn-sm absolute right-8"), 698 + attribute.href(comment.content_url), 699 + attribute.target("_blank"), 700 + ], 701 + [html.text("View comment on " <> comment.source)], 702 + ), 703 + ]), 704 + html.br([attribute.class("border-b-2 border-dotted")]), 705 + case comment.children, is_silenced { 706 + [], _ | _, True -> element.none() 707 + _, False -> 708 + html.section( 709 + [ 710 + attribute.class( 711 + "pl-5 border-s-4 border-default bg-neutral-secondary-soft chilp-oat-enhance-inset-2em", 712 + ), 713 + ], 714 + list.sort(comment.children, sort_comments) 715 + |> list.map(fn(child) { 716 + html.div( 717 + [attribute.class("chilp-oat-enhance-inset-quoteblock")], 718 + [ 719 + view_rendered_comment( 720 + child, 721 + bsky_op_profile, 722 + mastodon_op_profile, 723 + ), 724 + ], 725 + ) 726 + }), 727 + ) 728 + }, 729 + ]), 730 + ]) 731 + } 732 + 733 + fn view_about_chilp() -> Element(Msg) { 734 + html.div([attribute.class("my-5 px-5 pb-5 ")], [ 735 + html.p([], [ 736 + element.text("This widget is powered by Chilp! 💬 By MLC Bloeiman"), 737 + ]), 738 + html.p([], [ 739 + element.text("Want to read "), 740 + html.a( 741 + [ 742 + attribute.href("https://strawmelonjuice.com/post/chilpv2"), 743 + attribute.class("link link-primary-content"), 744 + ], 745 + [ 746 + element.text("more about Chilp"), 747 + ], 748 + ), 749 + element.text(" on "), 750 + html.img([ 751 + attribute.src("https://strawmelonjuice.com/strawmelonjuice.svg"), 752 + attribute.width(34), 753 + attribute.class("inline bg-white/65 rounded-lg"), 754 + ]), 755 + element.text(" strawmelonjuice.com?"), 756 + ]), 757 + ]) 758 + } 759 + 760 + fn view_respond_on_bsky(bskylink: String) -> Element(Msg) { 761 + let icon_link = fn(label: String, new_base: String, color_class: String) { 762 + html.button( 763 + [ 764 + attribute.href(string.replace(bskylink, "bsky.app", new_base)), 765 + attribute.target("_blank"), 766 + attribute.class( 767 + "btn btn-circle btn-sm btn-ghost tooltip " <> color_class, 768 + ), 769 + attribute.attribute("data-tip", label), 770 + ], 771 + [ 772 + element.text(string.slice(label, 0, 1)), 773 + ], 774 + ) 775 + } 776 + 777 + html.div([attribute.class("my-5 px-5 pb-5 ")], [ 778 + element.text("Respond to this post on Bluesky to have it show up here!"), 779 + html.div([attribute.class("flex gap-2 items-center")], [ 780 + html.span([attribute.class("text-xs mr-2 opacity-50")], [ 781 + element.text("Open in:"), 782 + ]), 783 + icon_link( 784 + "Bluesky", 785 + "bsky.app", 786 + "text-[#006aff] bg-white chilp-oat-enhance-inset-blueskyreply", 787 + ), 788 + icon_link( 789 + "Blacksky", 790 + "blacksky.community", 791 + "text-white bg-black chilp-oat-enhance-inset-blackskyreply", 792 + ), 793 + icon_link( 794 + "Witchsky", 795 + "witchsky.app", 796 + "bg-[#ed5345] text-white chilp-oat-enhance-inset-witchskyreply", 797 + ), 798 + ]), 799 + ]) 800 + } 801 + 802 + const instancelist = [ 803 + "mastodon.social", 804 + "pony.social", 805 + "todon.nl", 806 + "mstdn.social", 807 + "infosec.exchange", 808 + "woem.space", 809 + "shitpost.trade", 810 + "procial.tchncs.de", 811 + ] 812 + 813 + fn view_mastodon_respond_form(mastodon_anchor: Mastodon) -> Element(Msg) { 814 + html.div([attribute.class("my-5 px-5 pb-5 ")], [ 815 + html.form( 816 + [ 817 + attribute.class("mb-8 w-full"), 818 + event.on_submit(fn(n) { 819 + let value = 820 + list.key_find(n, "userinstance") 821 + |> result.unwrap("") 822 + MastodonAnswer(value) 823 + }), 824 + ], 825 + [ 826 + html.div([attribute.class("flex flex-col gap-2")], [ 827 + // Label Section 828 + html.label( 829 + [ 830 + attribute.for("userinstance"), 831 + attribute.class("text-sm font-semibold text-base-content/80"), 832 + ], 833 + [ 834 + html.text("Enter your instance address to reply or "), 835 + html.a( 836 + [ 837 + attribute.href( 838 + "https://" 839 + <> placeholder_instance(mastodon_anchor) 840 + <> "/auth/sign_up", 841 + ), 842 + attribute.class("link link-primary-content"), 843 + ], 844 + [html.text("create an account")], 845 + ), 846 + html.text("!"), 847 + ], 848 + ), 849 + 850 + // Disclaimer Section 851 + html.p( 852 + [attribute.class("text-xs italic opacity-50 -mt-1 mb-2 ml-1")], 853 + [ 854 + html.text( 855 + "on the instance recommended by this widget... or one you pick yourself!", 856 + ), 857 + ], 858 + ), 859 + 860 + // The Input + Button Group 861 + html.div([attribute.class("join w-full shadow-sm")], [ 862 + html.input([ 863 + attribute.type_("text"), 864 + attribute.required(True), 865 + attribute.placeholder(placeholder_instance(mastodon_anchor)), 866 + attribute.pattern("^([a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}$"), 867 + attribute.name("userinstance"), 868 + // "join-item" removes the inner borders/radii to make them stick 869 + attribute.class( 870 + "input input-bordered join-item flex-1 focus:outline-primary", 871 + ), 872 + ]), 873 + html.button( 874 + [ 875 + attribute.type_("submit"), 876 + attribute.class("btn btn-primary join-item"), 877 + ], 878 + [html.text("Go reply")], 879 + ), 880 + ]), 881 + ]), 882 + ], 883 + ), 884 + ]) 885 + } 886 + 887 + fn placeholder_instance(mastodon_anchor: Mastodon) -> String { 888 + [mastodon_anchor.instance, ..instancelist] 889 + |> list.shuffle 890 + |> list.first 891 + |> result.unwrap(mastodon_anchor.instance) 892 + } 893 + 894 + fn error_view(error: String) { 895 + // todo: Style dis. 896 + html.div([attribute.class("alert alert-error"), attribute.role("alert")], [ 897 + svg.svg( 898 + [ 899 + attribute("viewBox", "0 0 24 24"), 900 + attribute("fill", "none"), 901 + attribute.class("h-6 w-6 shrink-0 stroke-current"), 902 + attribute("xmlns", "http://www.w3.org/2000/svg"), 903 + ], 904 + [ 905 + svg.path([ 906 + attribute( 907 + "d", 908 + "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z", 909 + ), 910 + attribute("stroke-width", "2"), 911 + attribute("stroke-linejoin", "round"), 912 + attribute("stroke-linecap", "round"), 913 + ]), 914 + ], 915 + ), 916 + html.span([], [html.text("AN ERROR OCCURED:" <> error)]), 917 + ]) 918 + } 919 + 920 + // EFFECTS --------------------------------------------------------------------- 921 + 922 + fn new_coalesced_view( 923 + bsky_replies: List(BskyThreadReply), 924 + mastodon_descendants: List(MastodonDescendant), 49 925 ) { 50 - widget.element(masto, bsky) 926 + effect.from(fn(dispatch) { 927 + dispatch( 928 + IncomingCoalescedView(coalesce_views(bsky_replies, mastodon_descendants)), 929 + ) 930 + }) 931 + } 932 + 933 + fn refresh_bsky(model: Model) -> Effect(Msg) { 934 + case model.bluesky_anchor { 935 + Some(anchor) -> { 936 + let url = 937 + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://" 938 + <> anchor.did 939 + <> "/app.bsky.feed.post/" 940 + <> anchor.postid 941 + 942 + let handler = 943 + rsvp.expect_json(bsky_thread_view_decoder(), fn(response) { 944 + case response { 945 + Ok(threadview) -> BskyIncomingThreadView(threadview) 946 + Error(rsvperror) -> 947 + case rsvperror { 948 + rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 949 + AllStoppingError( 950 + "The response body we got back from Bluesky was misformed.", 951 + ) 952 + rsvp.BadUrl(_) -> 953 + AllStoppingError( 954 + "The API call to Bluesky failed. Did you enter the DID and post id correctly?", 955 + ) 956 + rsvp.HttpError(_) | rsvp.NetworkError -> 957 + AllStoppingError("Could not fetch comments from Bluesky.") 958 + } 959 + } 960 + }) 961 + rsvp.get(url, handler) 962 + } 963 + None -> effect.none() 964 + } 965 + } 966 + 967 + fn get_username_mastodon(model: Model) -> Effect(Msg) { 968 + case model.mastodon_anchor { 969 + Some(anchor) -> { 970 + let url = 971 + "https://" <> anchor.instance <> "/api/v1/statuses/" <> anchor.postid 972 + 973 + let handler = 974 + rsvp.expect_json( 975 + { 976 + use user <- decode.subfield(["account", "username"], decode.string) 977 + decode.success(user) 978 + }, 979 + fn(response) { 980 + case response { 981 + Ok(username) -> 982 + MastodonIncomingOpUsername(#( 983 + username, 984 + "https://" 985 + <> anchor.instance 986 + <> "/@" 987 + <> username 988 + <> "/" 989 + <> anchor.postid, 990 + )) 991 + Error(rsvperror) -> 992 + case rsvperror { 993 + rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 994 + AllStoppingError( 995 + "The response body we got back from Mastodon was misformed.", 996 + ) 997 + rsvp.BadUrl(_) -> 998 + AllStoppingError( 999 + "The API call to Mastodon failed. Did you enter the instance and post id correctly?", 1000 + ) 1001 + rsvp.HttpError(_) -> { 1002 + // This may mean something else! This might not be all-stopping just yet. 1003 + // If this is a Misskey or Sharkey instance, we can still save things! 1004 + MastodonErrorFetchingOpUsernameRetryForMisskey 1005 + } 1006 + rsvp.NetworkError -> 1007 + AllStoppingError( 1008 + "Could not fetch comments from Mastodon. (NetworkError)", 1009 + ) 1010 + } 1011 + } 1012 + }, 1013 + ) 1014 + rsvp.get(url, handler) 1015 + } 1016 + None -> effect.none() 1017 + } 1018 + } 1019 + 1020 + fn get_username_miskey(model: Model) -> Effect(Msg) { 1021 + case model.mastodon_anchor { 1022 + Some(anchor) -> { 1023 + let url = "https://" <> anchor.instance <> "/api/notes/show" 1024 + 1025 + let handler = 1026 + rsvp.expect_json( 1027 + { 1028 + use user <- decode.subfield(["user", "username"], decode.string) 1029 + decode.success(user) 1030 + }, 1031 + fn(response) { 1032 + case response { 1033 + Ok(username) -> 1034 + MastodonIncomingOpUsername(#( 1035 + username, 1036 + "https://" <> anchor.instance <> "/notes/" <> anchor.postid, 1037 + )) 1038 + Error(rsvperror) -> 1039 + case rsvperror { 1040 + rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 1041 + AllStoppingError( 1042 + "The response body we got back from Misskey/Sharkey was misformed.", 1043 + ) 1044 + rsvp.BadUrl(_) -> 1045 + AllStoppingError( 1046 + "The API call to Misskey/Sharkey failed. Did you enter the instance and post id correctly?", 1047 + ) 1048 + rsvp.HttpError(_) -> { 1049 + // ... if we end up here again, then, yes, we have in fact failed... Sorry! 1050 + AllStoppingError( 1051 + "Could not fetch comments from Mastodon/Misskey/Sharkey. (HttpError)", 1052 + ) 1053 + } 1054 + rsvp.NetworkError -> 1055 + AllStoppingError( 1056 + "Could not fetch comments from Misskey/Sharkey. (NetworkError)", 1057 + ) 1058 + } 1059 + } 1060 + }, 1061 + ) 1062 + rsvp.post( 1063 + url, 1064 + json.object([#("noteId", json.string(anchor.postid))]), 1065 + handler, 1066 + ) 1067 + } 1068 + None -> effect.none() 1069 + } 1070 + } 1071 + 1072 + fn refresh_mastodon(model: Model) -> Effect(Msg) { 1073 + case model.mastodon_anchor { 1074 + Some(anchor) -> { 1075 + let url = 1076 + "https://" 1077 + <> anchor.instance 1078 + <> "/api/v1/statuses/" 1079 + <> anchor.postid 1080 + <> "/context" 1081 + 1082 + let handler = 1083 + rsvp.expect_json( 1084 + mastodon_status_context_decoder(anchor.postid), 1085 + fn(response) { 1086 + case response { 1087 + Ok(status) -> MastodonIncomingStatus(status) 1088 + Error(rsvperror) -> 1089 + case rsvperror { 1090 + rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 1091 + AllStoppingError( 1092 + "The response body we got back from Mastodon was misformed.", 1093 + ) 1094 + rsvp.BadUrl(_) -> 1095 + AllStoppingError( 1096 + "The API call to Mastodon failed. Did you enter the instance and post id correctly?", 1097 + ) 1098 + rsvp.HttpError(_) | rsvp.NetworkError -> 1099 + AllStoppingError("Could not fetch comments from Mastodon.") 1100 + } 1101 + } 1102 + }, 1103 + ) 1104 + rsvp.get(url, handler) 1105 + } 1106 + None -> effect.none() 1107 + } 1108 + } 1109 + 1110 + fn browse(to: String) { 1111 + use _ <- effect.from 1112 + js_browse(to) 1113 + } 1114 + 1115 + // HELPERS --------------------------------------------------------------------- 1116 + 1117 + /// Attempts to do what DOMpurify does... ...while lustre-ifying it! 1118 + fn sanitise_ls(html: String) -> element.Element(a) { 1119 + // To a tree is the easy part. 1120 + html_parser.as_tree(html) 1121 + // Then reconstructing it sanely... 1122 + |> sanitise_reconstruct_ls 1123 + } 1124 + 1125 + fn sanitise_reconstruct_ls(el: html_parser.Element) -> element.Element(a) { 1126 + case el { 1127 + html_parser.EmptyElement -> element.none() 1128 + html_parser.StartElement(name:, attributes:, children:) -> { 1129 + // attributes 1130 + let attribs = 1131 + list.map(attributes, fn(attrib) { 1132 + case attrib { 1133 + html_parser.Attribute(key: "href", value: link) -> 1134 + attribute.href(link) 1135 + html_parser.Attribute(key: "class", value: classes) -> 1136 + attribute.class(classes) 1137 + html_parser.Attribute(key: "target", value: target) -> 1138 + attribute.target(target) 1139 + html_parser.Attribute(_, _) -> attribute.none() 1140 + } 1141 + }) 1142 + [ 1143 + list.map(children, sanitise_reconstruct_ls) 1144 + |> case name { 1145 + "b" -> html.b(attribs, _) 1146 + "i" -> html.i(attribs, _) 1147 + "em" -> html.em(attribs, _) 1148 + "strong" -> html.strong(attribs, _) 1149 + "a" -> html.a( 1150 + [attribute.class("link link-secondary-content"), ..attribs], 1151 + _, 1152 + ) 1153 + 1154 + "p" -> html.p(attribs, _) 1155 + "br" -> fn(_) { html.br(attribs) } 1156 + "span" -> html.span(attribs, _) 1157 + _ -> element.fragment 1158 + }, 1159 + element.text(" "), 1160 + ] 1161 + |> element.fragment() 1162 + } 1163 + // AFAIK we don't have this due to parsing as tree. 1164 + html_parser.EndElement(_) -> 1165 + element.text("ERROR: Did not expect an element end here!") 1166 + html_parser.Content(cnt) -> element.text(cnt) 1167 + } 1168 + } 1169 + 1170 + fn sort_comments(c1: CoalescedView, c2: CoalescedView) -> order.Order { 1171 + case int.compare(c1.agreeability, c2.agreeability) { 1172 + order.Eq -> timestamp.compare(c1.created_at, c2.created_at) 1173 + measure -> measure 1174 + } 1175 + } 1176 + 1177 + @external(javascript, "./ffi_chilp.mjs", "lassign") 1178 + fn js_browse(_: String) -> Nil { 1179 + Nil 1180 + } 1181 + 1182 + /// A Bluesky Threadview, like what you get from `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgrbiiadws2k`. 1183 + /// This one is very pruned! Why? Because these json responses are huge and we only need a small subset of the data in them! 1184 + type BskyThreadView { 1185 + BskyThreadView(at_uri: String, replies: List(BskyThreadReply)) 1186 + } 1187 + 1188 + type BskyThreadReply { 1189 + BskyThreadReply( 1190 + at_uri: String, 1191 + like_count: Int, 1192 + created_at: timestamp.Timestamp, 1193 + body_text: String, 1194 + author_did: String, 1195 + author_handle: String, 1196 + author_displayname: String, 1197 + author_avatar: String, 1198 + children: List(BskyThreadReply), 1199 + ) 1200 + } 1201 + 1202 + fn bsky_thread_view_decoder() -> decode.Decoder(BskyThreadView) { 1203 + use at_uri <- decode.subfield(["thread", "post", "uri"], decode.string) 1204 + use replies <- decode.subfield( 1205 + ["thread", "replies"], 1206 + decode.list(bsky_thread_reply_decoder()), 1207 + ) 1208 + decode.success(BskyThreadView(at_uri:, replies:)) 1209 + } 1210 + 1211 + fn bsky_thread_reply_decoder() -> decode.Decoder(BskyThreadReply) { 1212 + use created_at <- decode.subfield( 1213 + ["post", "record", "createdAt"], 1214 + decode.map(decode.string, fn(stringstamp) { 1215 + result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) 1216 + }), 1217 + ) 1218 + use at_uri <- decode.subfield(["post", "uri"], decode.string) 1219 + use body_text <- decode.subfield(["post", "record", "text"], decode.string) 1220 + 1221 + use author_did <- decode.subfield(["post", "author", "did"], decode.string) 1222 + use author_handle <- decode.subfield( 1223 + ["post", "author", "handle"], 1224 + decode.string, 1225 + ) 1226 + use author_displayname <- decode.subfield( 1227 + ["post", "author", "displayName"], 1228 + decode.string, 1229 + ) 1230 + use author_avatar <- decode.subfield( 1231 + ["post", "author", "avatar"], 1232 + decode.string, 1233 + ) 1234 + use like_count <- decode.subfield(["post", "likeCount"], decode.int) 1235 + use children <- decode.field( 1236 + "replies", 1237 + decode.list(bsky_thread_reply_decoder()), 1238 + ) 1239 + decode.success(BskyThreadReply( 1240 + at_uri:, 1241 + created_at:, 1242 + body_text:, 1243 + like_count:, 1244 + author_did:, 1245 + author_handle:, 1246 + author_displayname:, 1247 + author_avatar:, 1248 + children:, 1249 + )) 1250 + } 1251 + 1252 + /// Subset of a Mastodon Status-context, like what you get from `https://pony.social/api/v1/statuses/115911235653686237/context`. 1253 + type MastodonStatusContext { 1254 + MastodonStatusContext(descendants: List(MastodonDescendant)) 1255 + } 1256 + 1257 + fn mastodon_status_context_decoder( 1258 + original_id: String, 1259 + ) -> decode.Decoder(MastodonStatusContext) { 1260 + use flat_descendants <- decode.field( 1261 + "descendants", 1262 + decode.list(mastodon_descendant_decoder()), 1263 + ) 1264 + let descendants: List(MastodonDescendant) = 1265 + list.filter_map(flat_descendants, fn(desc) { 1266 + case desc.1 == original_id { 1267 + True -> { 1268 + // A parent! 1269 + 1270 + mastodon_decendant_inflater(desc.0, flat_descendants) 1271 + |> Ok 1272 + } 1273 + False -> { 1274 + Error(Nil) 1275 + } 1276 + } 1277 + }) 1278 + decode.success(MastodonStatusContext(descendants:)) 1279 + } 1280 + 1281 + fn mastodon_decendant_inflater( 1282 + parent: MastodonDescendant, 1283 + all_children: List(#(MastodonDescendant, String)), 1284 + ) { 1285 + MastodonDescendant( 1286 + ..parent, 1287 + children: list.filter_map(all_children, fn(c) { 1288 + case c.1 == parent.id { 1289 + True -> Ok(c.0) 1290 + False -> Error(Nil) 1291 + } 1292 + }), 1293 + ) 1294 + } 1295 + 1296 + type MastodonDescendant { 1297 + MastodonDescendant( 1298 + id: String, 1299 + uri: String, 1300 + content: element.Element(Msg), 1301 + created_at: timestamp.Timestamp, 1302 + favourite_count: Int, 1303 + // This one is not populated by the decoder, Mastodon API provides the tree flat, with fields (replying_to) to tell you which is child and which is parent. 1304 + // We gotta iterate over this later to get things nested. 1305 + children: List(MastodonDescendant), 1306 + author_url: String, 1307 + author_avatar_url: String, 1308 + author_username: String, 1309 + author_displayname: String, 1310 + ) 1311 + } 1312 + 1313 + /// Decodes #(MastodonDescendant, ReplyingTo), to enable the parent decoder to make this into a recursive three. 1314 + fn mastodon_descendant_decoder() -> decode.Decoder( 1315 + #(MastodonDescendant, String), 1316 + ) { 1317 + use replying_to <- decode.field("in_reply_to_id", decode.string) 1318 + use id <- decode.field("id", decode.string) 1319 + use uri <- decode.field("uri", decode.string) 1320 + use created_at <- decode.field( 1321 + "created_at", 1322 + decode.map(decode.string, fn(stringstamp) { 1323 + result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) 1324 + }), 1325 + ) 1326 + use favourite_count <- decode.field("favourites_count", decode.int) 1327 + use unescaped_html_content <- decode.field("content", decode.string) 1328 + let content = sanitise_ls(unescaped_html_content) 1329 + 1330 + let children = [] 1331 + 1332 + use author_url <- decode.subfield(["account", "url"], decode.string) 1333 + use author_avatar_url <- decode.subfield(["account", "avatar"], decode.string) 1334 + use author_displayname <- decode.subfield( 1335 + ["account", "display_name"], 1336 + decode.string, 1337 + ) 1338 + use author_username <- decode.subfield(["account", "acct"], decode.string) 1339 + 1340 + decode.success(#( 1341 + MastodonDescendant( 1342 + id:, 1343 + uri:, 1344 + content:, 1345 + created_at:, 1346 + favourite_count:, 1347 + children:, 1348 + author_url:, 1349 + author_avatar_url:, 1350 + author_username:, 1351 + author_displayname:, 1352 + ), 1353 + replying_to, 1354 + )) 1355 + } 1356 + 1357 + fn coalesce_views( 1358 + bsky: List(BskyThreadReply), 1359 + mastodon: List(MastodonDescendant), 1360 + ) -> List(CoalescedView) { 1361 + let mixed: List(Result(BskyThreadReply, MastodonDescendant)) = { 1362 + list.append( 1363 + list.map(bsky, fn(m) { Ok(m) }), 1364 + list.map(mastodon, fn(m) { Error(m) }), 1365 + ) 1366 + |> list.shuffle 1367 + } 1368 + list.map(mixed, fn(item) { 1369 + case item { 1370 + Ok(BskyThreadReply( 1371 + created_at:, 1372 + at_uri:, 1373 + like_count:, 1374 + body_text:, 1375 + author_did:, 1376 + author_displayname:, 1377 + author_handle:, 1378 + author_avatar:, 1379 + children:, 1380 + )) -> 1381 + CoalescedView( 1382 + content_url: "https://bsky.app/profile/" 1383 + <> { 1384 + string.replace(at_uri, "/app.bsky.feed.post/", "/post/") 1385 + |> string.replace("at://", "") 1386 + }, 1387 + created_at:, 1388 + author_profile_link: "https://bluesky.app/profile/" <> author_did, 1389 + source: "Bluesky", 1390 + agreeability: like_count, 1391 + content: element.text(body_text), 1392 + author_username: author_handle, 1393 + author_avatar_url: author_avatar, 1394 + displayname: author_displayname, 1395 + children: { coalesce_views(children, []) }, 1396 + ) 1397 + Error(MastodonDescendant( 1398 + created_at:, 1399 + id: _, 1400 + uri:, 1401 + content:, 1402 + favourite_count:, 1403 + children:, 1404 + author_url:, 1405 + author_avatar_url:, 1406 + author_username:, 1407 + author_displayname:, 1408 + )) -> 1409 + CoalescedView( 1410 + content_url: uri, 1411 + created_at:, 1412 + author_profile_link: author_url, 1413 + source: "Mastodon", 1414 + displayname: author_displayname, 1415 + agreeability: favourite_count, 1416 + author_avatar_url:, 1417 + author_username:, 1418 + content: content, 1419 + children: { coalesce_views([], children) }, 1420 + ) 1421 + } 1422 + }) 1423 + } 1424 + 1425 + type CoalescedView { 1426 + 1427 + CoalescedView( 1428 + created_at: timestamp.Timestamp, 1429 + author_profile_link: String, 1430 + author_avatar_url: String, 1431 + author_username: String, 1432 + source: String, 1433 + displayname: String, 1434 + content: element.Element(Msg), 1435 + content_url: String, 1436 + agreeability: Int, 1437 + children: List(CoalescedView), 1438 + ) 51 1439 }
src/chilp/ffi.mjs src/ffi_chilp.mjs
-1392
src/chilp/widget.gleam
··· 1 - // IMPORTS --------------------------------------------------------------------- 2 - import chilp/widget/anchors 3 - import gleam/bool 4 - import gleam/dynamic/decode 5 - import gleam/int 6 - import gleam/json 7 - import gleam/list 8 - import gleam/option.{type Option, None, Some} 9 - import gleam/order 10 - import gleam/pair 11 - import gleam/result 12 - import gleam/string 13 - import gleam/time/calendar 14 - import gleam/time/duration 15 - import gleam/time/timestamp 16 - import gleam/uri 17 - import html_parser 18 - import lustre 19 - import lustre/attribute.{attribute} 20 - import lustre/component 21 - import lustre/effect.{type Effect} 22 - import lustre/element.{type Element} 23 - import lustre/element/html 24 - import lustre/element/svg 25 - import lustre/event 26 - import rsvp 27 - 28 - // MAIN ------------------------------------------------------------------------ 29 - 30 - pub fn register() -> Result(Nil, lustre.Error) { 31 - // I went along with the examples at https://github.com/lustre-labs/lustre/tree/main/examples/05-components! 32 - // If you're looking for good examples, look there! 33 - let component = 34 - lustre.component(init, update, view, [ 35 - component.on_attribute_change("mastodon-anchor", fn(value) { 36 - use <- bool.guard(when: value == "", return: Ok(MastodonUnAnchored)) 37 - value 38 - |> string.split_once("\\") 39 - |> result.map(fn(a) { MastodonAnchored(a.0, a.1) }) 40 - }), 41 - component.on_attribute_change("bluesky-anchor", fn(value) { 42 - use <- bool.guard(when: value == "", return: Ok(BskyUnAnchored)) 43 - value 44 - |> string.split_once("\\") 45 - |> result.map(fn(a) { BskyAnchored(a.0, a.1) }) 46 - }), 47 - ]) 48 - 49 - lustre.register(component, "comment-widget") 50 - } 51 - 52 - pub fn element( 53 - mastodon_anchor: Option(anchors.Mastodon), 54 - bsky_anchor: Option(anchors.Bluesky), 55 - ) -> Element(msg) { 56 - element.element( 57 - "comment-widget", 58 - [ 59 - attribute.attribute("mastodon-anchor", case mastodon_anchor { 60 - Some(anchor) -> anchor.instance <> "\\" <> anchor.postid 61 - None -> "" 62 - }), 63 - attribute.attribute("bluesky-anchor", case bsky_anchor { 64 - Some(anchor) -> anchor.did <> "\\" <> anchor.postid 65 - None -> "" 66 - }), 67 - ], 68 - [], 69 - ) 70 - } 71 - 72 - // MODEL ----------------------------------------------------------------------- 73 - 74 - type Model { 75 - Model( 76 - mastodon_anchor: Option(anchors.Mastodon), 77 - cached_mastodon_descendants: List(MastodonDescendant), 78 - mastodon_op_username_and_posturl: #(String, String), 79 - bluesky_anchor: Option(anchors.Bluesky), 80 - cached_bluesky_replies: List(BskyThreadReply), 81 - bsky_op_handle: String, 82 - all_stopping_error: Option(String), 83 - cached_coalesced_view: List(CoalescedView), 84 - /// For those not using DaisyUI, using it's hacky way of creating tabs is... hacky. 85 - /// So we do this the old school way. Tabs in DOM. 86 - /// This value will be ignored if the model only has one anchor. 87 - open_tab: Int, 88 - ) 89 - } 90 - 91 - fn init(_) -> #(Model, Effect(Msg)) { 92 - #( 93 - Model( 94 - mastodon_anchor: None, 95 - cached_mastodon_descendants: [], 96 - mastodon_op_username_and_posturl: #("", ""), 97 - bluesky_anchor: None, 98 - cached_bluesky_replies: [], 99 - bsky_op_handle: "", 100 - all_stopping_error: None, 101 - cached_coalesced_view: [], 102 - open_tab: [1, 2] |> list.shuffle() |> list.first() |> result.unwrap(1), 103 - ), 104 - effect.none(), 105 - ) 106 - } 107 - 108 - // UPDATE ---------------------------------------------------------------------- 109 - 110 - type Msg { 111 - MastodonUnAnchored 112 - MastodonAnchored(instance: String, post: String) 113 - BskyUnAnchored 114 - BskyAnchored(did: String, post: String) 115 - AllStoppingError(String) 116 - BskyIncomingThreadView(BskyThreadView) 117 - IncomingCoalescedView(List(CoalescedView)) 118 - MastodonIncomingStatus(MastodonStatusContext) 119 - MastodonIncomingOpUsername(#(String, String)) 120 - SetTab(Int) 121 - MastodonAnswer(instance: String) 122 - MastodonErrorFetchingOpUsernameRetryForMisskey 123 - } 124 - 125 - fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 126 - case msg { 127 - AllStoppingError(msg) -> #( 128 - Model(..model, all_stopping_error: Some(msg)), 129 - effect.none(), 130 - ) 131 - MastodonUnAnchored -> #( 132 - Model(..model, mastodon_anchor: None), 133 - effect.none(), 134 - ) 135 - MastodonAnswer(instance:) -> { 136 - case model.mastodon_anchor { 137 - Some(..) if instance != "" -> { 138 - #( 139 - model, 140 - browse({ 141 - "https://" 142 - <> instance 143 - <> "/authorize_interaction?uri=" 144 - <> uri.percent_encode(model.mastodon_op_username_and_posturl.1) 145 - }), 146 - ) 147 - } 148 - _ -> #(model, effect.none()) 149 - } 150 - } 151 - 152 - MastodonAnchored(instance:, post:) -> { 153 - let model = 154 - Model( 155 - ..model, 156 - mastodon_anchor: Some(anchors.Mastodon(instance:, postid: post)), 157 - ) 158 - #( 159 - model, 160 - effect.batch([get_username_mastodon(model), refresh_mastodon(model)]), 161 - ) 162 - } 163 - MastodonIncomingOpUsername(mastodon_op_username_and_posturl) -> #( 164 - Model(..model, mastodon_op_username_and_posturl:), 165 - effect.none(), 166 - ) 167 - MastodonIncomingStatus(status) -> { 168 - #( 169 - Model(..model, cached_mastodon_descendants: status.descendants), 170 - new_coalesced_view(model.cached_bluesky_replies, status.descendants), 171 - ) 172 - } 173 - 174 - BskyUnAnchored -> #(Model(..model, bluesky_anchor: None), effect.none()) 175 - BskyAnchored(did:, post:) -> { 176 - let model = 177 - Model( 178 - ..model, 179 - bluesky_anchor: Some(anchors.Bluesky(did:, postid: post)), 180 - ) 181 - #(model, effect.batch([refresh_bsky(model)])) 182 - } 183 - BskyIncomingThreadView(threadview) -> { 184 - #( 185 - Model(..model, cached_bluesky_replies: threadview.replies), 186 - new_coalesced_view( 187 - threadview.replies, 188 - model.cached_mastodon_descendants, 189 - ), 190 - ) 191 - } 192 - SetTab(open_tab) -> #(Model(..model, open_tab:), effect.none()) 193 - // And then... everything comes together! 194 - IncomingCoalescedView(new) -> #( 195 - Model(..model, cached_coalesced_view: new), 196 - effect.none(), 197 - ) 198 - MastodonErrorFetchingOpUsernameRetryForMisskey -> #( 199 - model, 200 - get_username_miskey(model), 201 - ) 202 - } 203 - } 204 - 205 - // VIEW ------------------------------------------------------------------------ 206 - 207 - fn view(model: Model) -> Element(Msg) { 208 - case model.all_stopping_error { 209 - None -> view_normal(model) 210 - Some(error) -> error_view(error) 211 - } 212 - } 213 - 214 - fn view_normal(model: Model) -> Element(Msg) { 215 - let about_linkie = 216 - html.div([], [ 217 - html.a( 218 - [ 219 - attribute.class("float-right link link-secondary-content/40 text-xs"), 220 - ..case 221 - bool.exclusive_or( 222 - option.is_some(model.bluesky_anchor), 223 - option.is_some(model.mastodon_anchor), 224 - ), 225 - model.open_tab == 3 226 - { 227 - True, True -> [event.on_click(SetTab(1))] 228 - True, False -> [event.on_click(SetTab(3))] 229 - False, False -> [ 230 - event.on_click(SetTab(3)), 231 - attribute.class("absolute right-2 top-1"), 232 - ] 233 - False, True -> [ 234 - attribute.class("hidden chilp-oat-enhance-hide"), 235 - ] 236 - } 237 - ], 238 - [ 239 - html.text(case model.open_tab == 3 { 240 - False -> "About" 241 - True -> "×" 242 - }), 243 - ], 244 - ), 245 - ]) 246 - 247 - let #(linkedto, respond_here) = case 248 - model.bluesky_anchor, 249 - model.mastodon_anchor 250 - { 251 - Some(bsky), Some(masto) -> { 252 - #( 253 - [ 254 - html.text("Linked to "), 255 - case 256 - model.mastodon_op_username_and_posturl.1 257 - |> string.contains("/notes/") 258 - { 259 - True -> 260 - html.a( 261 - [ 262 - attribute.href(model.mastodon_op_username_and_posturl.1), 263 - attribute.class("link text-[#3da0c8]"), 264 - ], 265 - [html.text("this note on Sharkey")], 266 - ) 267 - False -> 268 - html.a( 269 - [ 270 - attribute.href(model.mastodon_op_username_and_posturl.1), 271 - attribute.class("link text-[#595aff]"), 272 - ], 273 - [html.text("this post on Mastodon")], 274 - ) 275 - }, 276 - html.text(", and to "), 277 - html.a( 278 - [ 279 - attribute.href( 280 - "https://bsky.app/profile/" 281 - <> bsky.did 282 - <> "/post/" 283 - <> bsky.postid, 284 - ), 285 - attribute.class("link text-[#006aff]"), 286 - ], 287 - [html.text("this post on Bluesky")], 288 - ), 289 - html.text("."), 290 - ] 291 - |> element.fragment, 292 - [ 293 - html.div( 294 - [ 295 - attribute.class("tabs tabs-box border-b-2 border-base-300 h-full"), 296 - attribute.role("tablist"), 297 - ], 298 - [ 299 - html.button( 300 - [ 301 - attribute.role("tab"), 302 - event.on_click(SetTab(1)), 303 - attribute.classes([ 304 - #("tab", True), 305 - #("tab-active outline", model.open_tab == 1), 306 - ]), 307 - ], 308 - [ 309 - html.text("Mastodon"), 310 - ], 311 - ), 312 - html.button( 313 - [ 314 - attribute.role("tab"), 315 - event.on_click(SetTab(2)), 316 - attribute.classes([ 317 - #("tab", True), 318 - #("tab-active outline", model.open_tab == 2), 319 - ]), 320 - ], 321 - [html.text("Bluesky")], 322 - ), 323 - ], 324 - ), 325 - html.br([]), 326 - case model.open_tab { 327 - 2 -> 328 - view_respond_on_bsky( 329 - "https://bsky.app/profile/" 330 - <> bsky.did 331 - <> "/post/" 332 - <> bsky.postid, 333 - ) 334 - 3 -> view_about_chilp() 335 - _ -> view_mastodon_respond_form(masto) 336 - }, 337 - ] 338 - |> element.fragment(), 339 - ) 340 - } 341 - None, Some(masto) -> { 342 - #( 343 - case model.open_tab { 344 - 3 -> view_about_chilp() 345 - _ -> 346 - [ 347 - html.text("Linked to "), 348 - case 349 - model.mastodon_op_username_and_posturl.1 350 - |> string.contains("/notes/") 351 - { 352 - True -> 353 - html.a( 354 - [ 355 - attribute.href(model.mastodon_op_username_and_posturl.1), 356 - attribute.class("link text-[#3da0c8]"), 357 - ], 358 - [html.text("this note on Sharkey")], 359 - ) 360 - False -> 361 - html.a( 362 - [ 363 - attribute.href(model.mastodon_op_username_and_posturl.1), 364 - attribute.class("link text-[#595aff]"), 365 - ], 366 - [html.text("this post on Mastodon")], 367 - ) 368 - }, 369 - 370 - html.text("."), 371 - ] 372 - |> element.fragment 373 - }, 374 - view_mastodon_respond_form(masto), 375 - ) 376 - } 377 - Some(bsky), None -> { 378 - #( 379 - case model.open_tab { 380 - 3 -> view_about_chilp() 381 - _ -> 382 - [ 383 - html.text("Linked to "), 384 - html.a( 385 - [ 386 - attribute.href( 387 - "https://bsky.app/profile/" 388 - <> bsky.did 389 - <> "/post/" 390 - <> bsky.postid, 391 - ), 392 - attribute.class("link link-[#006aff]"), 393 - ], 394 - [html.text("this post on Bluesky")], 395 - ), 396 - html.text("."), 397 - ] 398 - |> element.fragment 399 - }, 400 - view_respond_on_bsky( 401 - "https://bsky.app/profile/" <> bsky.did <> "/post/" <> bsky.postid, 402 - ), 403 - ) 404 - } 405 - None, None -> #( 406 - error_view("No comment backends are configured for this widget."), 407 - element.none(), 408 - ) 409 - } 410 - html.div([attribute.class("comment-widget")], [ 411 - html.div( 412 - [ 413 - attribute.class( 414 - "widget card bg-base-100 shadow-xl border border-base-200 p-10", 415 - ), 416 - ], 417 - [ 418 - html.h1([attribute.class("text-2xl font-extrabold text-base-content")], [ 419 - html.text("Comments"), 420 - ]), 421 - html.p([attribute.class("text-sm text-base-content/70")], [ 422 - about_linkie, 423 - linkedto, 424 - ]), 425 - html.div( 426 - [ 427 - attribute.class("card card-dash bg-base-200/70 border-base-300"), 428 - ], 429 - [ 430 - respond_here, 431 - ], 432 - ), 433 - html.section( 434 - [attribute.class("pt-5 space-y-10")], 435 - list.sort(model.cached_coalesced_view, sort_comments) 436 - |> list.map(view_rendered_comment( 437 - _, 438 - option.map(model.bluesky_anchor, fn(bsky) { 439 - "https://bsky.app/profile/" <> bsky.did 440 - }), 441 - option.map(model.mastodon_anchor, fn(masto) { 442 - "https://" 443 - <> masto.instance 444 - <> "/@" 445 - <> model.mastodon_op_username_and_posturl.0 446 - }), 447 - )), 448 - ), 449 - ], 450 - ), 451 - ]) 452 - } 453 - 454 - fn view_rendered_comment( 455 - comment: CoalescedView, 456 - bsky_op_profile: Option(String), 457 - mastodon_op_profile: Option(String), 458 - ) -> Element(Msg) { 459 - let is_by_op = { 460 - case comment.source, bsky_op_profile, mastodon_op_profile { 461 - "Mastodon", _, Some(op) -> op == comment.author_profile_link 462 - 463 - "Bluesky", Some(op), _ -> { 464 - // Had some mismatches here, but decided it is of no importance what hostname we use. 465 - { 466 - op 467 - |> string.replace("https://bsky.app", "") 468 - |> string.replace("https://bluesky.app", "") 469 - } 470 - == { 471 - comment.author_profile_link 472 - |> string.replace("https://bsky.app", "") 473 - |> string.replace("https://bluesky.app", "") 474 - } 475 - } 476 - _, _, _ -> False 477 - } 478 - } 479 - let commands = 480 - list.filter_map(comment.children, fn(child) { 481 - case 482 - { 483 - case comment.source, bsky_op_profile, mastodon_op_profile { 484 - "Mastodon", _, Some(op) -> op == child.author_profile_link 485 - 486 - "Bluesky", Some(op), _ -> { 487 - // Had some mismatches here, but decided it is of no importance what hostname we use. 488 - { 489 - op 490 - |> string.replace("https://bsky.app", "") 491 - |> string.replace("https://bluesky.app", "") 492 - } 493 - == { 494 - child.author_profile_link 495 - |> string.replace("https://bsky.app", "") 496 - |> string.replace("https://bluesky.app", "") 497 - } 498 - } 499 - _, _, _ -> False 500 - } 501 - } 502 - { 503 - // Not by op 504 - False -> Error(Nil) 505 - True -> { 506 - let content = 507 - element.to_readable_string(child.content) |> string.lowercase() 508 - case string.starts_with(content, "-chilp ") { 509 - True -> Ok(content) 510 - False -> Error(Nil) 511 - } 512 - } 513 - } 514 - }) 515 - let is_hidden = list.any(commands, string.starts_with(_, "-chilp hide")) 516 - let is_silenced = list.any(commands, string.starts_with(_, "-chilp silence")) 517 - // Is the comment we're currently trying to parse a command? Even unauthorised commands will not be rendered. 518 - let is_command = { 519 - string.starts_with( 520 - element.to_readable_string(comment.content) |> string.lowercase(), 521 - "-chilp ", 522 - ) 523 - } 524 - use <- bool.guard(when: is_hidden, return: element.none()) 525 - use <- bool.guard(when: is_command, return: element.none()) 526 - html.article([attribute.class("comment mt-2")], [ 527 - html.header([attribute.class("flex")], [ 528 - html.figure( 529 - [ 530 - attribute("aria-label", comment.author_username), 531 - attribute.class("small"), 532 - attribute("data-variant", "avatar"), 533 - ], 534 - [ 535 - html.img([ 536 - attribute.src(comment.author_avatar_url), 537 - attribute.class( 538 - "avatar w-[45px] h-[45px] mask mask-squircle flex-none", 539 - ), 540 - attribute.alt("@"), 541 - ]), 542 - ], 543 - ), 544 - html.div([attribute.class("meta pl-4 max-h-[45px]")], [ 545 - html.span( 546 - [attribute.class("display-name chilp-oat-enhance-inset-1em")], 547 - [ 548 - html.text(comment.displayname), 549 - case comment.source { 550 - "Mastodon" -> 551 - html.div( 552 - [ 553 - attribute("data-variant", "secondary"), 554 - attribute.class( 555 - "badge badge-sm badge-info ms-2 bg-[#595aff] text-white", 556 - ), 557 - ], 558 - [ 559 - element.text("Fediverse"), 560 - ], 561 - ) 562 - "Bluesky" -> 563 - html.div( 564 - [ 565 - attribute("data-variant", "secondary"), 566 - attribute.class( 567 - "badge badge-sm badge-info ms-2 bg-[#006aff] text-white", 568 - ), 569 - ], 570 - [ 571 - element.text("Bluesky"), 572 - ], 573 - ) 574 - _ -> element.none() 575 - }, 576 - case is_by_op { 577 - True -> 578 - html.div( 579 - [ 580 - attribute("data-variant", "outline"), 581 - attribute.class("badge badge-sm badge-accent ms-2"), 582 - ], 583 - [ 584 - element.text("Author"), 585 - ], 586 - ) 587 - False -> element.none() 588 - }, 589 - ], 590 - ), 591 - html.p([attribute.class("text-xs")], [ 592 - html.a( 593 - [ 594 - attribute.href(comment.author_profile_link), 595 - attribute.class("link link-secondary-content link-sm"), 596 - ], 597 - [element.text("@" <> comment.author_username)], 598 - ), 599 - element.text(" • "), 600 - html.time( 601 - [ 602 - attribute( 603 - "datetime", 604 - comment.created_at |> timestamp.to_rfc3339(calendar.utc_offset), 605 - ), 606 - ], 607 - [ 608 - element.text({ 609 - let b = 610 - case 611 - timestamp.difference( 612 - comment.created_at, 613 - timestamp.system_time(), 614 - ) 615 - |> duration.approximate 616 - |> pair.map_second(fn(d) { 617 - case d { 618 - duration.Nanosecond -> "nanosecond" 619 - duration.Microsecond -> "microsecond" 620 - duration.Millisecond -> "millisecond" 621 - duration.Second -> "second" 622 - duration.Minute -> "minute" 623 - duration.Hour -> "hour" 624 - duration.Day -> "day" 625 - duration.Week -> "week" 626 - duration.Month -> "month" 627 - duration.Year -> "year" 628 - } 629 - }) 630 - { 631 - #(1, x) -> #(1, x) 632 - #(x, d) -> #(x, d <> "s") 633 - } 634 - |> pair.map_first(int.to_string) 635 - 636 - b.0 <> " " <> b.1 <> " ago." 637 - }), 638 - ], 639 - ), 640 - ]), 641 - ]), 642 - ]), 643 - html.section([attribute.class("content mt-5")], [ 644 - html.span([], [comment.content]), 645 - ]), 646 - html.footer([], [ 647 - html.div([attribute.class("my-5")], [ 648 - html.a( 649 - [ 650 - attribute.class("btn btn-sm absolute right-8"), 651 - attribute.href(comment.content_url), 652 - attribute.target("_blank"), 653 - ], 654 - [html.text("View comment on " <> comment.source)], 655 - ), 656 - ]), 657 - html.br([attribute.class("border-b-2 border-dotted")]), 658 - case comment.children, is_silenced { 659 - [], _ | _, True -> element.none() 660 - _, False -> 661 - html.section( 662 - [ 663 - attribute.class( 664 - "pl-5 border-s-4 border-default bg-neutral-secondary-soft chilp-oat-enhance-inset-2em", 665 - ), 666 - ], 667 - list.sort(comment.children, sort_comments) 668 - |> list.map(fn(child) { 669 - html.div( 670 - [attribute.class("chilp-oat-enhance-inset-quoteblock")], 671 - [ 672 - view_rendered_comment( 673 - child, 674 - bsky_op_profile, 675 - mastodon_op_profile, 676 - ), 677 - ], 678 - ) 679 - }), 680 - ) 681 - }, 682 - ]), 683 - ]) 684 - } 685 - 686 - fn view_about_chilp() -> Element(Msg) { 687 - html.div([attribute.class("my-5 px-5 pb-5 ")], [ 688 - html.p([], [ 689 - element.text("This widget is powered by Chilp! 💬 By MLC Bloeiman"), 690 - ]), 691 - html.p([], [ 692 - element.text("Want to read "), 693 - html.a( 694 - [ 695 - attribute.href("https://strawmelonjuice.com/post/chilpv2"), 696 - attribute.class("link link-primary-content"), 697 - ], 698 - [ 699 - element.text("more about Chilp"), 700 - ], 701 - ), 702 - element.text(" on "), 703 - html.img([ 704 - attribute.src("https://strawmelonjuice.com/strawmelonjuice.svg"), 705 - attribute.width(34), 706 - attribute.class("inline bg-white/65 rounded-lg"), 707 - ]), 708 - element.text(" strawmelonjuice.com?"), 709 - ]), 710 - ]) 711 - } 712 - 713 - fn view_respond_on_bsky(bskylink: String) -> Element(Msg) { 714 - let icon_link = fn(label: String, new_base: String, color_class: String) { 715 - html.button( 716 - [ 717 - attribute.href(string.replace(bskylink, "bsky.app", new_base)), 718 - attribute.target("_blank"), 719 - attribute.class( 720 - "btn btn-circle btn-sm btn-ghost tooltip " <> color_class, 721 - ), 722 - attribute.attribute("data-tip", label), 723 - ], 724 - [ 725 - element.text(string.slice(label, 0, 1)), 726 - ], 727 - ) 728 - } 729 - 730 - html.div([attribute.class("my-5 px-5 pb-5 ")], [ 731 - element.text("Respond to this post on Bluesky to have it show up here!"), 732 - html.div([attribute.class("flex gap-2 items-center")], [ 733 - html.span([attribute.class("text-xs mr-2 opacity-50")], [ 734 - element.text("Open in:"), 735 - ]), 736 - icon_link( 737 - "Bluesky", 738 - "bsky.app", 739 - "text-[#006aff] bg-white chilp-oat-enhance-inset-blueskyreply", 740 - ), 741 - icon_link( 742 - "Blacksky", 743 - "blacksky.community", 744 - "text-white bg-black chilp-oat-enhance-inset-blackskyreply", 745 - ), 746 - icon_link( 747 - "Witchsky", 748 - "witchsky.app", 749 - "bg-[#ed5345] text-white chilp-oat-enhance-inset-witchskyreply", 750 - ), 751 - ]), 752 - ]) 753 - } 754 - 755 - const instancelist = [ 756 - "mastodon.social", 757 - "pony.social", 758 - "todon.nl", 759 - "mstdn.social", 760 - "infosec.exchange", 761 - "woem.space", 762 - "shitpost.trade", 763 - "procial.tchncs.de", 764 - ] 765 - 766 - fn view_mastodon_respond_form(mastodon_anchor: anchors.Mastodon) -> Element(Msg) { 767 - html.div([attribute.class("my-5 px-5 pb-5 ")], [ 768 - html.form( 769 - [ 770 - attribute.class("mb-8 w-full"), 771 - event.on_submit(fn(n) { 772 - let value = 773 - list.key_find(n, "userinstance") 774 - |> result.unwrap("") 775 - MastodonAnswer(value) 776 - }), 777 - ], 778 - [ 779 - html.div([attribute.class("flex flex-col gap-2")], [ 780 - // Label Section 781 - html.label( 782 - [ 783 - attribute.for("userinstance"), 784 - attribute.class("text-sm font-semibold text-base-content/80"), 785 - ], 786 - [ 787 - html.text("Enter your instance address to reply or "), 788 - html.a( 789 - [ 790 - attribute.href( 791 - "https://" 792 - <> placeholder_instance(mastodon_anchor) 793 - <> "/auth/sign_up", 794 - ), 795 - attribute.class("link link-primary-content"), 796 - ], 797 - [html.text("create an account")], 798 - ), 799 - html.text("!"), 800 - ], 801 - ), 802 - 803 - // Disclaimer Section 804 - html.p( 805 - [attribute.class("text-xs italic opacity-50 -mt-1 mb-2 ml-1")], 806 - [ 807 - html.text( 808 - "on the instance recommended by this widget... or one you pick yourself!", 809 - ), 810 - ], 811 - ), 812 - 813 - // The Input + Button Group 814 - html.div([attribute.class("join w-full shadow-sm")], [ 815 - html.input([ 816 - attribute.type_("text"), 817 - attribute.required(True), 818 - attribute.placeholder(placeholder_instance(mastodon_anchor)), 819 - attribute.pattern("^([a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}$"), 820 - attribute.name("userinstance"), 821 - // "join-item" removes the inner borders/radii to make them stick 822 - attribute.class( 823 - "input input-bordered join-item flex-1 focus:outline-primary", 824 - ), 825 - ]), 826 - html.button( 827 - [ 828 - attribute.type_("submit"), 829 - attribute.class("btn btn-primary join-item"), 830 - ], 831 - [html.text("Go reply")], 832 - ), 833 - ]), 834 - ]), 835 - ], 836 - ), 837 - ]) 838 - } 839 - 840 - fn placeholder_instance(mastodon_anchor: anchors.Mastodon) -> String { 841 - [mastodon_anchor.instance, ..instancelist] 842 - |> list.shuffle 843 - |> list.first 844 - |> result.unwrap(mastodon_anchor.instance) 845 - } 846 - 847 - fn error_view(error: String) { 848 - // todo: Style dis. 849 - html.div([attribute.class("alert alert-error"), attribute.role("alert")], [ 850 - svg.svg( 851 - [ 852 - attribute("viewBox", "0 0 24 24"), 853 - attribute("fill", "none"), 854 - attribute.class("h-6 w-6 shrink-0 stroke-current"), 855 - attribute("xmlns", "http://www.w3.org/2000/svg"), 856 - ], 857 - [ 858 - svg.path([ 859 - attribute( 860 - "d", 861 - "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z", 862 - ), 863 - attribute("stroke-width", "2"), 864 - attribute("stroke-linejoin", "round"), 865 - attribute("stroke-linecap", "round"), 866 - ]), 867 - ], 868 - ), 869 - html.span([], [html.text("AN ERROR OCCURED:" <> error)]), 870 - ]) 871 - } 872 - 873 - // EFFECTS --------------------------------------------------------------------- 874 - 875 - fn new_coalesced_view( 876 - bsky_replies: List(BskyThreadReply), 877 - mastodon_descendants: List(MastodonDescendant), 878 - ) { 879 - effect.from(fn(dispatch) { 880 - dispatch( 881 - IncomingCoalescedView(coalesce_views(bsky_replies, mastodon_descendants)), 882 - ) 883 - }) 884 - } 885 - 886 - fn refresh_bsky(model: Model) -> Effect(Msg) { 887 - case model.bluesky_anchor { 888 - Some(anchor) -> { 889 - let url = 890 - "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://" 891 - <> anchor.did 892 - <> "/app.bsky.feed.post/" 893 - <> anchor.postid 894 - 895 - let handler = 896 - rsvp.expect_json(bsky_thread_view_decoder(), fn(response) { 897 - case response { 898 - Ok(threadview) -> BskyIncomingThreadView(threadview) 899 - Error(rsvperror) -> 900 - case rsvperror { 901 - rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 902 - AllStoppingError( 903 - "The response body we got back from Bluesky was misformed.", 904 - ) 905 - rsvp.BadUrl(_) -> 906 - AllStoppingError( 907 - "The API call to Bluesky failed. Did you enter the DID and post id correctly?", 908 - ) 909 - rsvp.HttpError(_) | rsvp.NetworkError -> 910 - AllStoppingError("Could not fetch comments from Bluesky.") 911 - } 912 - } 913 - }) 914 - rsvp.get(url, handler) 915 - } 916 - None -> effect.none() 917 - } 918 - } 919 - 920 - fn get_username_mastodon(model: Model) -> Effect(Msg) { 921 - case model.mastodon_anchor { 922 - Some(anchor) -> { 923 - let url = 924 - "https://" <> anchor.instance <> "/api/v1/statuses/" <> anchor.postid 925 - 926 - let handler = 927 - rsvp.expect_json( 928 - { 929 - use user <- decode.subfield(["account", "username"], decode.string) 930 - decode.success(user) 931 - }, 932 - fn(response) { 933 - case response { 934 - Ok(username) -> 935 - MastodonIncomingOpUsername(#( 936 - username, 937 - "https://" 938 - <> anchor.instance 939 - <> "/@" 940 - <> username 941 - <> "/" 942 - <> anchor.postid, 943 - )) 944 - Error(rsvperror) -> 945 - case rsvperror { 946 - rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 947 - AllStoppingError( 948 - "The response body we got back from Mastodon was misformed.", 949 - ) 950 - rsvp.BadUrl(_) -> 951 - AllStoppingError( 952 - "The API call to Mastodon failed. Did you enter the instance and post id correctly?", 953 - ) 954 - rsvp.HttpError(_) -> { 955 - // This may mean something else! This might not be all-stopping just yet. 956 - // If this is a Misskey or Sharkey instance, we can still save things! 957 - MastodonErrorFetchingOpUsernameRetryForMisskey 958 - } 959 - rsvp.NetworkError -> 960 - AllStoppingError( 961 - "Could not fetch comments from Mastodon. (NetworkError)", 962 - ) 963 - } 964 - } 965 - }, 966 - ) 967 - rsvp.get(url, handler) 968 - } 969 - None -> effect.none() 970 - } 971 - } 972 - 973 - fn get_username_miskey(model: Model) -> Effect(Msg) { 974 - case model.mastodon_anchor { 975 - Some(anchor) -> { 976 - let url = "https://" <> anchor.instance <> "/api/notes/show" 977 - 978 - let handler = 979 - rsvp.expect_json( 980 - { 981 - use user <- decode.subfield(["user", "username"], decode.string) 982 - decode.success(user) 983 - }, 984 - fn(response) { 985 - case response { 986 - Ok(username) -> 987 - MastodonIncomingOpUsername(#( 988 - username, 989 - "https://" <> anchor.instance <> "/notes/" <> anchor.postid, 990 - )) 991 - Error(rsvperror) -> 992 - case rsvperror { 993 - rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 994 - AllStoppingError( 995 - "The response body we got back from Misskey/Sharkey was misformed.", 996 - ) 997 - rsvp.BadUrl(_) -> 998 - AllStoppingError( 999 - "The API call to Misskey/Sharkey failed. Did you enter the instance and post id correctly?", 1000 - ) 1001 - rsvp.HttpError(_) -> { 1002 - // ... if we end up here again, then, yes, we have in fact failed... Sorry! 1003 - AllStoppingError( 1004 - "Could not fetch comments from Mastodon/Misskey/Sharkey. (HttpError)", 1005 - ) 1006 - } 1007 - rsvp.NetworkError -> 1008 - AllStoppingError( 1009 - "Could not fetch comments from Misskey/Sharkey. (NetworkError)", 1010 - ) 1011 - } 1012 - } 1013 - }, 1014 - ) 1015 - rsvp.post( 1016 - url, 1017 - json.object([#("noteId", json.string(anchor.postid))]), 1018 - handler, 1019 - ) 1020 - } 1021 - None -> effect.none() 1022 - } 1023 - } 1024 - 1025 - fn refresh_mastodon(model: Model) -> Effect(Msg) { 1026 - case model.mastodon_anchor { 1027 - Some(anchor) -> { 1028 - let url = 1029 - "https://" 1030 - <> anchor.instance 1031 - <> "/api/v1/statuses/" 1032 - <> anchor.postid 1033 - <> "/context" 1034 - 1035 - let handler = 1036 - rsvp.expect_json( 1037 - mastodon_status_context_decoder(anchor.postid), 1038 - fn(response) { 1039 - case response { 1040 - Ok(status) -> MastodonIncomingStatus(status) 1041 - Error(rsvperror) -> 1042 - case rsvperror { 1043 - rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 1044 - AllStoppingError( 1045 - "The response body we got back from Mastodon was misformed.", 1046 - ) 1047 - rsvp.BadUrl(_) -> 1048 - AllStoppingError( 1049 - "The API call to Mastodon failed. Did you enter the instance and post id correctly?", 1050 - ) 1051 - rsvp.HttpError(_) | rsvp.NetworkError -> 1052 - AllStoppingError("Could not fetch comments from Mastodon.") 1053 - } 1054 - } 1055 - }, 1056 - ) 1057 - rsvp.get(url, handler) 1058 - } 1059 - None -> effect.none() 1060 - } 1061 - } 1062 - 1063 - fn browse(to: String) { 1064 - use _ <- effect.from 1065 - js_browse(to) 1066 - } 1067 - 1068 - // HELPERS --------------------------------------------------------------------- 1069 - 1070 - /// Attempts to do what DOMpurify does... ...while lustre-ifying it! 1071 - fn sanitise_ls(html: String) -> element.Element(a) { 1072 - // To a tree is the easy part. 1073 - html_parser.as_tree(html) 1074 - // Then reconstructing it sanely... 1075 - |> sanitise_reconstruct_ls 1076 - } 1077 - 1078 - fn sanitise_reconstruct_ls(el: html_parser.Element) -> element.Element(a) { 1079 - case el { 1080 - html_parser.EmptyElement -> element.none() 1081 - html_parser.StartElement(name:, attributes:, children:) -> { 1082 - // attributes 1083 - let attribs = 1084 - list.map(attributes, fn(attrib) { 1085 - case attrib { 1086 - html_parser.Attribute(key: "href", value: link) -> 1087 - attribute.href(link) 1088 - html_parser.Attribute(key: "class", value: classes) -> 1089 - attribute.class(classes) 1090 - html_parser.Attribute(key: "target", value: target) -> 1091 - attribute.target(target) 1092 - html_parser.Attribute(_, _) -> attribute.none() 1093 - } 1094 - }) 1095 - [ 1096 - list.map(children, sanitise_reconstruct_ls) 1097 - |> case name { 1098 - "b" -> html.b(attribs, _) 1099 - "i" -> html.i(attribs, _) 1100 - "em" -> html.em(attribs, _) 1101 - "strong" -> html.strong(attribs, _) 1102 - "a" -> html.a( 1103 - [attribute.class("link link-secondary-content"), ..attribs], 1104 - _, 1105 - ) 1106 - 1107 - "p" -> html.p(attribs, _) 1108 - "br" -> fn(_) { html.br(attribs) } 1109 - "span" -> html.span(attribs, _) 1110 - _ -> element.fragment 1111 - }, 1112 - element.text(" "), 1113 - ] 1114 - |> element.fragment() 1115 - } 1116 - // AFAIK we don't have this due to parsing as tree. 1117 - html_parser.EndElement(_) -> 1118 - element.text("ERROR: Did not expect an element end here!") 1119 - html_parser.Content(cnt) -> element.text(cnt) 1120 - } 1121 - } 1122 - 1123 - fn sort_comments(c1: CoalescedView, c2: CoalescedView) -> order.Order { 1124 - case int.compare(c1.agreeability, c2.agreeability) { 1125 - order.Eq -> timestamp.compare(c1.created_at, c2.created_at) 1126 - measure -> measure 1127 - } 1128 - } 1129 - 1130 - @external(javascript, "./ffi.mjs", "lassign") 1131 - fn js_browse(_: String) -> Nil { 1132 - Nil 1133 - } 1134 - 1135 - /// A Bluesky Threadview, like what you get from `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgrbiiadws2k`. 1136 - /// This one is very pruned! Why? Because these json responses are huge and we only need a small subset of the data in them! 1137 - type BskyThreadView { 1138 - BskyThreadView(at_uri: String, replies: List(BskyThreadReply)) 1139 - } 1140 - 1141 - type BskyThreadReply { 1142 - BskyThreadReply( 1143 - at_uri: String, 1144 - like_count: Int, 1145 - created_at: timestamp.Timestamp, 1146 - body_text: String, 1147 - author_did: String, 1148 - author_handle: String, 1149 - author_displayname: String, 1150 - author_avatar: String, 1151 - children: List(BskyThreadReply), 1152 - ) 1153 - } 1154 - 1155 - fn bsky_thread_view_decoder() -> decode.Decoder(BskyThreadView) { 1156 - use at_uri <- decode.subfield(["thread", "post", "uri"], decode.string) 1157 - use replies <- decode.subfield( 1158 - ["thread", "replies"], 1159 - decode.list(bsky_thread_reply_decoder()), 1160 - ) 1161 - decode.success(BskyThreadView(at_uri:, replies:)) 1162 - } 1163 - 1164 - fn bsky_thread_reply_decoder() -> decode.Decoder(BskyThreadReply) { 1165 - use created_at <- decode.subfield( 1166 - ["post", "record", "createdAt"], 1167 - decode.map(decode.string, fn(stringstamp) { 1168 - result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) 1169 - }), 1170 - ) 1171 - use at_uri <- decode.subfield(["post", "uri"], decode.string) 1172 - use body_text <- decode.subfield(["post", "record", "text"], decode.string) 1173 - 1174 - use author_did <- decode.subfield(["post", "author", "did"], decode.string) 1175 - use author_handle <- decode.subfield( 1176 - ["post", "author", "handle"], 1177 - decode.string, 1178 - ) 1179 - use author_displayname <- decode.subfield( 1180 - ["post", "author", "displayName"], 1181 - decode.string, 1182 - ) 1183 - use author_avatar <- decode.subfield( 1184 - ["post", "author", "avatar"], 1185 - decode.string, 1186 - ) 1187 - use like_count <- decode.subfield(["post", "likeCount"], decode.int) 1188 - use children <- decode.field( 1189 - "replies", 1190 - decode.list(bsky_thread_reply_decoder()), 1191 - ) 1192 - decode.success(BskyThreadReply( 1193 - at_uri:, 1194 - created_at:, 1195 - body_text:, 1196 - like_count:, 1197 - author_did:, 1198 - author_handle:, 1199 - author_displayname:, 1200 - author_avatar:, 1201 - children:, 1202 - )) 1203 - } 1204 - 1205 - /// Subset of a Mastodon Status-context, like what you get from `https://pony.social/api/v1/statuses/115911235653686237/context`. 1206 - type MastodonStatusContext { 1207 - MastodonStatusContext(descendants: List(MastodonDescendant)) 1208 - } 1209 - 1210 - fn mastodon_status_context_decoder( 1211 - original_id: String, 1212 - ) -> decode.Decoder(MastodonStatusContext) { 1213 - use flat_descendants <- decode.field( 1214 - "descendants", 1215 - decode.list(mastodon_descendant_decoder()), 1216 - ) 1217 - let descendants: List(MastodonDescendant) = 1218 - list.filter_map(flat_descendants, fn(desc) { 1219 - case desc.1 == original_id { 1220 - True -> { 1221 - // A parent! 1222 - 1223 - mastodon_decendant_inflater(desc.0, flat_descendants) 1224 - |> Ok 1225 - } 1226 - False -> { 1227 - Error(Nil) 1228 - } 1229 - } 1230 - }) 1231 - decode.success(MastodonStatusContext(descendants:)) 1232 - } 1233 - 1234 - fn mastodon_decendant_inflater( 1235 - parent: MastodonDescendant, 1236 - all_children: List(#(MastodonDescendant, String)), 1237 - ) { 1238 - MastodonDescendant( 1239 - ..parent, 1240 - children: list.filter_map(all_children, fn(c) { 1241 - case c.1 == parent.id { 1242 - True -> Ok(c.0) 1243 - False -> Error(Nil) 1244 - } 1245 - }), 1246 - ) 1247 - } 1248 - 1249 - type MastodonDescendant { 1250 - MastodonDescendant( 1251 - id: String, 1252 - uri: String, 1253 - content: element.Element(Msg), 1254 - created_at: timestamp.Timestamp, 1255 - favourite_count: Int, 1256 - // This one is not populated by the decoder, Mastodon API provides the tree flat, with fields (replying_to) to tell you which is child and which is parent. 1257 - // We gotta iterate over this later to get things nested. 1258 - children: List(MastodonDescendant), 1259 - author_url: String, 1260 - author_avatar_url: String, 1261 - author_username: String, 1262 - author_displayname: String, 1263 - ) 1264 - } 1265 - 1266 - /// Decodes #(MastodonDescendant, ReplyingTo), to enable the parent decoder to make this into a recursive three. 1267 - fn mastodon_descendant_decoder() -> decode.Decoder( 1268 - #(MastodonDescendant, String), 1269 - ) { 1270 - use replying_to <- decode.field("in_reply_to_id", decode.string) 1271 - use id <- decode.field("id", decode.string) 1272 - use uri <- decode.field("uri", decode.string) 1273 - use created_at <- decode.field( 1274 - "created_at", 1275 - decode.map(decode.string, fn(stringstamp) { 1276 - result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) 1277 - }), 1278 - ) 1279 - use favourite_count <- decode.field("favourites_count", decode.int) 1280 - use unescaped_html_content <- decode.field("content", decode.string) 1281 - let content = sanitise_ls(unescaped_html_content) 1282 - 1283 - let children = [] 1284 - 1285 - use author_url <- decode.subfield(["account", "url"], decode.string) 1286 - use author_avatar_url <- decode.subfield(["account", "avatar"], decode.string) 1287 - use author_displayname <- decode.subfield( 1288 - ["account", "display_name"], 1289 - decode.string, 1290 - ) 1291 - use author_username <- decode.subfield(["account", "acct"], decode.string) 1292 - 1293 - decode.success(#( 1294 - MastodonDescendant( 1295 - id:, 1296 - uri:, 1297 - content:, 1298 - created_at:, 1299 - favourite_count:, 1300 - children:, 1301 - author_url:, 1302 - author_avatar_url:, 1303 - author_username:, 1304 - author_displayname:, 1305 - ), 1306 - replying_to, 1307 - )) 1308 - } 1309 - 1310 - fn coalesce_views( 1311 - bsky: List(BskyThreadReply), 1312 - mastodon: List(MastodonDescendant), 1313 - ) -> List(CoalescedView) { 1314 - let mixed: List(Result(BskyThreadReply, MastodonDescendant)) = { 1315 - list.append( 1316 - list.map(bsky, fn(m) { Ok(m) }), 1317 - list.map(mastodon, fn(m) { Error(m) }), 1318 - ) 1319 - |> list.shuffle 1320 - } 1321 - list.map(mixed, fn(item) { 1322 - case item { 1323 - Ok(BskyThreadReply( 1324 - created_at:, 1325 - at_uri:, 1326 - like_count:, 1327 - body_text:, 1328 - author_did:, 1329 - author_displayname:, 1330 - author_handle:, 1331 - author_avatar:, 1332 - children:, 1333 - )) -> 1334 - CoalescedView( 1335 - content_url: "https://bsky.app/profile/" 1336 - <> { 1337 - string.replace(at_uri, "/app.bsky.feed.post/", "/post/") 1338 - |> string.replace("at://", "") 1339 - }, 1340 - created_at:, 1341 - author_profile_link: "https://bluesky.app/profile/" <> author_did, 1342 - source: "Bluesky", 1343 - agreeability: like_count, 1344 - content: element.text(body_text), 1345 - author_username: author_handle, 1346 - author_avatar_url: author_avatar, 1347 - displayname: author_displayname, 1348 - children: { coalesce_views(children, []) }, 1349 - ) 1350 - Error(MastodonDescendant( 1351 - created_at:, 1352 - id: _, 1353 - uri:, 1354 - content:, 1355 - favourite_count:, 1356 - children:, 1357 - author_url:, 1358 - author_avatar_url:, 1359 - author_username:, 1360 - author_displayname:, 1361 - )) -> 1362 - CoalescedView( 1363 - content_url: uri, 1364 - created_at:, 1365 - author_profile_link: author_url, 1366 - source: "Mastodon", 1367 - displayname: author_displayname, 1368 - agreeability: favourite_count, 1369 - author_avatar_url:, 1370 - author_username:, 1371 - content: content, 1372 - children: { coalesce_views([], children) }, 1373 - ) 1374 - } 1375 - }) 1376 - } 1377 - 1378 - type CoalescedView { 1379 - 1380 - CoalescedView( 1381 - created_at: timestamp.Timestamp, 1382 - author_profile_link: String, 1383 - author_avatar_url: String, 1384 - author_username: String, 1385 - source: String, 1386 - displayname: String, 1387 - content: element.Element(Msg), 1388 - content_url: String, 1389 - agreeability: Int, 1390 - children: List(CoalescedView), 1391 - ) 1392 - }
-19
src/chilp/widget/anchors.gleam
··· 1 - pub type Mastodon { 2 - Mastodon( 3 - /// The instance name, e.g. mastodon.social, this is where your post is stored, and where chilp will attempt to fetch it from. 4 - instance: String, 5 - /// A post id to bind to, you'll find this in a post url `https://mastodon.social/@<username>/[postid]`. 6 - postid: String, 7 - ) 8 - } 9 - 10 - pub type Bluesky { 11 - Bluesky( 12 - /// Your DID, for `@strawmelonjuice.com`, this looks like `"did:plc:jgtfsmv25thfs4zmydtbccnn"`. 13 - /// 14 - /// Not sure how to find your DID? <https://bsky-did.neocities.org> is one of the many places where you can easily find it. 15 - did: String, 16 - /// A post id to bind to, you'll find this in a post url `https://bsky.app/profile/<your-username-or-did>/post/[postid]` 17 - postid: String, 18 - ) 19 - }