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

Configure Feed

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

And so it begins again


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

MLC Bloeiman (Jun 5, 2026, 4:43 PM +0200) 0bd1221d 05048c55

+173 -88
+173 -88
src/chilp.gleam
··· 225 225 //// # Chilp 226 226 //// 227 227 228 - // --black: #2a2020; 229 - // --hard-black: #000; 230 - // --pink: #ffaff3; 231 - // --hot-pink: #d900b8; 232 - // --white: #fff; 233 - // --pink-white: #fff8fe; 234 - // --mid-grey: #dfe2e5; 235 - // --light-grey: #f5f5f5; 236 - // --boi-blue: #a6f0fc; 237 - // --text: var(--black); 238 - // --background: var(--white); 239 - // --accented-background: var(--pink-white); 240 - // --code-border: var(--pink); 241 - // --code-background: var(--light-grey); 242 - // --table-border: var(--mid-grey); 243 - // --table-background: var(--pink-white); 244 - // --links: var(--hot-pink); 245 - // --accent: var(--pink); 246 - // --content-width: 772px; 247 - // --header-height: 60px; 248 - // --hash-offset: calc(var(--header-height) * 1.67); 249 - // --sidebar-width: 240px; 250 - // --gap: 24px; 251 - // --small-gap: calc(var(--gap) / 2); 252 - // --tiny-gap: calc(var(--small-gap) / 2); 253 - // --large-gap: calc(var(--gap) * 2); 254 - // --sidebar-toggle-size: 33px; 255 - // --search-height: 4rem; 256 - // --shadow: 0 0 0 1px rgba(50, 50, 93, 0.075), 0 0 1px #e9ecef, 257 - // 0 2px 4px -2px rgba(138, 141, 151, 0.6); 258 - // --nav-shadow: 0 0 6px 2px rgba(0, 0, 0, 0.1); 259 - 260 228 // IMPORTS --------------------------------------------------------------------- 261 229 import gleam/bool 262 230 import gleam/dynamic/decode ··· 322 290 /// // We connect to Bluesky... 323 291 /// bluesky: option.Some(chilp.bluesky("did:plc:my-did","myPostId")), 324 292 /// // ...But not to Mastodon? Sure! Make it a None! 325 - /// mastodon: option.None 293 + /// mastodon: option.None, 294 + /// attributes: [] 326 295 /// ) 327 296 /// ``` 328 297 /// ··· 330 299 /// ```gleam 331 300 /// html.text("I use a cool list of devices!") 332 301 /// chilp.widget( 333 - /// bluesky: option.Some(chilp.bluesky(did: "did:plc:jgtfsmv25thfs4zmydtbccnn", post_id: "3mk5u4xwqus2i")), 334 - /// mastodon: option.Some(chilp.mastodon(instance: "pony.social", post_id: "116453666510859359")) 302 + /// chilp.bluesky(did: "did:plc:jgtfsmv25thfs4zmydtbccnn", post_id: "3mk5u4xwqus2i"), 303 + /// mastodon: option.Some(chilp.mastodon(instance: "pony.social", post_id: "116453666510859359")), 304 + /// [] 335 305 /// ) 336 306 /// ``` 337 307 /// Yields you: ··· 342 312 pub fn widget( 343 313 mastodon mastodon_anchor: Option(Mastodon), 344 314 bluesky bsky_anchor: Option(Bluesky), 315 + attributes attributes: List(attribute.Attribute(Message)), 345 316 ) -> Element(msg) { 346 317 element.element( 347 318 "comment-widget", ··· 354 325 Some(anchor) -> anchor.did <> "\\" <> anchor.postid 355 326 None -> "" 356 327 }), 328 + ..attributes 357 329 ], 358 330 [], 359 331 ) 360 332 } 361 333 334 + /// Sets the content of the header atop the Chilp widget 335 + /// If not given, "Comments" is used. 336 + pub fn title(title) { 337 + attribute.title(title) 338 + } 339 + 340 + /// Sets the id of your chilp widget, this also sets that id as a prefix to all other element id's 341 + pub fn id(id) { 342 + attribute.title(id) 343 + } 344 + 345 + /// Decodes passed JSON to Global config, then registers with it. 346 + @internal 347 + pub fn register_json(json: String) { 348 + todo 349 + } 350 + 362 351 /// # Widget activation 363 352 /// 353 + /// > When using plain HTML instead of client-side Lustre, you can also: 354 + /// ```html 355 + /// <script id="chilp-global-config" type="application/json"> 356 + /// { 357 + /// // Your global config options here. 358 + /// } 359 + /// </script> 360 + /// <script type="module" src="https://cdn.jsdelivr.net/npm/@strawmelonjuice/chilp@latest/dist/bundle.js"></script> 361 + /// ``` 362 + /// 364 363 /// You should run this register function before using the widget component, so that Chilp can 365 364 /// listen for it and load the component contents. 366 365 /// 367 - pub fn register() -> Result(Nil, lustre.Error) { 366 + /// This is also where you set up the global Chilp config, providing things as the styling method. 367 + pub fn register(global_config: GlobalConfig) -> Result(Nil, lustre.Error) { 368 368 let component = 369 - lustre.component(init, update, view, [ 370 - component.on_attribute_change("mastodon-anchor", fn(value) { 371 - use <- bool.guard(when: value == "", return: Ok(MastodonUnAnchored)) 372 - value 373 - |> string.split_once("\\") 374 - |> result.map(fn(a) { MastodonAnchored(a.0, a.1) }) 375 - }), 376 - component.on_attribute_change("bluesky-anchor", fn(value) { 377 - use <- bool.guard(when: value == "", return: Ok(BskyUnAnchored)) 378 - value 379 - |> string.split_once("\\") 380 - |> result.map(fn(a) { BskyAnchored(a.0, a.1) }) 381 - }), 382 - ]) 369 + lustre.component( 370 + // fn init 371 + fn(_) { 372 + #( 373 + Model( 374 + title: "Comments", 375 + id: "chilpwidget-" <> int.random(10_000_000) |> int.to_string(), 376 + mastodon_anchor: None, 377 + cached_mastodon_descendants: [], 378 + mastodon_op_username_and_posturl: #("", ""), 379 + bluesky_anchor: None, 380 + cached_bluesky_replies: [], 381 + bsky_op_handle: "", 382 + all_stopping_error: None, 383 + cached_coalesced_view: [], 384 + open_tab: [1, 2] 385 + |> list.shuffle() 386 + |> list.first() 387 + |> result.unwrap(1), 388 + global_config:, 389 + ), 390 + effect.none(), 391 + ) 392 + }, 393 + update, 394 + view, 395 + [ 396 + component.on_attribute_change("title", fn(value) { Ok(TitleSet(value)) }), 397 + component.on_attribute_change("id", fn(value) { Ok(IdSet(value)) }), 398 + component.on_attribute_change("mastodon-anchor", fn(value) { 399 + use <- bool.guard(when: value == "", return: Ok(MastodonUnAnchored)) 400 + value 401 + |> string.split_once("\\") 402 + |> result.map(fn(a) { MastodonAnchored(a.0, a.1) }) 403 + }), 404 + component.on_attribute_change("bluesky-anchor", fn(value) { 405 + use <- bool.guard(when: value == "", return: Ok(BskyUnAnchored)) 406 + value 407 + |> string.split_once("\\") 408 + |> result.map(fn(a) { BskyAnchored(a.0, a.1) }) 409 + }), 410 + ], 411 + ) 383 412 384 413 lustre.register(component, "comment-widget") 385 414 } 386 415 416 + pub type Styling { 417 + /// Styles your widgets using inline CSS, stylable through your defined variables. 418 + /// 419 + /// Assumes you have CSS `--variables` for the following values: 420 + /// - `--bluesky-colour` 421 + /// - `--fediverse-colour` 422 + /// 423 + /// If you miss these, your widget may look different between browsers. 424 + CSS 425 + 426 + /// Same as `CSS`, except also applies some extra styles and takes some styles from the existing [OatUI](https://oat.ink/) config. 427 + /// Assumes you have CSS `--variables` for the following values: 428 + /// - `--bluesky-colour` 429 + /// - `--fediverse-colour` 430 + Oat 431 + 432 + /// Does not read from CSS variables, instead you will need to have Tailwind take `chilp.gleam` as an input, and Chilp will automatically follow your DaisyUI styled site! 433 + DaisyUI 434 + } 435 + 436 + pub type GlobalConfig { 437 + GlobalConfig(styling: Styling) 438 + } 439 + 387 440 // MODEL ----------------------------------------------------------------------- 388 441 389 442 @internal 390 443 pub type Model { 391 444 Model( 445 + title: String, 446 + id: String, 447 + global_config: GlobalConfig, 392 448 mastodon_anchor: Option(Mastodon), 393 449 cached_mastodon_descendants: List(MastodonDescendant), 394 450 mastodon_op_username_and_posturl: #(String, String), ··· 399 455 cached_coalesced_view: List(CoalescedView), 400 456 /// For those not using DaisyUI, using it's hacky way of creating tabs is... hacky. 401 457 /// So we do this the old school way. Tabs in DOM. 402 - /// This value will be ignored if the model only has one anchor. 403 458 open_tab: Int, 404 459 ) 405 460 } ··· 414 469 Bluesky(did: String, postid: String) 415 470 } 416 471 417 - fn init(_) -> #(Model, Effect(Msg)) { 418 - #( 419 - Model( 420 - mastodon_anchor: None, 421 - cached_mastodon_descendants: [], 422 - mastodon_op_username_and_posturl: #("", ""), 423 - bluesky_anchor: None, 424 - cached_bluesky_replies: [], 425 - bsky_op_handle: "", 426 - all_stopping_error: None, 427 - cached_coalesced_view: [], 428 - open_tab: [1, 2] |> list.shuffle() |> list.first() |> result.unwrap(1), 429 - ), 430 - effect.none(), 431 - ) 432 - } 433 - 434 472 // UPDATE ---------------------------------------------------------------------- 435 473 436 474 @internal 437 - pub type Msg { 475 + pub type Message { 438 476 MastodonUnAnchored 439 477 MastodonAnchored(instance: String, post: String) 440 478 BskyUnAnchored ··· 447 485 SetTab(Int) 448 486 MastodonAnswer(instance: String) 449 487 MastodonErrorFetchingOpUsernameRetryForMisskey 488 + TitleSet(String) 489 + IdSet(String) 450 490 } 451 491 452 492 @internal 453 - pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 493 + pub fn update(model: Model, msg: Message) -> #(Model, Effect(Message)) { 454 494 case msg { 455 495 AllStoppingError(msg) -> #( 456 496 Model(..model, all_stopping_error: Some(msg)), ··· 521 561 model, 522 562 get_username_miskey(model), 523 563 ) 564 + TitleSet(new) -> #(Model(..model, title: new), effect.none()) 565 + IdSet(new) -> #(Model(..model, id: new), effect.none()) 524 566 } 525 567 } 526 568 527 569 // VIEW ------------------------------------------------------------------------ 528 570 571 + type IdentifyiableElements { 572 + ElTitle 573 + ElHeader(commentname: String) 574 + ElContent(commentname: String) 575 + ElFooter(commentname: String) 576 + } 577 + 578 + fn attributes( 579 + from model: Model, 580 + on on_element: IdentifyiableElements, 581 + other other: List(attribute.Attribute(Message)), 582 + ) { 583 + let Model(global_config:, id:, ..) = model 584 + case on_element, global_config.styling { 585 + ElTitle, DaisyUI -> [ 586 + attribute.class("text-2xl font-extrabold text-base-content"), 587 + attribute.id(id <> "title"), 588 + ] 589 + ElTitle, CSS -> todo 590 + ElTitle, Oat -> todo 591 + ElHeader(commentname:), _ -> todo 592 + ElContent(commentname:), _ -> todo 593 + ElFooter(commentname:), _ -> todo 594 + } 595 + // Can't use prepend syntax here, bummer. 596 + |> list.append(other) 597 + } 598 + 529 599 @internal 530 - pub fn view(model: Model) -> Element(Msg) { 600 + pub fn view(model: Model) -> Element(Message) { 601 + let attributes: fn(IdentifyiableElements, List(attribute.Attribute(Message))) -> 602 + List(attribute.Attribute(Message)) = fn(on, other) { 603 + attributes(model, on:, other:) 604 + } 531 605 case model.all_stopping_error { 532 - None -> view_normal(model) 533 - Some(error) -> error_view(error) 606 + None -> view_normal(model, attributes) 607 + Some(error) -> error_view(error, attributes) 534 608 } 535 609 } 536 610 537 - fn view_normal(model: Model) -> Element(Msg) { 611 + fn view_normal( 612 + model: Model, 613 + attributes: fn(IdentifyiableElements, List(attribute.Attribute(Message))) -> 614 + List(attribute.Attribute(Message)), 615 + ) -> Element(Message) { 538 616 let about_linkie = 539 617 html.div([], [ 540 618 html.a( ··· 726 804 ) 727 805 } 728 806 None, None -> #( 729 - error_view("No comment backends are configured for this widget."), 807 + error_view( 808 + "No comment backends are configured for this widget.", 809 + attributes, 810 + ), 730 811 element.none(), 731 812 ) 732 813 } ··· 738 819 ), 739 820 ], 740 821 [ 741 - html.h1([attribute.class("text-2xl font-extrabold text-base-content")], [ 742 - html.text("Comments"), 822 + html.h1(attributes(ElTitle, []), [ 823 + html.text(model.title), 743 824 ]), 744 825 html.p([attribute.class("text-sm text-base-content/70")], [ 745 826 about_linkie, ··· 778 859 comment: CoalescedView, 779 860 bsky_op_profile: Option(String), 780 861 mastodon_op_profile: Option(String), 781 - ) -> Element(Msg) { 862 + ) -> Element(Message) { 782 863 let is_by_op = { 783 864 case comment.source, bsky_op_profile, mastodon_op_profile { 784 865 "Mastodon", _, Some(op) -> op == comment.author_profile_link ··· 987 1068 ]) 988 1069 } 989 1070 990 - fn view_about_chilp() -> Element(Msg) { 1071 + fn view_about_chilp() -> Element(Message) { 991 1072 html.div([attribute.class("my-5 px-5 pb-5 ")], [ 992 1073 html.p([], [ 993 1074 element.text("This widget is powered by Chilp! 💬 By MLC Bloeiman"), ··· 1014 1095 ]) 1015 1096 } 1016 1097 1017 - fn view_respond_on_bsky(bskylink: String) -> Element(Msg) { 1098 + fn view_respond_on_bsky(bskylink: String) -> Element(Message) { 1018 1099 let icon_link = fn(label: String, new_base: String, color_class: String) { 1019 1100 html.a( 1020 1101 [ ··· 1074 1155 "procial.tchncs.de", 1075 1156 ] 1076 1157 1077 - fn view_mastodon_respond_form(mastodon_anchor: Mastodon) -> Element(Msg) { 1158 + fn view_mastodon_respond_form(mastodon_anchor: Mastodon) -> Element(Message) { 1078 1159 html.div([attribute.class("my-5 px-5 pb-5 ")], [ 1079 1160 html.form( 1080 1161 [ ··· 1155 1236 |> result.unwrap(mastodon_anchor.instance) 1156 1237 } 1157 1238 1158 - fn error_view(error: String) { 1239 + fn error_view( 1240 + error: String, 1241 + attributes: fn(IdentifyiableElements, List(attribute.Attribute(Message))) -> 1242 + List(attribute.Attribute(Message)), 1243 + ) { 1159 1244 // todo: Style dis. 1160 1245 html.div([attribute.class("alert alert-error"), attribute.role("alert")], [ 1161 1246 svg.svg( ··· 1194 1279 }) 1195 1280 } 1196 1281 1197 - fn refresh_bsky(model: Model) -> Effect(Msg) { 1282 + fn refresh_bsky(model: Model) -> Effect(Message) { 1198 1283 case model.bluesky_anchor { 1199 1284 Some(anchor) -> { 1200 1285 let url = ··· 1228 1313 } 1229 1314 } 1230 1315 1231 - fn get_username_mastodon(model: Model) -> Effect(Msg) { 1316 + fn get_username_mastodon(model: Model) -> Effect(Message) { 1232 1317 case model.mastodon_anchor { 1233 1318 Some(anchor) -> { 1234 1319 let url = ··· 1281 1366 } 1282 1367 } 1283 1368 1284 - fn get_username_miskey(model: Model) -> Effect(Msg) { 1369 + fn get_username_miskey(model: Model) -> Effect(Message) { 1285 1370 case model.mastodon_anchor { 1286 1371 Some(anchor) -> { 1287 1372 let url = "https://" <> anchor.instance <> "/api/notes/show" ··· 1333 1418 } 1334 1419 } 1335 1420 1336 - fn refresh_mastodon(model: Model) -> Effect(Msg) { 1421 + fn refresh_mastodon(model: Model) -> Effect(Message) { 1337 1422 case model.mastodon_anchor { 1338 1423 Some(anchor) -> { 1339 1424 let url = ··· 1565 1650 MastodonDescendant( 1566 1651 id: String, 1567 1652 uri: String, 1568 - content: element.Element(Msg), 1653 + content: element.Element(Message), 1569 1654 created_at: timestamp.Timestamp, 1570 1655 favourite_count: Int, 1571 1656 // 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. ··· 1700 1785 author_username: String, 1701 1786 source: String, 1702 1787 displayname: String, 1703 - content: element.Element(Msg), 1788 + content: element.Element(Message), 1704 1789 content_url: String, 1705 1790 agreeability: Int, 1706 1791 children: List(CoalescedView),