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.

I- lost myself a bit. Ready to prep for v2 ?


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

+763 -376
+2 -2
NOTES.md
··· 1 1 # Notes for 1.1.0: 2 2 3 3 To do for this release: 4 - - [ ] [feat] Adding multiple back-ends 5 - - [ ] Bluesky, including a "[Comment via Bluesky]" button, and fetching comments from Bluesky posts in Bluesky-only mode or in a mixed mode with Mastodon. 4 + - [x] [feat] Adding multiple back-ends 5 + - [x] Bluesky, including a "[Comment via Bluesky]" button, and fetching comments from Bluesky posts in Bluesky-only mode or in a mixed mode with Mastodon. 6 6 - [x] ~~GitHub issues, including a "[Comment via GitHub]" button.~~ For a later release, as this will require a lot of work to implement the GraphQL queries and mutations. 7 7 - [ ] Other platforms? Maybe? Depends on how much time I have, and how much demand there is for other platforms. 8 8 - [x] Remove dependency on DOMPurifier
+2 -2
examples/lustre_chilp_app/src/lustre_chilp_app.gleam
··· 63 63 postid: "115911235653686237", 64 64 )), 65 65 option.Some(anchors.Bluesky( 66 - did: "did:plc:jgtfsmv25thfs4zmydtbccnn", 67 - postid: "3mgrbiiadws2k", 66 + did: "did:plc:tydnkicz4pafvkt3jspzldn6", 67 + postid: "3mhwwbldyjc2o", 68 68 )), 69 69 ), 70 70 ])
-262
src/chilp/api_typing/new.gleam
··· 1 - import chilp/internal 2 - import gleam/dynamic/decode 3 - import gleam/list 4 - import gleam/result 5 - import gleam/time/timestamp 6 - import lustre/element 7 - 8 - /// 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`. 9 - /// This one is very pruned! Why? Because these json responses are huge and we only need a small subset of the data in them! 10 - pub type BskyThreadView { 11 - BskyThreadView(at_uri: String, replies: List(BskyThreadReply)) 12 - } 13 - 14 - pub type Message { 15 - Message 16 - } 17 - 18 - pub type BskyThreadReply { 19 - BskyThreadReply( 20 - at_uri: String, 21 - like_count: Int, 22 - created_at: timestamp.Timestamp, 23 - body_text: String, 24 - author_did: String, 25 - author_handle: String, 26 - author_displayname: String, 27 - author_avatar: String, 28 - children: List(BskyThreadReply), 29 - ) 30 - } 31 - 32 - pub fn bsky_thread_view_decoder() -> decode.Decoder(BskyThreadView) { 33 - use at_uri <- decode.subfield(["thread", "post", "uri"], decode.string) 34 - use replies <- decode.subfield( 35 - ["thread", "replies"], 36 - decode.list(bsky_thread_reply_decoder()), 37 - ) 38 - decode.success(BskyThreadView(at_uri:, replies:)) 39 - } 40 - 41 - fn bsky_thread_reply_decoder() -> decode.Decoder(BskyThreadReply) { 42 - use created_at <- decode.subfield( 43 - ["post", "record", "created_at"], 44 - decode.map(decode.string, fn(stringstamp) { 45 - result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) 46 - }), 47 - ) 48 - use at_uri <- decode.subfield(["post", "uri"], decode.string) 49 - use body_text <- decode.subfield(["post", "record", "text"], decode.string) 50 - 51 - use author_did <- decode.subfield(["post", "author", "did"], decode.string) 52 - use author_handle <- decode.subfield( 53 - ["post", "author", "handle"], 54 - decode.string, 55 - ) 56 - use author_displayname <- decode.subfield( 57 - ["post", "author", "displayName"], 58 - decode.string, 59 - ) 60 - use author_avatar <- decode.subfield( 61 - ["post", "author", "avatar"], 62 - decode.string, 63 - ) 64 - use like_count <- decode.subfield(["post", "likeCount"], decode.int) 65 - use children <- decode.field( 66 - "replies", 67 - decode.list(bsky_thread_reply_decoder()), 68 - ) 69 - decode.success(BskyThreadReply( 70 - at_uri:, 71 - created_at:, 72 - body_text:, 73 - like_count:, 74 - author_did:, 75 - author_handle:, 76 - author_displayname:, 77 - author_avatar:, 78 - children:, 79 - )) 80 - } 81 - 82 - /// Subset of a Mastodon Status-context, like what you get from `https://pony.social/api/v1/statuses/115911235653686237/context`. 83 - pub type MastodonStatusContext { 84 - MastodonStatusContext(descendants: List(MastodonDescendant)) 85 - } 86 - 87 - pub fn mastodon_status_context_decoder( 88 - original_id: String, 89 - ) -> decode.Decoder(MastodonStatusContext) { 90 - use flat_descendants <- decode.field( 91 - "descendants", 92 - decode.list(mastodon_descendant_decoder()), 93 - ) 94 - let descendants: List(MastodonDescendant) = 95 - list.filter_map(flat_descendants, fn(desc) { 96 - case desc.1 == original_id { 97 - True -> { 98 - // A parent! 99 - 100 - mastodon_decendant_inflater(desc.0, flat_descendants) 101 - |> Ok 102 - } 103 - False -> { 104 - Error(Nil) 105 - } 106 - } 107 - }) 108 - decode.success(MastodonStatusContext(descendants:)) 109 - } 110 - 111 - fn mastodon_decendant_inflater( 112 - parent: MastodonDescendant, 113 - all_children: List(#(MastodonDescendant, String)), 114 - ) { 115 - MastodonDescendant( 116 - ..parent, 117 - children: list.filter_map(all_children, fn(c) { 118 - case c.1 == parent.id { 119 - True -> Ok(c.0) 120 - False -> Error(Nil) 121 - } 122 - }), 123 - ) 124 - } 125 - 126 - pub type MastodonDescendant { 127 - MastodonDescendant( 128 - id: String, 129 - uri: String, 130 - content: element.Element(Message), 131 - created_at: timestamp.Timestamp, 132 - favourite_count: Int, 133 - // 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. 134 - // We gotta iterate over this later to get things nested. 135 - children: List(MastodonDescendant), 136 - author_url: String, 137 - author_avatar_url: String, 138 - author_username: String, 139 - author_displayname: String, 140 - ) 141 - } 142 - 143 - /// Decodes #(MastodonDescendant, ReplyingTo), to enable the parent decoder to make this into a recursive three. 144 - fn mastodon_descendant_decoder() -> decode.Decoder( 145 - #(MastodonDescendant, String), 146 - ) { 147 - use replying_to <- decode.field("in_reply_to_id", decode.string) 148 - use id <- decode.field("id", decode.string) 149 - use uri <- decode.field("uri", decode.string) 150 - use created_at <- decode.field( 151 - "created_at", 152 - decode.map(decode.string, fn(stringstamp) { 153 - result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) 154 - }), 155 - ) 156 - use favourite_count <- decode.field("favourites_count", decode.int) 157 - use unescaped_html_content <- decode.field("content", decode.string) 158 - let content = internal.sanitise_ls(unescaped_html_content) 159 - 160 - let children = [] 161 - 162 - use author_url <- decode.subfield(["account", "url"], decode.string) 163 - use author_avatar_url <- decode.subfield(["account", "avatar"], decode.string) 164 - use author_displayname <- decode.subfield( 165 - ["account", "display_name"], 166 - decode.string, 167 - ) 168 - use author_username <- decode.subfield(["account", "acct"], decode.string) 169 - 170 - decode.success(#( 171 - MastodonDescendant( 172 - id:, 173 - uri:, 174 - content:, 175 - created_at:, 176 - favourite_count:, 177 - children:, 178 - author_url:, 179 - author_avatar_url:, 180 - author_username:, 181 - author_displayname:, 182 - ), 183 - replying_to, 184 - )) 185 - } 186 - 187 - pub fn coalesce_views( 188 - bsky: List(BskyThreadReply), 189 - mastodon: List(MastodonDescendant), 190 - ) { 191 - let mixed: List(Result(BskyThreadReply, MastodonDescendant)) = { 192 - list.append( 193 - list.map(bsky, fn(m) { Ok(m) }), 194 - list.map(mastodon, fn(m) { Error(m) }), 195 - ) 196 - |> list.shuffle 197 - } 198 - list.map(mixed, fn(item) { 199 - let coalesced: CoalescedView = case item { 200 - Ok(BskyThreadReply( 201 - created_at:, 202 - at_uri:, 203 - like_count:, 204 - body_text:, 205 - author_did:, 206 - author_displayname:, 207 - author_handle:, 208 - author_avatar:, 209 - children:, 210 - )) -> 211 - CoalescedView( 212 - created_at:, 213 - author_profile_link: "https://witchsky.app/profile/" <> author_did, 214 - source: "Bluesky", 215 - agreeability: like_count, 216 - content: element.text(body_text), 217 - author_username: author_handle, 218 - author_avatar_url: author_avatar, 219 - displayname: author_displayname, 220 - children: { coalesce_views(children, []) }, 221 - ) 222 - Error(MastodonDescendant( 223 - created_at:, 224 - id:, 225 - uri:, 226 - content:, 227 - favourite_count:, 228 - children:, 229 - author_url:, 230 - author_avatar_url:, 231 - author_username:, 232 - author_displayname:, 233 - )) -> 234 - CoalescedView( 235 - created_at:, 236 - author_profile_link: author_url, 237 - source: "Mastodon", 238 - displayname: author_displayname, 239 - agreeability: favourite_count, 240 - author_avatar_url:, 241 - author_username:, 242 - content: content, 243 - children: { coalesce_views([], children) }, 244 - ) 245 - } 246 - }) 247 - } 248 - 249 - pub type CoalescedView { 250 - 251 - CoalescedView( 252 - created_at: timestamp.Timestamp, 253 - author_profile_link: String, 254 - author_avatar_url: String, 255 - author_username: String, 256 - source: String, 257 - displayname: String, 258 - content: element.Element(Message), 259 - agreeability: Int, 260 - children: List(CoalescedView), 261 - ) 262 - }
+20 -12
src/chilp/internal.gleam
··· 94 94 html_parser.Attribute(_, _) -> attribute.none() 95 95 } 96 96 }) 97 - list.map(children, sanitise_reconstruct_ls) 98 - |> case name { 99 - "b" -> html.b(attribs, _) 100 - "i" -> html.i(attribs, _) 101 - "em" -> html.em(attribs, _) 102 - "strong" -> html.strong(attribs, _) 103 - "a" -> html.a(attribs, _) 104 - "p" -> html.p(attribs, _) 105 - "br" -> fn(_) { html.br(attribs) } 106 - "span" -> html.span(attribs, _) 107 - _ -> element.fragment 108 - } 97 + [ 98 + list.map(children, sanitise_reconstruct_ls) 99 + |> case name { 100 + "b" -> html.b(attribs, _) 101 + "i" -> html.i(attribs, _) 102 + "em" -> html.em(attribs, _) 103 + "strong" -> html.strong(attribs, _) 104 + "a" -> html.a( 105 + [attribute.class("link link-secondary"), ..attribs], 106 + _, 107 + ) 108 + 109 + "p" -> html.p(attribs, _) 110 + "br" -> fn(_) { html.br(attribs) } 111 + "span" -> html.span(attribs, _) 112 + _ -> element.fragment 113 + }, 114 + element.text(" "), 115 + ] 116 + |> element.fragment() 109 117 } 110 118 // AFAIK we don't have this due to parsing as tree. 111 119 html_parser.EndElement(_) ->
+739 -98
src/chilp/widget.gleam
··· 1 1 // IMPORTS --------------------------------------------------------------------- 2 2 3 - import chilp/api_typing/new.{type BskyThreadReply, type MastodonDescendant} 3 + import chilp/internal 4 4 import chilp/widget/anchors 5 5 import gleam/bool 6 6 import gleam/dynamic/decode 7 + import gleam/int 8 + import gleam/list 7 9 import gleam/option.{type Option, None, Some} 10 + import gleam/order 11 + import gleam/pair 8 12 import gleam/result 9 13 import gleam/string 14 + import gleam/time/calendar 15 + import gleam/time/duration 16 + import gleam/time/timestamp 17 + import gleam/uri 10 18 import lustre 11 - import lustre/attribute 19 + import lustre/attribute.{attribute} 12 20 import lustre/component 13 21 import lustre/effect.{type Effect} 14 22 import lustre/element.{type Element} 15 23 import lustre/element/html 24 + import lustre/element/svg 25 + import lustre/event 16 26 import rsvp 17 27 18 28 // MAIN ------------------------------------------------------------------------ ··· 70 80 cached_bluesky_replies: List(BskyThreadReply), 71 81 bsky_op_handle: String, 72 82 all_stopping_error: Option(String), 73 - cached_coalesced_view: List(new.CoalescedView), 83 + cached_coalesced_view: List(CoalescedView), 74 84 /// For those not using DaisyUI, using it's hacky way of creating tabs is... hacky. 75 85 /// So we do this the old school way. Tabs in DOM. 76 86 /// This value will be ignored if the model only has one anchor. ··· 89 99 bsky_op_handle: "", 90 100 all_stopping_error: None, 91 101 cached_coalesced_view: [], 92 - open_tab: 1, 102 + open_tab: [1, 2] |> list.shuffle() |> list.first() |> result.unwrap(1), 93 103 ), 94 104 effect.none(), 95 105 ) ··· 103 113 BskyUnAnchored 104 114 BskyAnchored(did: String, post: String) 105 115 AllStoppingError(String) 106 - BskyIncomingThreadView(new.BskyThreadView) 107 - IncomingCoalescedView(List(new.CoalescedView)) 108 - MastodonIncomingStatus(new.MastodonStatusContext) 116 + BskyIncomingThreadView(BskyThreadView) 117 + IncomingCoalescedView(List(CoalescedView)) 118 + MastodonIncomingStatus(MastodonStatusContext) 109 119 MastodonIncomingOpUsername(String) 120 + SetTab(Int) 121 + MastodonAnswer(instance: String) 110 122 } 111 123 112 124 fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ··· 119 131 Model(..model, mastodon_anchor: None), 120 132 effect.none(), 121 133 ) 134 + MastodonAnswer(instance:) -> { 135 + case model.mastodon_anchor { 136 + Some(anchor) if instance != "" -> { 137 + #( 138 + model, 139 + browse({ 140 + "https://" 141 + <> instance 142 + <> "/authorize_interaction?uri=" 143 + <> { 144 + { 145 + "https://" 146 + <> anchor.instance 147 + <> "/@" 148 + <> model.mastodon_op_username 149 + <> "/" 150 + <> anchor.postid 151 + } 152 + |> uri.percent_encode 153 + } 154 + }), 155 + ) 156 + } 157 + _ -> #(model, effect.none()) 158 + } 159 + } 160 + 122 161 MastodonAnchored(instance:, post:) -> { 123 162 let model = 124 163 Model( ··· 131 170 ) 132 171 } 133 172 MastodonIncomingOpUsername(mastodon_op_username) -> #( 134 - Model(..model, mastodon_op_username: echo mastodon_op_username), 173 + Model(..model, mastodon_op_username: mastodon_op_username), 135 174 effect.none(), 136 175 ) 137 176 MastodonIncomingStatus(status) -> { ··· 159 198 ), 160 199 ) 161 200 } 201 + SetTab(open_tab) -> #(Model(..model, open_tab:), effect.none()) 162 202 // And then... everything comes together! 163 203 IncomingCoalescedView(new) -> #( 164 204 Model(..model, cached_coalesced_view: new), 165 205 effect.none(), 166 206 ) 167 207 } 168 - |> echo 169 208 } 170 209 171 210 // VIEW ------------------------------------------------------------------------ ··· 178 217 } 179 218 180 219 fn view_normal(model: Model) -> Element(Msg) { 181 - html.div([attribute.class("chilp-widget")], [ 182 - html.div([attribute.class("widget ")], [ 183 - html.h1([attribute.class("widget-header h1 ")], [html.text("Comments")]), 184 - html.p([attribute.class("subheader ")], [ 185 - html.text("Linked to "), 186 - case model.bluesky_anchor, model.mastodon_anchor { 187 - Some(bsky), Some(masto) -> { 220 + let #(linkedto, respond_here) = case 221 + model.bluesky_anchor, 222 + model.mastodon_anchor 223 + { 224 + Some(bsky), Some(masto) -> { 225 + #( 226 + [ 227 + html.a( 228 + [ 229 + attribute.href( 230 + "https://" 231 + <> masto.instance 232 + <> "/@" 233 + <> model.mastodon_op_username 234 + <> "/" 235 + <> masto.postid, 236 + ), 237 + attribute.class("link text-[#595aff]"), 238 + ], 239 + [html.text("this post")], 240 + ), 241 + html.text(" on Mastodon, and to "), 242 + html.a( 243 + [ 244 + attribute.href( 245 + "https://bsky.app/profile/" 246 + <> bsky.did 247 + <> "/post/" 248 + <> bsky.postid, 249 + ), 250 + attribute.class("link text-[#006aff]"), 251 + ], 252 + [html.text("this post")], 253 + ), 254 + html.text(" on Bluesky."), 255 + ] 256 + |> element.fragment, 257 + [ 258 + html.div( 259 + [ 260 + attribute.class("tabs tabs-box border-b-2 border-base-300 h-full"), 261 + attribute.role("tablist"), 262 + ], 188 263 [ 189 264 html.a( 190 265 [ 191 - attribute.class("text-purple"), 192 - // https://pony.social/@strawmelonjuice/115911235653686237 193 - attribute.href( 194 - "https://" 195 - <> masto.instance 196 - <> "/@" 197 - <> model.mastodon_op_username 198 - <> "/" 199 - <> masto.postid, 200 - ), 201 - attribute.class("post-link "), 266 + attribute.role("tab"), 267 + event.on_click(SetTab(1)), 268 + attribute.classes([ 269 + #("tab", True), 270 + #("tab-active", model.open_tab == 1), 271 + ]), 272 + ], 273 + [ 274 + html.text("Mastodon"), 202 275 ], 203 - [html.text("this post")], 204 276 ), 205 - html.text(" on Mastodon, and to "), 206 277 html.a( 207 278 [ 208 - attribute.href( 209 - "https://bsky.app/profile/" 210 - <> bsky.did 211 - <> "/post/" 212 - <> bsky.postid, 213 - ), 214 - attribute.class("post-link "), 279 + attribute.role("tab"), 280 + event.on_click(SetTab(2)), 281 + attribute.classes([ 282 + #("tab", True), 283 + #("tab-active", model.open_tab == 2), 284 + ]), 215 285 ], 216 - [html.text("this post")], 286 + [html.text("Bluesky")], 217 287 ), 218 - html.text(" on Bluesky."), 219 - ] 220 - } 221 - None, Some(masto) -> { 222 - [ 223 288 html.a( 224 289 [ 225 - attribute.href( 226 - "https://" 227 - <> masto.instance 228 - <> "/" 229 - <> model.mastodon_op_username 230 - <> "/" 231 - <> masto.postid, 232 - ), 233 - attribute.class("post-link "), 290 + attribute.role("tab"), 291 + event.on_click(SetTab(3)), 292 + attribute.classes([ 293 + #("tab", True), 294 + #("tab-active", model.open_tab == 3), 295 + ]), 296 + ], 297 + [ 298 + html.text("About"), 234 299 ], 235 - [html.text("this post")], 236 300 ), 237 - html.text(" on Mastodon."), 238 - ] 239 - } 240 - Some(bsky), None -> { 301 + ], 302 + ), 303 + html.br([]), 304 + case model.open_tab { 305 + 2 -> 306 + view_respond_on_bsky( 307 + "https://bsky.app/profile/" 308 + <> bsky.did 309 + <> "/post/" 310 + <> bsky.postid, 311 + ) 312 + 3 -> view_about_chilp() 313 + _ -> view_mastodon_respond_form(masto) 314 + }, 315 + ] 316 + |> element.fragment(), 317 + ) 318 + } 319 + None, Some(masto) -> { 320 + #( 321 + [ 322 + html.a( 323 + [ 324 + attribute.href( 325 + "https://" 326 + <> masto.instance 327 + <> "/" 328 + <> model.mastodon_op_username 329 + <> "/" 330 + <> masto.postid, 331 + ), 332 + attribute.class("link text-[#595aff]"), 333 + ], 334 + [html.text("this post")], 335 + ), 336 + html.text(" on Mastodon."), 337 + ] 338 + |> element.fragment, 339 + view_mastodon_respond_form(masto), 340 + ) 341 + } 342 + Some(bsky), None -> { 343 + #( 344 + [ 345 + html.a( 241 346 [ 242 - html.a( 347 + attribute.href( 348 + "https://bsky.app/profile/" 349 + <> bsky.did 350 + <> "/post/" 351 + <> bsky.postid, 352 + ), 353 + attribute.class("link link-[#006aff]"), 354 + ], 355 + [html.text("this post")], 356 + ), 357 + html.text(" on Bluesky."), 358 + ] 359 + |> element.fragment, 360 + view_respond_on_bsky( 361 + "https://bsky.app/profile/" <> bsky.did <> "/post/" <> bsky.postid, 362 + ), 363 + ) 364 + } 365 + None, None -> #( 366 + error_view("No comment backends are configured for this widget."), 367 + element.none(), 368 + ) 369 + } 370 + html.div([attribute.class("comment-widget px-4")], [ 371 + html.div( 372 + [ 373 + attribute.class( 374 + "widget card bg-base-100 shadow-xl border border-base-200 p-10", 375 + ), 376 + ], 377 + [ 378 + html.h1([attribute.class("text-2xl font-extrabold text-base-content")], [ 379 + html.text("Comments"), 380 + ]), 381 + html.p([attribute.class("text-sm text-base-content/70")], [ 382 + html.text("Linked to "), 383 + linkedto, 384 + ]), 385 + html.div( 386 + [ 387 + attribute.class("card card-dash bg-base-200/70 border-base-300"), 388 + ], 389 + [ 390 + respond_here, 391 + ], 392 + ), 393 + html.section( 394 + [attribute.class("pt-5 space-y-10")], 395 + list.sort(model.cached_coalesced_view, sort_comments) 396 + |> list.map(view_rendered_comment), 397 + ), 398 + ], 399 + ), 400 + ]) 401 + } 402 + 403 + fn view_rendered_comment(comment: CoalescedView) -> Element(Msg) { 404 + html.article([attribute.class("comment mt-2")], [ 405 + html.header([attribute.class("flex")], [ 406 + html.img([ 407 + attribute.src(comment.author_avatar_url), 408 + attribute.class("avatar w-[45px] h-[45px] mask mask-squircle flex-none"), 409 + attribute.alt("@"), 410 + ]), 411 + html.div([attribute.class("meta pl-4 max-h-[45px]")], [ 412 + html.span([attribute.class("display-name")], [ 413 + html.text(comment.displayname), 414 + case comment.source { 415 + "Mastodon" -> 416 + html.div( 243 417 [ 244 - attribute.href( 245 - "https://bsky.app/profile/" 246 - <> bsky.did 247 - <> "/post/" 248 - <> bsky.postid, 418 + attribute.class( 419 + "badge badge-sm badge-info ms-2 bg-[#595aff] text-white", 249 420 ), 250 - attribute.class("post-link "), 421 + ], 422 + [ 423 + element.text("Mastodon"), 251 424 ], 252 - [html.text("this post")], 425 + ) 426 + "Bluesky" -> 427 + html.div( 428 + [ 429 + attribute.class( 430 + "badge badge-sm badge-info ms-2 bg-[#006aff] text-white", 431 + ), 432 + ], 433 + [ 434 + element.text("Bluesky"), 435 + ], 436 + ) 437 + _ -> element.none() 438 + }, 439 + ]), 440 + html.p([attribute.class("text-xs")], [ 441 + html.a( 442 + [ 443 + attribute.href(comment.author_profile_link), 444 + attribute.class("link link-secondary link-sm"), 445 + ], 446 + [element.text(comment.author_username)], 447 + ), 448 + element.text(" • "), 449 + html.time( 450 + [ 451 + attribute( 452 + "datetime", 453 + comment.created_at |> timestamp.to_rfc3339(calendar.utc_offset), 253 454 ), 254 - html.text(" on Bluesky."), 255 - ] 256 - } 257 - None, None -> [ 258 - error_view("No comment backends are configured for this widget."), 259 - ] 260 - } 261 - |> element.fragment, 455 + ], 456 + [ 457 + element.text({ 458 + let b = 459 + case 460 + timestamp.difference( 461 + comment.created_at, 462 + timestamp.system_time(), 463 + ) 464 + |> duration.approximate 465 + |> pair.map_second(fn(d) { 466 + case d { 467 + duration.Nanosecond -> "nanosecond" 468 + duration.Microsecond -> "microsecond" 469 + duration.Millisecond -> "millisecond" 470 + duration.Second -> "second" 471 + duration.Minute -> "minute" 472 + duration.Hour -> "hour" 473 + duration.Day -> "day" 474 + duration.Week -> "week" 475 + duration.Month -> "month" 476 + duration.Year -> "year" 477 + } 478 + }) 479 + { 480 + #(1, x) -> #(1, x) 481 + #(x, d) -> #(x, d <> "s") 482 + } 483 + |> pair.map_first(int.to_string) 484 + 485 + b.0 <> " " <> b.1 <> " ago." 486 + }), 487 + ], 488 + ), 489 + ]), 490 + ]), 491 + ]), 492 + html.section([attribute.class("content mt-5")], [ 493 + html.span([], [comment.content]), 494 + ]), 495 + html.footer([], [ 496 + html.div([attribute.class("my-5")], [ 497 + html.a( 498 + [ 499 + attribute.class("btn btn-sm absolute right-8"), 500 + attribute.href(comment.content_url), 501 + attribute.target("_blank"), 502 + ], 503 + [html.text("View comment on " <> comment.source)], 504 + ), 505 + ]), 506 + html.br([attribute.class("border-b-2 border-dotted")]), 507 + case comment.children { 508 + [] -> element.none() 509 + _ -> 510 + html.section( 511 + [ 512 + attribute.class( 513 + "pl-5 border-s-4 border-default bg-neutral-secondary-soft", 514 + ), 515 + ], 516 + list.sort(comment.children, sort_comments) 517 + |> list.map(view_rendered_comment), 518 + ) 519 + }, 520 + ]), 521 + ]) 522 + } 523 + 524 + fn view_about_chilp() -> Element(Msg) { 525 + html.div([attribute.class("my-5 px-5 pb-5 ")], [ 526 + html.p([], [ 527 + element.text("This widget is powered by Chilp! 💬 By MLC Bloeiman"), 528 + ]), 529 + html.p([], [ 530 + element.text("Want to read "), 531 + html.a( 532 + [ 533 + attribute.href("https://strawmelonjuice.com/post/chilpv2"), 534 + attribute.class("link link-primary"), 535 + ], 536 + [ 537 + element.text("more about Chilp"), 538 + ], 539 + ), 540 + element.text(" on "), 541 + html.img([ 542 + attribute.src("https://strawmelonjuice.com/strawmelonjuice.svg"), 543 + attribute.width(34), 544 + attribute.class("inline bg-white/65 rounded-lg"), 545 + ]), 546 + element.text(" strawmelonjuice.com?"), 547 + ]), 548 + ]) 549 + } 550 + 551 + fn view_respond_on_bsky(bskylink: String) -> Element(Msg) { 552 + let icon_link = fn(label: String, new_base: String, color_class: String) { 553 + html.a( 554 + [ 555 + attribute.href(string.replace(bskylink, "bsky.app", new_base)), 556 + attribute.target("_blank"), 557 + attribute.class( 558 + "btn btn-circle btn-sm btn-ghost tooltip " <> color_class, 559 + ), 560 + attribute.attribute("data-tip", label), 561 + ], 562 + [ 563 + element.text(string.slice(label, 0, 1)), 564 + ], 565 + ) 566 + } 567 + 568 + html.div([attribute.class("my-5 px-5 pb-5 ")], [ 569 + element.text("Respond to this post on Bluesky to have it show up here!"), 570 + html.div([attribute.class("flex gap-2 items-center")], [ 571 + html.span([attribute.class("text-xs mr-2 opacity-50")], [ 572 + element.text("Open in:"), 262 573 ]), 263 - html.form([attribute.class("go-reply-form ")], [ 264 - html.div([attribute.class("input-group")], [ 574 + icon_link("Bluesky", "bsky.app", "text-info"), 575 + icon_link("Blacksky", "blacksky.community", "text-neutral"), 576 + icon_link("Witchsky", "witchsky.app", "text-secondary"), 577 + ]), 578 + ]) 579 + } 580 + 581 + const instancelist = [ 582 + "mastodon.social", 583 + "pony.social", 584 + "todon.nl", 585 + "mstdn.social", 586 + "infosec.exchange", 587 + "woem.space", 588 + "shitpost.trade", 589 + "procial.tchncs.de", 590 + ] 591 + 592 + fn view_mastodon_respond_form(mastodon_anchor: anchors.Mastodon) -> Element(Msg) { 593 + html.div([attribute.class("my-5 px-5 pb-5 ")], [ 594 + html.form( 595 + [ 596 + attribute.class("mb-8 w-full"), 597 + event.on_submit(fn(n) { 598 + let value = 599 + list.key_find(n, "userinstance") 600 + |> result.unwrap("") 601 + MastodonAnswer(value) 602 + }), 603 + ], 604 + [ 605 + html.div([attribute.class("flex flex-col gap-2")], [ 606 + // Label Section 265 607 html.label( 266 608 [ 267 609 attribute.for("userinstance"), 268 - attribute.class("go-reply-label"), 610 + attribute.class("text-sm font-semibold text-base-content/80"), 269 611 ], 270 612 [ 271 - html.text("Enter your instance adress to reply or "), 613 + html.text("Enter your instance address to reply or "), 272 614 html.a( 273 615 [ 274 - attribute.href("https://mastodon.social/auth/sign_up"), 275 - attribute.class("post-link "), 616 + attribute.href( 617 + "https://" 618 + <> placeholder_instance(mastodon_anchor) 619 + <> "/auth/sign_up", 620 + ), 621 + attribute.class("link link-primary"), 276 622 ], 277 623 [html.text("create an account")], 278 624 ), 279 625 html.text("!"), 280 626 ], 281 627 ), 628 + 629 + // Disclaimer Section 282 630 html.p( 283 - [ 284 - attribute.for("userinstance"), 285 - attribute.class("or-create-an-account-disclaimer"), 286 - ], 631 + [attribute.class("text-xs italic opacity-50 -mt-1 mb-2 ml-1")], 287 632 [ 288 633 html.text( 289 - "on an instance reccommended by this site... or one you pick yourself!", 634 + "on the instance recommended by this widget... or one you pick yourself!", 290 635 ), 291 636 ], 292 637 ), 293 - html.div([attribute.class("form-controls")], [ 638 + 639 + // The Input + Button Group 640 + html.div([attribute.class("join w-full shadow-sm")], [ 294 641 html.input([ 295 642 attribute.type_("text"), 296 643 attribute.required(True), 297 - attribute.placeholder("todon.nl"), 644 + attribute.placeholder(placeholder_instance(mastodon_anchor)), 298 645 attribute.pattern("^([a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}$"), 299 646 attribute.name("userinstance"), 300 - attribute.class("go-reply-form-input "), 647 + // "join-item" removes the inner borders/radii to make them stick 648 + attribute.class( 649 + "input input-bordered join-item flex-1 focus:outline-primary", 650 + ), 301 651 ]), 302 652 html.button( 303 653 [ 304 654 attribute.type_("submit"), 305 - attribute.class("go-reply-form-button "), 655 + attribute.class("btn btn-primary join-item"), 306 656 ], 307 657 [html.text("Go reply")], 308 658 ), 309 659 ]), 310 660 ]), 311 - ]), 312 - html.section([], []), 313 - ]), 661 + ], 662 + ), 314 663 ]) 315 664 } 316 665 666 + fn placeholder_instance(mastodon_anchor: anchors.Mastodon) -> String { 667 + [mastodon_anchor.instance, ..instancelist] 668 + |> list.shuffle 669 + |> list.first 670 + |> result.unwrap(mastodon_anchor.instance) 671 + } 672 + 317 673 fn error_view(error: String) { 318 674 // todo: Style dis. 319 - element.text("AN ERROR OCCURED:" <> error) 675 + html.div([attribute.class("alert alert-error"), attribute.role("alert")], [ 676 + svg.svg( 677 + [ 678 + attribute("viewBox", "0 0 24 24"), 679 + attribute("fill", "none"), 680 + attribute.class("h-6 w-6 shrink-0 stroke-current"), 681 + attribute("xmlns", "http://www.w3.org/2000/svg"), 682 + ], 683 + [ 684 + svg.path([ 685 + attribute( 686 + "d", 687 + "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z", 688 + ), 689 + attribute("stroke-width", "2"), 690 + attribute("stroke-linejoin", "round"), 691 + attribute("stroke-linecap", "round"), 692 + ]), 693 + ], 694 + ), 695 + html.span([], [html.text("AN ERROR OCCURED:" <> error)]), 696 + ]) 320 697 } 321 698 322 699 // EFFECTS --------------------------------------------------------------------- ··· 325 702 bsky_replies: List(BskyThreadReply), 326 703 mastodon_descendants: List(MastodonDescendant), 327 704 ) { 328 - use dispatch <- effect.from 329 - new.coalesce_views(bsky_replies, mastodon_descendants) 330 - |> IncomingCoalescedView 331 - |> dispatch 705 + effect.from(fn(dispatch) { 706 + dispatch( 707 + IncomingCoalescedView(coalesce_views(bsky_replies, mastodon_descendants)), 708 + ) 709 + }) 332 710 } 333 711 334 712 fn refresh_bsky(model: Model) -> Effect(Msg) { ··· 341 719 <> anchor.postid 342 720 343 721 let handler = 344 - rsvp.expect_json(new.bsky_thread_view_decoder(), fn(response) { 722 + rsvp.expect_json(bsky_thread_view_decoder(), fn(response) { 345 723 case response { 346 724 Ok(threadview) -> BskyIncomingThreadView(threadview) 347 725 Error(rsvperror) -> 348 - case rsvperror { 726 + case echo rsvperror { 349 727 rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 350 728 AllStoppingError( 351 729 "The response body we got back from Bluesky was misformed.", ··· 414 792 415 793 let handler = 416 794 rsvp.expect_json( 417 - new.mastodon_status_context_decoder(anchor.postid), 795 + mastodon_status_context_decoder(anchor.postid), 418 796 fn(response) { 419 797 case response { 420 798 Ok(status) -> MastodonIncomingStatus(status) ··· 446 824 } 447 825 448 826 // HELPERS --------------------------------------------------------------------- 827 + fn sort_comments(c1: CoalescedView, c2: CoalescedView) -> order.Order { 828 + case int.compare(c1.agreeability, c2.agreeability) { 829 + order.Eq -> timestamp.compare(c1.created_at, c2.created_at) 830 + measure -> measure 831 + } 832 + } 833 + 449 834 @external(javascript, "./ffi.mjs", "lassign") 450 835 fn js_browse(_: String) -> Nil { 451 836 Nil 452 837 } 838 + 839 + /// 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`. 840 + /// This one is very pruned! Why? Because these json responses are huge and we only need a small subset of the data in them! 841 + pub type BskyThreadView { 842 + BskyThreadView(at_uri: String, replies: List(BskyThreadReply)) 843 + } 844 + 845 + pub type BskyThreadReply { 846 + BskyThreadReply( 847 + at_uri: String, 848 + like_count: Int, 849 + created_at: timestamp.Timestamp, 850 + body_text: String, 851 + author_did: String, 852 + author_handle: String, 853 + author_displayname: String, 854 + author_avatar: String, 855 + children: List(BskyThreadReply), 856 + ) 857 + } 858 + 859 + pub fn bsky_thread_view_decoder() -> decode.Decoder(BskyThreadView) { 860 + use at_uri <- decode.subfield(["thread", "post", "uri"], decode.string) 861 + use replies <- decode.subfield( 862 + ["thread", "replies"], 863 + decode.list(bsky_thread_reply_decoder()), 864 + ) 865 + decode.success(BskyThreadView(at_uri:, replies:)) 866 + } 867 + 868 + fn bsky_thread_reply_decoder() -> decode.Decoder(BskyThreadReply) { 869 + use created_at <- decode.subfield( 870 + ["post", "record", "createdAt"], 871 + decode.map(decode.string, fn(stringstamp) { 872 + result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) 873 + }), 874 + ) 875 + use at_uri <- decode.subfield(["post", "uri"], decode.string) 876 + use body_text <- decode.subfield(["post", "record", "text"], decode.string) 877 + 878 + use author_did <- decode.subfield(["post", "author", "did"], decode.string) 879 + use author_handle <- decode.subfield( 880 + ["post", "author", "handle"], 881 + decode.string, 882 + ) 883 + use author_displayname <- decode.subfield( 884 + ["post", "author", "displayName"], 885 + decode.string, 886 + ) 887 + use author_avatar <- decode.subfield( 888 + ["post", "author", "avatar"], 889 + decode.string, 890 + ) 891 + use like_count <- decode.subfield(["post", "likeCount"], decode.int) 892 + use children <- decode.field( 893 + "replies", 894 + decode.list(bsky_thread_reply_decoder()), 895 + ) 896 + decode.success(BskyThreadReply( 897 + at_uri:, 898 + created_at:, 899 + body_text:, 900 + like_count:, 901 + author_did:, 902 + author_handle:, 903 + author_displayname:, 904 + author_avatar:, 905 + children:, 906 + )) 907 + } 908 + 909 + /// Subset of a Mastodon Status-context, like what you get from `https://pony.social/api/v1/statuses/115911235653686237/context`. 910 + type MastodonStatusContext { 911 + MastodonStatusContext(descendants: List(MastodonDescendant)) 912 + } 913 + 914 + fn mastodon_status_context_decoder( 915 + original_id: String, 916 + ) -> decode.Decoder(MastodonStatusContext) { 917 + use flat_descendants <- decode.field( 918 + "descendants", 919 + decode.list(mastodon_descendant_decoder()), 920 + ) 921 + let descendants: List(MastodonDescendant) = 922 + list.filter_map(flat_descendants, fn(desc) { 923 + case desc.1 == original_id { 924 + True -> { 925 + // A parent! 926 + 927 + mastodon_decendant_inflater(desc.0, flat_descendants) 928 + |> Ok 929 + } 930 + False -> { 931 + Error(Nil) 932 + } 933 + } 934 + }) 935 + decode.success(MastodonStatusContext(descendants:)) 936 + } 937 + 938 + fn mastodon_decendant_inflater( 939 + parent: MastodonDescendant, 940 + all_children: List(#(MastodonDescendant, String)), 941 + ) { 942 + MastodonDescendant( 943 + ..parent, 944 + children: list.filter_map(all_children, fn(c) { 945 + case c.1 == parent.id { 946 + True -> Ok(c.0) 947 + False -> Error(Nil) 948 + } 949 + }), 950 + ) 951 + } 952 + 953 + type MastodonDescendant { 954 + MastodonDescendant( 955 + id: String, 956 + uri: String, 957 + content: element.Element(Msg), 958 + created_at: timestamp.Timestamp, 959 + favourite_count: Int, 960 + // 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. 961 + // We gotta iterate over this later to get things nested. 962 + children: List(MastodonDescendant), 963 + author_url: String, 964 + author_avatar_url: String, 965 + author_username: String, 966 + author_displayname: String, 967 + ) 968 + } 969 + 970 + /// Decodes #(MastodonDescendant, ReplyingTo), to enable the parent decoder to make this into a recursive three. 971 + fn mastodon_descendant_decoder() -> decode.Decoder( 972 + #(MastodonDescendant, String), 973 + ) { 974 + use replying_to <- decode.field("in_reply_to_id", decode.string) 975 + use id <- decode.field("id", decode.string) 976 + use uri <- decode.field("uri", decode.string) 977 + use created_at <- decode.field( 978 + "created_at", 979 + decode.map(decode.string, fn(stringstamp) { 980 + result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) 981 + }), 982 + ) 983 + use favourite_count <- decode.field("favourites_count", decode.int) 984 + use unescaped_html_content <- decode.field("content", decode.string) 985 + let content = internal.sanitise_ls(unescaped_html_content) 986 + 987 + let children = [] 988 + 989 + use author_url <- decode.subfield(["account", "url"], decode.string) 990 + use author_avatar_url <- decode.subfield(["account", "avatar"], decode.string) 991 + use author_displayname <- decode.subfield( 992 + ["account", "display_name"], 993 + decode.string, 994 + ) 995 + use author_username <- decode.subfield(["account", "acct"], decode.string) 996 + 997 + decode.success(#( 998 + MastodonDescendant( 999 + id:, 1000 + uri:, 1001 + content:, 1002 + created_at:, 1003 + favourite_count:, 1004 + children:, 1005 + author_url:, 1006 + author_avatar_url:, 1007 + author_username:, 1008 + author_displayname:, 1009 + ), 1010 + replying_to, 1011 + )) 1012 + } 1013 + 1014 + fn coalesce_views( 1015 + bsky: List(BskyThreadReply), 1016 + mastodon: List(MastodonDescendant), 1017 + ) -> List(CoalescedView) { 1018 + let mixed: List(Result(BskyThreadReply, MastodonDescendant)) = { 1019 + list.append( 1020 + list.map(bsky, fn(m) { Ok(m) }), 1021 + list.map(mastodon, fn(m) { Error(m) }), 1022 + ) 1023 + |> list.shuffle 1024 + } 1025 + list.map(mixed, fn(item) { 1026 + case item { 1027 + Ok(BskyThreadReply( 1028 + created_at:, 1029 + at_uri:, 1030 + like_count:, 1031 + body_text:, 1032 + author_did:, 1033 + author_displayname:, 1034 + author_handle:, 1035 + author_avatar:, 1036 + children:, 1037 + )) -> 1038 + CoalescedView( 1039 + content_url: "https://bsky.app/profile/" 1040 + <> string.replace(at_uri, "/app.bsky.feed.post/", "/post/"), 1041 + created_at:, 1042 + author_profile_link: "https://bluesky.app/profile/" <> author_did, 1043 + source: "Bluesky", 1044 + agreeability: like_count, 1045 + content: element.text(body_text), 1046 + author_username: author_handle, 1047 + author_avatar_url: author_avatar, 1048 + displayname: author_displayname, 1049 + children: { coalesce_views(children, []) }, 1050 + ) 1051 + Error(MastodonDescendant( 1052 + created_at:, 1053 + id:, 1054 + uri:, 1055 + content:, 1056 + favourite_count:, 1057 + children:, 1058 + author_url:, 1059 + author_avatar_url:, 1060 + author_username:, 1061 + author_displayname:, 1062 + )) -> 1063 + CoalescedView( 1064 + content_url: uri, 1065 + created_at:, 1066 + author_profile_link: author_url, 1067 + source: "Mastodon", 1068 + displayname: author_displayname, 1069 + agreeability: favourite_count, 1070 + author_avatar_url:, 1071 + author_username:, 1072 + content: content, 1073 + children: { coalesce_views([], children) }, 1074 + ) 1075 + } 1076 + }) 1077 + } 1078 + 1079 + type CoalescedView { 1080 + 1081 + CoalescedView( 1082 + created_at: timestamp.Timestamp, 1083 + author_profile_link: String, 1084 + author_avatar_url: String, 1085 + author_username: String, 1086 + source: String, 1087 + displayname: String, 1088 + content: element.Element(Msg), 1089 + content_url: String, 1090 + agreeability: Int, 1091 + children: List(CoalescedView), 1092 + ) 1093 + }