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.

rc2


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

+127 -133
+11 -1
README.md
··· 6 6 ```sh 7 7 gleam add chilp@1 8 8 ``` 9 + ## Usage 9 10 10 - ## Examples 11 + You can use chilp as any Lustre component, the main module has an easy builder which allows you to input typed `Anchors` (links to posts from which we fetch comments) instead of using attributes. If you prefer, you could also use `widget.element()` or create a Chilp widget yourself using element.element. As long as `widget.register()` is ran Chilp should be able to pick it up. 12 + 13 + ### Examples 11 14 12 15 - [lustre_chilp_app](https://forge.strawmelonjuice.com/strawmelonjuice/chilp/src/branch/main/examples/lustre_chilp_app) 16 + 17 + ### Commands 18 + 19 + Chilp's power is that it can rely on other backends to provide moderation, user verification, etc. However, sometimes you may want to create some custom behaviour for your site specifically. In that case, you should be able to add a comment to any offending comments to this end which Chilp recognises and processes. Chilp commands are prefixed like `-chilp <command>`. 20 + 21 + - `hide`: Commenting `-chilp hide` on a comment hides that particular comment and all it's children. 22 + - `silence`: Commenting `-chilp silence` on a comment shows that particular comment but hides all it's children. 13 23 14 24 Further documentation can be found at <https://hexdocs.pm/chilp>. 15 25
+1 -1
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.0-rc" 3 + version = "2.0.0-rc2" 4 4 gleam = ">= 1.15.0" 5 5 licences = ["Apache-2.0"] 6 6 repository = { type = "tangled", user = "did:plc:jgtfsmv25thfs4zmydtbccnn", repo = "chilp" }
-123
src/chilp/internal.gleam
··· 1 - import gleam/list 2 - import gleam/string 3 - import html_parser 4 - import lustre/attribute 5 - import lustre/element 6 - import lustre/element/html 7 - 8 - /// Attempts to do what DOMpurify does... 9 - pub fn sanitise(html: String) -> String { 10 - // To a tree is the easy part. 11 - html_parser.as_tree(html) 12 - // Then reconstructing it sanely... 13 - |> sanitise_reconstruct 14 - } 15 - 16 - /// Attempts to do what DOMpurify does... ...while lustre-ifying it! 17 - pub fn sanitise_ls(html: String) -> element.Element(a) { 18 - // To a tree is the easy part. 19 - html_parser.as_tree(html) 20 - // Then reconstructing it sanely... 21 - |> sanitise_reconstruct_ls 22 - } 23 - 24 - fn sanitise_reconstruct(el: html_parser.Element) { 25 - case el { 26 - html_parser.EmptyElement -> "" 27 - html_parser.StartElement(name:, attributes:, children:) -> { 28 - // Opening tag 29 - let opener = case name { 30 - "b" -> "<b" 31 - "i" -> "<i" 32 - "em" -> "<em" 33 - "strong" -> "<strong" 34 - "a" -> "<a" 35 - "p" -> "<p" 36 - "br" -> "<br" 37 - "span" -> "<span" 38 - _ -> "" 39 - } 40 - opener 41 - <> list.map(attributes, fn(attrib) { 42 - case attrib { 43 - _ if opener == "" -> { 44 - "" 45 - } 46 - html_parser.Attribute(key: "href", value: link) -> 47 - " href=\"" <> link <> "\" " 48 - html_parser.Attribute(key: "class", value: classes) -> 49 - " class=\"" <> classes <> "\" " 50 - html_parser.Attribute(key: "target", value: target) -> 51 - " target=\"" <> target <> "\" " 52 - html_parser.Attribute(_, _) -> "" 53 - } 54 - }) 55 - |> string.concat() 56 - <> case opener == "" { 57 - False -> ">" 58 - True -> "" 59 - } 60 - <> list.map(children, sanitise_reconstruct) |> string.concat 61 - // Closing tag 62 - <> case name { 63 - "b" -> "</b>" 64 - "i" -> "</i>" 65 - "em" -> "</em>" 66 - "strong" -> "</strong>" 67 - "a" -> "</a>" 68 - "p" -> "</p>" 69 - "br" -> "</br>" 70 - "span" -> "</span>" 71 - _ -> "" 72 - } 73 - } 74 - // AFAIK we don't have this due to parsing as tree. 75 - html_parser.EndElement(_) -> "ERROR: Did not expect an element end here!" 76 - html_parser.Content(cnt) -> cnt 77 - } 78 - } 79 - 80 - fn sanitise_reconstruct_ls(el: html_parser.Element) -> element.Element(a) { 81 - case el { 82 - html_parser.EmptyElement -> element.none() 83 - html_parser.StartElement(name:, attributes:, children:) -> { 84 - // attributes 85 - let attribs = 86 - list.map(attributes, fn(attrib) { 87 - case attrib { 88 - html_parser.Attribute(key: "href", value: link) -> 89 - attribute.href(link) 90 - html_parser.Attribute(key: "class", value: classes) -> 91 - attribute.class(classes) 92 - html_parser.Attribute(key: "target", value: target) -> 93 - attribute.target(target) 94 - html_parser.Attribute(_, _) -> attribute.none() 95 - } 96 - }) 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-content"), ..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() 117 - } 118 - // AFAIK we don't have this due to parsing as tree. 119 - html_parser.EndElement(_) -> 120 - element.text("ERROR: Did not expect an element end here!") 121 - html_parser.Content(cnt) -> element.text(cnt) 122 - } 123 - }
+115 -8
src/chilp/widget.gleam
··· 1 1 // IMPORTS --------------------------------------------------------------------- 2 - 3 - import chilp/internal 4 2 import chilp/widget/anchors 5 3 import gleam/bool 6 4 import gleam/dynamic/decode ··· 15 13 import gleam/time/duration 16 14 import gleam/time/timestamp 17 15 import gleam/uri 16 + import html_parser 18 17 import lustre 19 18 import lustre/attribute.{attribute} 20 19 import lustre/component ··· 393 392 html.section( 394 393 [attribute.class("pt-5 space-y-10")], 395 394 list.sort(model.cached_coalesced_view, sort_comments) 396 - |> list.map(view_rendered_comment), 395 + |> list.map(view_rendered_comment( 396 + _, 397 + option.map(model.bluesky_anchor, fn(bsky) { 398 + "https://bsky.app/profile/" <> bsky.did 399 + }), 400 + option.map(model.mastodon_anchor, fn(masto) { 401 + "https://" 402 + <> masto.instance 403 + <> "/" 404 + <> model.mastodon_op_username 405 + }), 406 + )), 397 407 ), 398 408 ], 399 409 ), 400 410 ]) 401 411 } 402 412 403 - fn view_rendered_comment(comment: CoalescedView) -> Element(Msg) { 413 + fn view_rendered_comment( 414 + comment: CoalescedView, 415 + bsky_op_profile: Option(String), 416 + mastodon_op_profile: Option(String), 417 + ) -> Element(Msg) { 418 + use <- bool.guard( 419 + when: { 420 + list.find(comment.children, fn(child) { 421 + child.author_profile_link 422 + == case comment.source, bsky_op_profile, mastodon_op_profile { 423 + "Mastodon", _, Some(op) -> op 424 + "Bluesky", Some(op), _ -> op 425 + _, _, _ -> "" 426 + } 427 + && child.content |> element.to_readable_string() == "-chilp hide" 428 + }) 429 + // If this is ok, that means a hide command by the op was found. We return this thread and don't need to process anything else in it. 430 + |> result.is_ok() 431 + }, 432 + return: element.none(), 433 + ) 404 434 html.article([attribute.class("comment mt-2")], [ 405 435 html.header([attribute.class("flex")], [ 406 436 html.img([ ··· 513 543 "pl-5 border-s-4 border-default bg-neutral-secondary-soft", 514 544 ), 515 545 ], 516 - list.sort(comment.children, sort_comments) 517 - |> list.map(view_rendered_comment), 546 + case 547 + { 548 + list.find(comment.children, fn(child) { 549 + child.author_profile_link 550 + == case comment.source, bsky_op_profile, mastodon_op_profile { 551 + "Mastodon", _, Some(op) -> op 552 + "Bluesky", Some(op), _ -> op 553 + _, _, _ -> "" 554 + } 555 + && child.content |> element.to_readable_string() 556 + == "-chilp silence" 557 + }) 558 + // If this is ok, that means a hide command by the op was found. We return this thread and don't need to process anything else in it. 559 + |> result.is_ok() 560 + } 561 + { 562 + True -> 563 + list.sort(comment.children, sort_comments) 564 + |> list.map(view_rendered_comment( 565 + _, 566 + bsky_op_profile, 567 + mastodon_op_profile, 568 + )) 569 + False -> [element.text("Comments on this comment are hidden.")] 570 + }, 518 571 ) 519 572 }, 520 573 ]), ··· 824 877 } 825 878 826 879 // HELPERS --------------------------------------------------------------------- 880 + 881 + /// Attempts to do what DOMpurify does... ...while lustre-ifying it! 882 + fn sanitise_ls(html: String) -> element.Element(a) { 883 + // To a tree is the easy part. 884 + html_parser.as_tree(html) 885 + // Then reconstructing it sanely... 886 + |> sanitise_reconstruct_ls 887 + } 888 + 889 + fn sanitise_reconstruct_ls(el: html_parser.Element) -> element.Element(a) { 890 + case el { 891 + html_parser.EmptyElement -> element.none() 892 + html_parser.StartElement(name:, attributes:, children:) -> { 893 + // attributes 894 + let attribs = 895 + list.map(attributes, fn(attrib) { 896 + case attrib { 897 + html_parser.Attribute(key: "href", value: link) -> 898 + attribute.href(link) 899 + html_parser.Attribute(key: "class", value: classes) -> 900 + attribute.class(classes) 901 + html_parser.Attribute(key: "target", value: target) -> 902 + attribute.target(target) 903 + html_parser.Attribute(_, _) -> attribute.none() 904 + } 905 + }) 906 + [ 907 + list.map(children, sanitise_reconstruct_ls) 908 + |> case name { 909 + "b" -> html.b(attribs, _) 910 + "i" -> html.i(attribs, _) 911 + "em" -> html.em(attribs, _) 912 + "strong" -> html.strong(attribs, _) 913 + "a" -> html.a( 914 + [attribute.class("link link-secondary-content"), ..attribs], 915 + _, 916 + ) 917 + 918 + "p" -> html.p(attribs, _) 919 + "br" -> fn(_) { html.br(attribs) } 920 + "span" -> html.span(attribs, _) 921 + _ -> element.fragment 922 + }, 923 + element.text(" "), 924 + ] 925 + |> element.fragment() 926 + } 927 + // AFAIK we don't have this due to parsing as tree. 928 + html_parser.EndElement(_) -> 929 + element.text("ERROR: Did not expect an element end here!") 930 + html_parser.Content(cnt) -> element.text(cnt) 931 + } 932 + } 933 + 827 934 fn sort_comments(c1: CoalescedView, c2: CoalescedView) -> order.Order { 828 935 case int.compare(c1.agreeability, c2.agreeability) { 829 936 order.Eq -> timestamp.compare(c1.created_at, c2.created_at) ··· 982 1089 ) 983 1090 use favourite_count <- decode.field("favourites_count", decode.int) 984 1091 use unescaped_html_content <- decode.field("content", decode.string) 985 - let content = internal.sanitise_ls(unescaped_html_content) 1092 + let content = sanitise_ls(unescaped_html_content) 986 1093 987 1094 let children = [] 988 1095 ··· 1050 1157 ) 1051 1158 Error(MastodonDescendant( 1052 1159 created_at:, 1053 - id:, 1160 + id: _, 1054 1161 uri:, 1055 1162 content:, 1056 1163 favourite_count:,