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.

Enhance support for Sharkey/Misskey instances. This fixes #3!


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

+251 -81
+3 -3
.envrc
··· 1 - if nix flake show &> /dev/null; then 2 - use flake 3 - fi 1 + if nix flake show &>/dev/null; then 2 + use flake 3 + fi
+10 -4
examples/lustre_chilp_app/src/lustre_chilp_app.gleam
··· 58 58 element.text(model.string), 59 59 // Let's render comments under https://pony.social/@strawmelonjuice/115911235653686237 60 60 chilp.widget( 61 - option.Some(anchors.Mastodon( 62 - instance: "pony.social", 63 - postid: "115911235653686237", 61 + // A post on a sharkey instance 62 + mastodon: option.Some(anchors.Mastodon( 63 + instance: "procial.tchncs.de", 64 + postid: "alf5j0fozrnl0002", 64 65 )), 65 - option.Some(anchors.Bluesky( 66 + // A post on a regular instance 67 + // mastodon: option.Some(anchors.Mastodon( 68 + // instance: "pony.social", 69 + // postid: "115911235653686237", 70 + // )), 71 + bluesky: option.Some(anchors.Bluesky( 66 72 did: "did:plc:tydnkicz4pafvkt3jspzldn6", 67 73 postid: "3mhwwbldyjc2o", 68 74 )),
+46 -1
src/chilp.gleam
··· 1 1 import chilp/widget 2 + import chilp/widget/anchors 3 + import gleam/option.{type Option} 4 + 5 + /// An anchor to the Bluesky post you want to 6 + /// fetch comments from. 7 + /// | Parameter | Description | 8 + /// | --------- | ----------- | 9 + /// | `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 + /// | `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 + /// 12 + /// 13 + /// You can also construct this directly if you import `chilp/widget/anchors`. 14 + pub fn bluesky(did did: String, post_id postid: String) { 15 + anchors.Bluesky(did:, postid:) 16 + } 17 + 18 + /// 19 + /// An anchor to the Fediverse post you want to 20 + /// fetch comments from. 21 + /// | Parameter | Description | 22 + /// | ---------- | ----------- | 23 + /// | `instance` | The instance name, e.g. `"mastodon.social"`, this is where your post is stored, and where chilp will attempt to fetch it from. | 24 + /// | `post_id` | A post id to bind to, you'll find this in a post url `https://instance.social/@<username>/[postid]`. | 25 + /// | | On Sharkey or Misskey this is a note id, found `https://instance.social/notes/[note-id]` | 26 + /// 27 + /// You can also construct this directly if you import `chilp/widget/anchors`. 28 + pub fn mastodon(instance instance: String, post_id postid: String) { 29 + anchors.Mastodon(instance:, postid:) 30 + } 2 31 3 32 /// Widget component! 4 33 /// Before adding this component make sure to call `widget.register()` to register it! 5 34 /// 6 - pub const widget = widget.element 35 + /// Little example: 36 + /// ```gleam 37 + /// chilp.widget( 38 + /// // We connect to Bluesky... 39 + /// bluesky: option.Some(chilp.bluesky("did:plc:my-did","myPostId")), 40 + /// // ...But not to Mastodon? Sure! Make it a None! 41 + /// mastodon: option.None 42 + /// ) 43 + /// ``` 44 + /// 45 + /// You can also construct this directly if you import `chilp/widget`. 46 + pub fn widget( 47 + bluesky bsky: Option(anchors.Bluesky), 48 + mastodon masto: Option(anchors.Mastodon), 49 + ) { 50 + widget.element(masto, bsky) 51 + }
+192 -67
src/chilp/widget.gleam
··· 3 3 import gleam/bool 4 4 import gleam/dynamic/decode 5 5 import gleam/int 6 + import gleam/json 6 7 import gleam/list 7 8 import gleam/option.{type Option, None, Some} 8 9 import gleam/order ··· 74 75 Model( 75 76 mastodon_anchor: Option(anchors.Mastodon), 76 77 cached_mastodon_descendants: List(MastodonDescendant), 77 - mastodon_op_username: String, 78 + mastodon_op_username_and_posturl: #(String, String), 78 79 bluesky_anchor: Option(anchors.Bluesky), 79 80 cached_bluesky_replies: List(BskyThreadReply), 80 81 bsky_op_handle: String, ··· 92 93 Model( 93 94 mastodon_anchor: None, 94 95 cached_mastodon_descendants: [], 95 - mastodon_op_username: "username", 96 + mastodon_op_username_and_posturl: #("", ""), 96 97 bluesky_anchor: None, 97 98 cached_bluesky_replies: [], 98 99 bsky_op_handle: "", ··· 115 116 BskyIncomingThreadView(BskyThreadView) 116 117 IncomingCoalescedView(List(CoalescedView)) 117 118 MastodonIncomingStatus(MastodonStatusContext) 118 - MastodonIncomingOpUsername(String) 119 + MastodonIncomingOpUsername(#(String, String)) 119 120 SetTab(Int) 120 121 MastodonAnswer(instance: String) 122 + MastodonErrorFetchingOpUsernameRetryForMisskey 121 123 } 122 124 123 125 fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ··· 132 134 ) 133 135 MastodonAnswer(instance:) -> { 134 136 case model.mastodon_anchor { 135 - Some(anchor) if instance != "" -> { 137 + Some(..) if instance != "" -> { 136 138 #( 137 139 model, 138 140 browse({ 139 141 "https://" 140 142 <> instance 141 143 <> "/authorize_interaction?uri=" 142 - <> { 143 - { 144 - "https://" 145 - <> anchor.instance 146 - <> "/@" 147 - <> model.mastodon_op_username 148 - <> "/" 149 - <> anchor.postid 150 - } 151 - |> uri.percent_encode 152 - } 144 + <> uri.percent_encode(model.mastodon_op_username_and_posturl.1) 153 145 }), 154 146 ) 155 147 } ··· 168 160 effect.batch([get_username_mastodon(model), refresh_mastodon(model)]), 169 161 ) 170 162 } 171 - MastodonIncomingOpUsername(mastodon_op_username) -> #( 172 - Model(..model, mastodon_op_username: mastodon_op_username), 163 + MastodonIncomingOpUsername(mastodon_op_username_and_posturl) -> #( 164 + Model(..model, mastodon_op_username_and_posturl:), 173 165 effect.none(), 174 166 ) 175 167 MastodonIncomingStatus(status) -> { ··· 203 195 Model(..model, cached_coalesced_view: new), 204 196 effect.none(), 205 197 ) 198 + MastodonErrorFetchingOpUsernameRetryForMisskey -> #( 199 + model, 200 + get_username_miskey(model), 201 + ) 206 202 } 207 203 } 208 204 ··· 220 216 html.div([], [ 221 217 html.a( 222 218 [ 223 - event.on_click(SetTab(3)), 224 - attribute.classes([ 225 - #("float-right link link-secondary-content/40 text-xs", True), 226 - #("hidden", model.open_tab == 3), 227 - ]), 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 -> [attribute.class("hidden")] 234 + } 228 235 ], 229 236 [ 230 - html.text("About"), 237 + html.text(case model.open_tab == 3 { 238 + False -> "About" 239 + True -> "×" 240 + }), 231 241 ], 232 242 ), 233 243 ]) ··· 239 249 Some(bsky), Some(masto) -> { 240 250 #( 241 251 [ 242 - html.a( 243 - [ 244 - attribute.href( 245 - "https://" 246 - <> masto.instance 247 - <> "/@" 248 - <> model.mastodon_op_username 249 - <> "/" 250 - <> masto.postid, 251 - ), 252 - attribute.class("link text-[#595aff]"), 253 - ], 254 - [html.text("this post")], 255 - ), 256 - html.text(" on Mastodon, and to "), 252 + html.text("Linked to "), 253 + case 254 + model.mastodon_op_username_and_posturl.1 255 + |> string.contains("/notes/") 256 + { 257 + True -> 258 + html.a( 259 + [ 260 + attribute.href(model.mastodon_op_username_and_posturl.1), 261 + attribute.class("link text-[#3da0c8]"), 262 + ], 263 + [html.text("this note on Sharkey")], 264 + ) 265 + False -> 266 + html.a( 267 + [ 268 + attribute.href(model.mastodon_op_username_and_posturl.1), 269 + attribute.class("link text-[#595aff]"), 270 + ], 271 + [html.text("this post on Mastodon")], 272 + ) 273 + }, 274 + html.text(", and to "), 257 275 html.a( 258 276 [ 259 277 attribute.href( ··· 264 282 ), 265 283 attribute.class("link text-[#006aff]"), 266 284 ], 267 - [html.text("this post")], 285 + [html.text("this post on Bluesky")], 268 286 ), 269 - html.text(" on Bluesky."), 287 + html.text("."), 270 288 ] 271 289 |> element.fragment, 272 290 [ ··· 314 332 3 -> view_about_chilp() 315 333 _ -> view_mastodon_respond_form(masto) 316 334 }, 317 - html.span([attribute.class("absolute right-2 top-1")], [about_linkie]), 318 335 ] 319 336 |> element.fragment(), 320 337 ) ··· 325 342 3 -> view_about_chilp() 326 343 _ -> 327 344 [ 328 - html.a( 329 - [ 330 - attribute.href( 331 - "https://" 332 - <> masto.instance 333 - <> "/" 334 - <> model.mastodon_op_username 335 - <> "/" 336 - <> masto.postid, 337 - ), 338 - attribute.class("link text-[#595aff]"), 339 - ], 340 - [html.text("this post")], 341 - ), 342 - html.text(" on Mastodon."), 343 - about_linkie, 345 + html.text("Linked to "), 346 + case 347 + model.mastodon_op_username_and_posturl.1 348 + |> string.contains("/notes/") 349 + { 350 + True -> 351 + html.a( 352 + [ 353 + attribute.href(model.mastodon_op_username_and_posturl.1), 354 + attribute.class("link text-[#3da0c8]"), 355 + ], 356 + [html.text("this note on Sharkey")], 357 + ) 358 + False -> 359 + html.a( 360 + [ 361 + attribute.href(model.mastodon_op_username_and_posturl.1), 362 + attribute.class("link text-[#595aff]"), 363 + ], 364 + [html.text("this post on Mastodon")], 365 + ) 366 + }, 367 + 368 + html.text("."), 344 369 ] 345 370 |> element.fragment 346 371 }, ··· 353 378 3 -> view_about_chilp() 354 379 _ -> 355 380 [ 381 + html.text("Linked to "), 356 382 html.a( 357 383 [ 358 384 attribute.href( ··· 363 389 ), 364 390 attribute.class("link link-[#006aff]"), 365 391 ], 366 - [html.text("this post")], 392 + [html.text("this post on Bluesky")], 367 393 ), 368 - html.text(" on Bluesky."), 369 - about_linkie, 394 + html.text("."), 370 395 ] 371 396 |> element.fragment 372 397 }, ··· 392 417 html.text("Comments"), 393 418 ]), 394 419 html.p([attribute.class("text-sm text-base-content/70")], [ 395 - html.text("Linked to "), 420 + about_linkie, 396 421 linkedto, 397 422 ]), 398 423 html.div( ··· 414 439 option.map(model.mastodon_anchor, fn(masto) { 415 440 "https://" 416 441 <> masto.instance 417 - <> "/" 418 - <> model.mastodon_op_username 442 + <> "/@" 443 + <> model.mastodon_op_username_and_posturl.0 419 444 }), 420 445 )), 421 446 ), ··· 429 454 bsky_op_profile: Option(String), 430 455 mastodon_op_profile: Option(String), 431 456 ) -> Element(Msg) { 457 + let is_by_op = { 458 + case comment.source, bsky_op_profile, mastodon_op_profile { 459 + "Mastodon", _, Some(op) -> op == comment.author_profile_link 460 + 461 + "Bluesky", Some(op), _ -> { 462 + // Had some mismatches here, but decided it is of no importance what hostname we use. 463 + { 464 + op 465 + |> string.replace("https://bsky.app", "") 466 + |> string.replace("https://bluesky.app", "") 467 + } 468 + == { 469 + comment.author_profile_link 470 + |> string.replace("https://bsky.app", "") 471 + |> string.replace("https://bluesky.app", "") 472 + } 473 + } 474 + _, _, _ -> False 475 + } 476 + } 432 477 let commands = 433 478 list.filter_map(comment.children, fn(child) { 434 479 case ··· 495 540 ), 496 541 ], 497 542 [ 498 - element.text("Mastodon"), 543 + element.text("Fediverse"), 499 544 ], 500 545 ) 501 546 "Bluesky" -> ··· 510 555 ], 511 556 ) 512 557 _ -> element.none() 558 + }, 559 + case is_by_op { 560 + True -> 561 + html.div( 562 + [ 563 + attribute.class("badge badge-sm badge-accent ms-2"), 564 + ], 565 + [ 566 + element.text("Author"), 567 + ], 568 + ) 569 + False -> element.none() 513 570 }, 514 571 ]), 515 572 html.p([attribute.class("text-xs")], [ ··· 836 893 }, 837 894 fn(response) { 838 895 case response { 839 - Ok(username) -> MastodonIncomingOpUsername(username) 896 + Ok(username) -> 897 + MastodonIncomingOpUsername(#( 898 + username, 899 + "https://" 900 + <> anchor.instance 901 + <> "/@" 902 + <> username 903 + <> "/" 904 + <> anchor.postid, 905 + )) 840 906 Error(rsvperror) -> 841 907 case rsvperror { 842 908 rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> ··· 847 913 AllStoppingError( 848 914 "The API call to Mastodon failed. Did you enter the instance and post id correctly?", 849 915 ) 850 - rsvp.HttpError(_) | rsvp.NetworkError -> 851 - AllStoppingError("Could not fetch comments from Mastodon.") 916 + rsvp.HttpError(_) -> { 917 + // This may mean something else! This might not be all-stopping just yet. 918 + // If this is a Misskey or Sharkey instance, we can still save things! 919 + MastodonErrorFetchingOpUsernameRetryForMisskey 920 + } 921 + rsvp.NetworkError -> 922 + AllStoppingError( 923 + "Could not fetch comments from Mastodon. (NetworkError)", 924 + ) 852 925 } 853 926 } 854 927 }, 855 928 ) 856 929 rsvp.get(url, handler) 930 + } 931 + None -> effect.none() 932 + } 933 + } 934 + 935 + fn get_username_miskey(model: Model) -> Effect(Msg) { 936 + case model.mastodon_anchor { 937 + Some(anchor) -> { 938 + let url = "https://" <> anchor.instance <> "/api/notes/show" 939 + 940 + let handler = 941 + rsvp.expect_json( 942 + { 943 + use user <- decode.subfield(["user", "username"], decode.string) 944 + decode.success(user) 945 + }, 946 + fn(response) { 947 + case response { 948 + Ok(username) -> 949 + MastodonIncomingOpUsername(#( 950 + username, 951 + "https://" <> anchor.instance <> "/notes/" <> anchor.postid, 952 + )) 953 + Error(rsvperror) -> 954 + case rsvperror { 955 + rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 956 + AllStoppingError( 957 + "The response body we got back from Misskey/Sharkey was misformed.", 958 + ) 959 + rsvp.BadUrl(_) -> 960 + AllStoppingError( 961 + "The API call to Misskey/Sharkey failed. Did you enter the instance and post id correctly?", 962 + ) 963 + rsvp.HttpError(_) -> { 964 + // ... if we end up here again, then, yes, we have in fact failed... Sorry! 965 + AllStoppingError( 966 + "Could not fetch comments from Mastodon/Misskey/Sharkey. (HttpError)", 967 + ) 968 + } 969 + rsvp.NetworkError -> 970 + AllStoppingError( 971 + "Could not fetch comments from Misskey/Sharkey. (NetworkError)", 972 + ) 973 + } 974 + } 975 + }, 976 + ) 977 + rsvp.post( 978 + url, 979 + json.object([#("noteId", json.string(anchor.postid))]), 980 + handler, 981 + ) 857 982 } 858 983 None -> effect.none() 859 984 }
-6
src/chilp/widget/anchors.gleam
··· 17 17 postid: String, 18 18 ) 19 19 } 20 - 21 - @internal 22 - pub type ConnectionType { 23 - Bsky(Bluesky) 24 - Fedi(Mastodon) 25 - }