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.

Componentise

+836 -992
+1 -1
README.md
··· 18 18 19 19 ## Styling 20 20 21 - You may want to look at [styles.css](https://forge.strawmelonjuice.com/strawmelonjuice/chilp/src/branch/main/examples/lustre_chilp_app_autoloading/assets/styles.css) to see how to style your own comment sections! 21 + <!--You may want to look at [styles.css](https://forge.strawmelonjuice.com/strawmelonjuice/chilp/src/branch/main/examples/lustre_chilp_app_autoloading/assets/styles.css) to see how to style your own comment sections!--> 22 22 23 23 ## Development 24 24
-252
examples/lustre_chilp_app/assets/styles.css
··· 1 - .chilp-widget { 2 - --highlight: #595aff; 3 - transition: all 0.5s ease; 4 - overflow: hidden; 5 - ::selection { 6 - background-color: rgba(89, 90, 255, 0.2); 7 - color: var(--highlight); 8 - } 9 - 10 - .widget::-webkit-scrollbar { 11 - width: 6px; 12 - } 13 - .widget::-webkit-scrollbar-thumb { 14 - background-color: #e2e8f0; 15 - border-radius: 10px; 16 - } 17 - 18 - .widget { 19 - max-width: 600px; 20 - margin: 2rem auto; 21 - background-color: floralwhite; 22 - font-family: sans-serif; 23 - padding: 2rem; 24 - border-radius: 12px; 25 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); 26 - } 27 - 28 - .widget > .btn-get-comments { 29 - display: block; 30 - width: 100%; 31 - max-width: 200px; 32 - margin: 2rem auto 0 auto; /* Centers it and adds space above */ 33 - padding: 12px 24px; 34 - background-color: transparent; 35 - color: #595aff; 36 - border: 2px solid #595aff; 37 - border-radius: 8px; 38 - font-weight: 600; 39 - cursor: pointer; 40 - transition: all 0.2s ease-in-out; 41 - } 42 - 43 - .widget > .btn-get-comments:hover { 44 - background-color: #595aff; 45 - color: white; 46 - box-shadow: 0 4px 12px rgba(89, 90, 255, 0.3); 47 - transform: translateY(-1px); 48 - } 49 - 50 - h1.widget-header { 51 - font-size: 1.75rem; 52 - font-weight: 800; 53 - color: #1a202c; 54 - margin: 0 0 0.5rem 0; 55 - letter-spacing: -0.025em; 56 - } 57 - 58 - .subheader { 59 - font-size: 0.95rem; 60 - color: #718096; 61 - margin-bottom: 1.5rem; 62 - display: flex; 63 - align-items: center; 64 - gap: 6px; 65 - } 66 - 67 - .post-link { 68 - color: #595aff; 69 - text-decoration: none; 70 - font-weight: 500; 71 - border-bottom: 1px solid transparent; 72 - transition: border-color 0.2s; 73 - } 74 - 75 - .post-link:hover { 76 - border-bottom-color: #595aff; 77 - } 78 - 79 - .or-create-an-account-disclaimer { 80 - font-size: 0.73rem; 81 - color: #a0aec0; 82 - margin: -4px 0 12px 2px; 83 - font-style: italic; 84 - margin-bottom: 1.5rem; 85 - display: flex; 86 - align-items: center; 87 - gap: 6px; 88 - } 89 - 90 - .go-reply-form { 91 - display: flex; 92 - gap: 0; 93 - border-bottom: 1px solid #edf2f7; 94 - padding-bottom: 1.5rem; 95 - margin-bottom: 2rem; 96 - max-width: 500px; 97 - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); 98 - } 99 - 100 - .go-reply-form-input { 101 - flex: 1; 102 - padding: 10px 15px; 103 - border: 2px solid #e2e8f0; 104 - flex: 1; 105 - border-right: none; 106 - border-radius: 8px 0 0 8px; 107 - font-size: 0.95rem; 108 - outline: none; 109 - transition: border-color 0.2s; 110 - } 111 - 112 - .go-reply-form-input:focus { 113 - border-color: var(--highlight); 114 - } 115 - 116 - .go-reply-form-input::placeholder { 117 - color: #a0aec0; 118 - } 119 - .input-group { 120 - display: flex; 121 - flex-direction: column; 122 - width: 100%; 123 - gap: 6px; 124 - } 125 - 126 - .go-reply-label { 127 - font-size: 0.85rem; 128 - font-weight: 600; 129 - color: #4a5568; 130 - /*text-transform: uppercase;*/ 131 - letter-spacing: 0.05em; 132 - margin-left: 2px; 133 - } 134 - 135 - .form-controls { 136 - display: flex; 137 - width: 100%; 138 - } 139 - 140 - .go-reply-form-button { 141 - padding: 10px 20px; 142 - border: 2px solid var(--highlight); 143 - border-radius: 0 8px 8px 0; 144 - background-color: var(--highlight); 145 - color: white; 146 - font-weight: 600; 147 - cursor: pointer; 148 - transition: all 0.2s; 149 - white-space: nowrap; 150 - } 151 - 152 - .go-reply-form-button:hover { 153 - background-color: var(--highlight); 154 - border-color: var(--highlight); 155 - } 156 - 157 - @media (max-width: 480px) { 158 - .go-reply-form { 159 - flex-direction: column; 160 - gap: 8px; 161 - } 162 - .go-reply-form-input, 163 - .go-reply-form-button { 164 - border-radius: 8px; 165 - border: 2px solid #e2e8f0; 166 - } 167 - } 168 - 169 - .comment-widget form { 170 - display: flex; 171 - gap: 10px; 172 - margin-bottom: 1.5rem; 173 - } 174 - 175 - .comment-widget input[name="userinstance"] { 176 - flex-grow: 1; 177 - padding: 8px 12px; 178 - border: 1px solid #ccc; 179 - border-radius: 4px; 180 - } 181 - 182 - .comment { 183 - border-left: 2px solid #eee; 184 - padding-left: 1rem; 185 - margin-top: 1.5rem; 186 - display: flex; 187 - flex-direction: column; 188 - } 189 - 190 - .comment header { 191 - display: flex; 192 - align-items: center; 193 - gap: 12px; 194 - margin-bottom: 8px; 195 - } 196 - 197 - .comment .avatar { 198 - width: 36px; 199 - height: 36px; 200 - border-radius: 4px; 201 - object-fit: cover; 202 - background: #eee; 203 - border: 1px solid rgba(0, 0, 0, 0.05); 204 - } 205 - 206 - .comment .display-name { 207 - font-weight: bold; 208 - display: block; 209 - } 210 - 211 - .comment time { 212 - font-size: 0.85rem; 213 - color: #666; 214 - } 215 - 216 - .comment .content { 217 - line-height: 1.5; 218 - } 219 - 220 - .comment .content p { 221 - margin: 0.5rem 0; 222 - } 223 - 224 - .comment .mention { 225 - color: var(--highlight); 226 - text-decoration: none; 227 - } 228 - 229 - .comment footer { 230 - margin-top: 8px; 231 - } 232 - 233 - .comment footer a { 234 - font-size: 0.8rem; 235 - color: #888; 236 - text-decoration: none; 237 - } 238 - 239 - .comment footer a:hover { 240 - text-decoration: underline; 241 - } 242 - 243 - .comment .comment { 244 - margin-left: 10px; 245 - border-left: 2px solid #ddd; 246 - } 247 - 248 - .error { 249 - color: red; 250 - font-size: smaller; 251 - } 252 - }
-4
examples/lustre_chilp_app/gleam.toml
··· 21 21 [dev-dependencies] 22 22 gleeunit = ">= 1.0.0 and < 2.0.0" 23 23 lustre_dev_tools = ">= 2.3.4 and < 3.0.0" 24 - [tools.lustre.html] 25 - stylesheets = [ 26 - { href = "styles.css" } 27 - ]
examples/lustre_chilp_app/src/ffi_lustre_chilp_app.mjs examples/lustre_chilp_app_nocomponent/src/ffi_lustre_chilp_app.mjs
+14 -28
examples/lustre_chilp_app/src/lustre_chilp_app.gleam
··· 4 4 import lustre 5 5 import lustre/effect.{type Effect} 6 6 import lustre/element.{type Element} 7 + import lustre/element/html 7 8 8 9 // MAIN ------------------------------------------------------------------------ 9 10 10 11 pub fn main() { 11 - // In this example, we're not making much sense. Just Chilp. 12 + // In this example, we're not making much sense. Just showing off Chilp! 13 + 14 + let assert Ok(_) = widget.register() 15 + 12 16 let app = lustre.application(init, update, view) 13 17 let assert Ok(_) = lustre.start(app, "#app", Nil) 14 18 ··· 18 22 // MODEL ----------------------------------------------------------------------- 19 23 20 24 type Model { 21 - Model( 22 - string: String, 23 - // .. and other things your application would need to know, for chilp we have: 24 - chilp_model: widget.ChilpDataInYourModel(Msg), 25 - ) 25 + Model(string: String, num: Int) 26 26 } 27 27 28 28 fn init(_) -> #(Model, Effect(Msg)) { 29 - let model = Model(string: "Hi", chilp_model: widget.init(ChilpMessage)) 29 + let model = Model(string: "Hi", num: 4) 30 30 // No effects, though you could force Chilp to pre-fetch a post with widget.force()! 31 31 let effect = effect.none() 32 32 33 33 #(model, effect) 34 34 } 35 35 36 - // HELPERS---------------------------------------------------------------------- 37 - @external(javascript, "./ffi_lustre_chilp_app.mjs", "lassign") 38 - fn js_browse(to: String) -> Nil { 39 - Nil 40 - } 41 - 42 - fn browse(to: String) { 43 - js_browse(to) 44 - effect.none() 45 - } 46 - 47 36 // UPDATE ---------------------------------------------------------------------- 48 37 49 38 type Msg { 50 - ChilpMessage(widget.ChilpMsg) 39 + SetString(String) 51 40 } 52 41 53 42 // You can't usually make `ChilpMsg`s, with a few exceptions. ··· 55 44 // One of them being `widget.trigger()`, which does what `widget.force()` does but instead of an effect it returns a `ChilpMsg`! 56 45 fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 57 46 case msg { 58 - // Normally, your own variants would be here too. 59 - ChilpMessage(message) -> { 60 - let #(chilp_model, effect) = 61 - widget.update(message, model.chilp_model, browse) 62 - #(Model(..model, chilp_model:), effect) 63 - } 47 + SetString(string) -> #(Model(string: string, num: model.num), effect.none()) 64 48 } 65 49 } 66 50 67 51 // VIEW ------------------------------------------------------------------------ 68 52 69 53 fn view(model: Model) -> Element(Msg) { 70 - // Let's render comments under https://pony.social/@strawmelonjuice/115911235653686237 and nothing else 71 - widget.new("pony.social", "115911235653686237", model.chilp_model) 72 - |> widget.show(model.chilp_model) 54 + html.div([], [ 55 + element.text(model.string), 56 + // Let's render comments under https://pony.social/@strawmelonjuice/115911235653686237 and nothing else 57 + widget.element(instance: "pony.social", post_id: "115911235653686237"), 58 + ]) 73 59 }
examples/lustre_chilp_app_autoloading/.gitignore examples/lustre_chilp_app_nocomponent/.gitignore
+16
examples/lustre_chilp_app_autoloading/assets/styles.css src/chilp/ffi.mjs
··· 1 + export function lassign(url) { 2 + window.location.assign(url); 3 + } 4 + 5 + export function inline_styles() { 6 + return ` 1 7 .chilp-widget { 2 8 --highlight: #595aff; 3 9 transition: all 0.5s ease; ··· 250 256 font-size: smaller; 251 257 } 252 258 } 259 + `; 260 + } 261 + import DOMPurify from "https://esm.sh/dompurify@3.2.7"; 262 + 263 + export function sanitize(html) { 264 + return DOMPurify.sanitize(html, { 265 + ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "span", "p", "br"], 266 + ALLOWED_ATTR: ["href", "class", "target"], 267 + }); 268 + }
+2 -5
examples/lustre_chilp_app_autoloading/gleam.toml examples/lustre_chilp_app_nocomponent/gleam.toml
··· 1 - name = "lustre_chilp_app_autoload" 1 + name = "lustre_chilp_app" 2 2 version = "1.0.0" 3 3 4 4 # Fill out these fields if you intend to generate HTML documentation or publish ··· 16 16 gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 17 chilp = {path = "../.."} 18 18 lustre = ">= 5.5.2 and < 6.0.0" 19 + glentities = ">= 6.2.1 and < 7.0.0" 19 20 20 21 [dev-dependencies] 21 22 gleeunit = ">= 1.0.0 and < 2.0.0" 22 23 lustre_dev_tools = ">= 2.3.4 and < 3.0.0" 23 - [tools.lustre.html] 24 - stylesheets = [ 25 - { href = "styles.css" } 26 - ]
+2
examples/lustre_chilp_app_autoloading/manifest.toml examples/lustre_chilp_app_nocomponent/manifest.toml
··· 37 37 { name = "lustre_dev_tools", version = "2.3.4", build_tools = ["gleam"], requirements = ["argv", "booklet", "filepath", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib", "glint", "group_registry", "justin", "lustre", "mist", "polly", "simplifile", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "5D5C479E465A3EA018205EFCD2F2FE430A9B9783CAC21670E6CB25703069407D" }, 38 38 { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 39 39 { name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" }, 40 + { name = "odysseus", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "odysseus", source = "hex", outer_checksum = "6A97DA1075BDDEA8B60F47B1DFFAD49309FA27E73843F13A0AF32EA7087BA11C" }, 40 41 { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 41 42 { name = "polly", version = "3.0.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_erlang", "gleam_otp", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "35B11497B998618CEE216415A7853C3FED3F0F2148DC86BD8FC86B95D67F6DD8" }, 42 43 { name = "rsvp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "rsvp", source = "hex", outer_checksum = "40F9E0E662FF258E10C7041A9591261FE802D56625FB444B91510969644F7722" }, ··· 51 52 chilp = { path = "../.." } 52 53 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 53 54 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 55 + glentities = { version = ">= 6.2.1 and < 7.0.0" } 54 56 lustre = { version = ">= 5.5.2 and < 6.0.0" } 55 57 lustre_dev_tools = { version = ">= 2.3.4 and < 3.0.0" }
-3
examples/lustre_chilp_app_autoloading/src/ffi_lustre_chilp_app_autoload.mjs
··· 1 - export function lassign(url) { 2 - window.location.assign(url); 3 - }
-86
examples/lustre_chilp_app_autoloading/src/lustre_chilp_app_autoload.gleam
··· 1 - // Same example as 2 - 3 - // IMPORTS --------------------------------------------------------------------- 4 - 5 - import chilp/widget 6 - import lustre 7 - import lustre/effect.{type Effect} 8 - import lustre/element.{type Element} 9 - 10 - // MAIN ------------------------------------------------------------------------ 11 - 12 - pub fn main() { 13 - // In this example, we're not making much sense. Just Chilp. 14 - let app = lustre.application(init, update, view) 15 - let assert Ok(_) = lustre.start(app, "#app", Nil) 16 - 17 - Nil 18 - } 19 - 20 - // MODEL ----------------------------------------------------------------------- 21 - 22 - type Model { 23 - Model( 24 - string: String, 25 - // .. and other things your application would need to know, for chilp we have: 26 - chilp_model: widget.ChilpDataInYourModel(Msg), 27 - // A widget we pre-create in the init function, this could also be done inside of 28 - // your update function, and you don't NEED to store the widget data itself in your 29 - // model, you are allowed to call `widget.new()` twice and it'll create the same widget. 30 - my_widget: widget.CommentWidget(Msg), 31 - ) 32 - } 33 - 34 - fn init(_) -> #(Model, Effect(Msg)) { 35 - // Let's create a widget! 36 - // In this case we create the widget and let it travel with the model, but just creating it twice works too! 37 - let chilp_model = widget.init(ChilpMessage) 38 - let my_widget = 39 - widget.new( 40 - instance: "mastodon.social", 41 - post_id: "115978549407058619", 42 - chilp_model:, 43 - ) 44 - let model = Model(string: "Hi", chilp_model:, my_widget:) 45 - let effect = widget.force(chilp_model:, on: my_widget) 46 - 47 - #(model, effect) 48 - } 49 - 50 - // HELPERS---------------------------------------------------------------------- 51 - @external(javascript, "./ffi_lustre_chilp_app_autoload.mjs", "lassign") 52 - fn js_browse(to: String) -> Nil { 53 - Nil 54 - } 55 - 56 - fn browse(to: String) { 57 - let _ = js_browse(to) == Nil 58 - effect.none() 59 - } 60 - 61 - // UPDATE ---------------------------------------------------------------------- 62 - 63 - type Msg { 64 - ChilpMessage(widget.ChilpMsg) 65 - } 66 - 67 - // You can't usually make `ChilpMsg`s, with a few exceptions. 68 - // 69 - // One of them being `widget.trigger()`, which does what `widget.force()` does but instead of an effect it returns a `ChilpMsg`! 70 - fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 71 - case msg { 72 - // Normally, your own variants would be here too. 73 - ChilpMessage(message) -> { 74 - let #(chilp_model, effect) = 75 - widget.update(message, model.chilp_model, browse) 76 - #(Model(..model, chilp_model:), effect) 77 - } 78 - } 79 - } 80 - 81 - // VIEW ------------------------------------------------------------------------ 82 - 83 - fn view(model: Model) -> Element(Msg) { 84 - // Render the widget we made in init(). 85 - widget.show(model.my_widget, model.chilp_model) 86 - }
+73
examples/lustre_chilp_app_nocomponent/src/lustre_chilp_app.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import chilp/widget/base 4 + import lustre 5 + import lustre/effect.{type Effect} 6 + import lustre/element.{type Element} 7 + 8 + // MAIN ------------------------------------------------------------------------ 9 + 10 + pub fn main() { 11 + // In this example, we're not making much sense. Just Chilp. 12 + let app = lustre.application(init, update, view) 13 + let assert Ok(_) = lustre.start(app, "#app", Nil) 14 + 15 + Nil 16 + } 17 + 18 + // MODEL ----------------------------------------------------------------------- 19 + 20 + type Model { 21 + Model( 22 + string: String, 23 + // .. and other things your application would need to know, for chilp we have: 24 + chilp_model: base.ChilpDataInYourModel(Msg), 25 + ) 26 + } 27 + 28 + fn init(_) -> #(Model, Effect(Msg)) { 29 + let model = Model(string: "Hi", chilp_model: base.init(ChilpMessage)) 30 + // No effects, though you could force Chilp to pre-fetch a post with base.force()! 31 + let effect = effect.none() 32 + 33 + #(model, effect) 34 + } 35 + 36 + // HELPERS---------------------------------------------------------------------- 37 + @external(javascript, "./ffi_lustre_chilp_app.mjs", "lassign") 38 + fn js_browse(_: String) -> Nil { 39 + Nil 40 + } 41 + 42 + fn browse(to: String) { 43 + let Nil = js_browse(to) 44 + effect.none() 45 + } 46 + 47 + // UPDATE ---------------------------------------------------------------------- 48 + 49 + type Msg { 50 + ChilpMessage(base.ChilpMsg) 51 + } 52 + 53 + // You can't usually make `ChilpMsg`s, with a few exceptions. 54 + // 55 + // One of them being `base.trigger()`, which does what `base.force()` does but instead of an effect it returns a `ChilpMsg`! 56 + fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 57 + case msg { 58 + // Normally, your own variants would be here too. 59 + ChilpMessage(message) -> { 60 + let #(chilp_model, effect) = 61 + base.update(message, model.chilp_model, browse) 62 + #(Model(..model, chilp_model:), effect) 63 + } 64 + } 65 + } 66 + 67 + // VIEW ------------------------------------------------------------------------ 68 + 69 + fn view(model: Model) -> Element(Msg) { 70 + // Let's render comments under https://pony.social/@strawmelonjuice/115911235653686237 and nothing else 71 + base.new("pony.social", "115911235653686237", model.chilp_model) 72 + |> base.show(model.chilp_model) 73 + }
-1
src/chilp.gleam
··· 1 - //// More API focussed stuff should go here 2 1
-8
src/chilp/purify_bind_ffi.mjs
··· 1 - import DOMPurify from "https://esm.sh/dompurify@3.2.7"; 2 - 3 - export function sanitize(html) { 4 - return DOMPurify.sanitize(html, { 5 - ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "span", "p", "br"], 6 - ALLOWED_ATTR: ["href", "class", "target"], 7 - }); 8 - }
+78 -604
src/chilp/widget.gleam
··· 1 - import chilp/api_typing 2 - import gleam/dict 3 - import gleam/int 4 - import gleam/list 5 - import gleam/option 6 - import gleam/order 7 - import gleam/pair 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import chilp/widget/base as widget 8 4 import gleam/result 9 5 import gleam/string 10 - import gleam/time/duration 11 - import gleam/time/timestamp 12 - import gleam/uri 13 - import lustre/attribute.{attribute} 14 - import lustre/effect 15 - import lustre/element 6 + import lustre 7 + import lustre/attribute 8 + import lustre/component 9 + import lustre/effect.{type Effect} 10 + import lustre/element.{type Element} 16 11 import lustre/element/html 17 - import lustre/event 18 - import rsvp 19 12 20 - pub opaque type MastodonPost { 21 - MastodonPost(instance: String, postid: String) 22 - } 13 + // MAIN ------------------------------------------------------------------------ 23 14 24 - /// Creates a comment widget, this is where you should probably start! 25 - /// 26 - /// This function takes three arguments: 27 - /// - `instance`: The instance name, e.g. mastodon.social 28 - /// - `postid`: A post id to bind to, you'll find this in a post url `https://mastodon.social/@<username>/[postid]`. 29 - /// - `messages`: Some messages that chilp needs to be able to send 30 - /// The resulting comment widget can be edited however you'd like, but is 31 - pub fn new( 32 - instance instance: String, 33 - post_id postid: String, 34 - chilp_model model: ChilpDataInYourModel(msg), 35 - ) -> CommentWidget(msg) { 36 - let instancelist = [ 37 - instance, 38 - "mastodon.social", 39 - instance, 40 - "pony.social", 41 - instance, 42 - "todon.nl", 43 - instance, 44 - "mstdn.social", 45 - instance, 46 - ] 47 - let instanceplaceholder = { 48 - instancelist 49 - |> list.shuffle 50 - |> list.first 51 - |> result.unwrap("myinstance.social") 52 - } 53 - let post = MastodonPost(instance:, postid:) 54 - let set_message_get = Get(post) |> model.message 55 - let go_answer = fn(n) { 56 - let value = 57 - list.key_find(n, "userinstance") 58 - |> result.unwrap(instanceplaceholder) 59 - GoAnswer(value, post) |> model.message 60 - } 61 - CommentWidget( 62 - post:, 63 - instancelist:, 64 - recursion_limit: 3, 65 - emit_error: True, 66 - widget_header: #("Comments", [ 67 - attribute.classes([#("widget-header h1", True)]), 68 - ]), 69 - widget: [ 70 - attribute.classes([#("widget", True)]), 71 - ], 72 - load_button: [ 73 - event.on_click(set_message_get), 74 - attribute.classes([#("btn-get-comments", True)]), 75 - ], 76 - comments_section: [], 77 - go_reply_form: [ 78 - event.on_submit(go_answer), 79 - attribute.classes([#("go-reply-form", True)]), 80 - ], 81 - go_reply_text_box: [ 82 - attribute.type_("text"), 83 - attribute.placeholder(instanceplaceholder), 84 - attribute.name("userinstance"), 85 - attribute.classes([#("go-reply-form-input", True)]), 86 - attribute.pattern("^([a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}$"), 87 - attribute.required(True), 88 - ], 89 - go_reply_button: [ 90 - attribute.type_("submit"), 91 - attribute.classes([ 92 - #("go-reply-form-button", True), 93 - ]), 94 - ], 95 - comment_article_by_op: [attribute.class("comment comment-by-op")], 96 - comment_article: [attribute.class("comment")], 97 - comment_header: [], 98 - children_section: [], 99 - loading_span: [], 100 - avatar_img: [ 101 - attribute.class("avatar"), 102 - attribute.alt("@"), 103 - ], 104 - error_element: [attribute.class("chilp-error")], 105 - metadata_div: [attribute.class("meta")], 106 - displayname: [attribute.class("display-name")], 107 - written_at: [], 108 - content_section: [attribute.class("content")], 109 - comment_link: [], 110 - comment_footer: [], 111 - widget_subheader: [ 112 - attribute.classes([ 113 - #("subheader", True), 114 - ]), 115 - ], 116 - widget_subheader_link: [ 117 - attribute.classes([ 118 - #("post-link", True), 119 - ]), 120 - ], 121 - go_reply_label: [ 122 - attribute.class("go-reply-label"), 123 - attribute.for("userinstance"), 124 - ], 125 - or_create_an_account_link: [ 126 - attribute.classes([ 127 - #("post-link", True), 128 - ]), 129 - ], 130 - or_create_an_account_disclaimer: [ 131 - attribute.class("or-create-an-account-disclaimer"), 132 - attribute.for("userinstance"), 133 - ], 134 - ) 135 - } 15 + pub fn register() -> Result(Nil, lustre.Error) { 16 + let component = 17 + lustre.component(init, update, view, [ 18 + // Attributes are string values that are set on the component's HTML element. 19 + // We can set up listeners for any attributes we care about and decode them 20 + // into a message for our update function. Any time the parent app changes 21 + // the attribute, this function will run and if it is `Ok` the message will 22 + // be sent to the component's update function. 23 + component.on_attribute_change("postpointer", fn(value) { 24 + value 25 + |> string.split_once(":") 26 + |> result.map(fn(a) { PostChanged(a.0, a.1) }) 27 + }), 28 + ]) 136 29 137 - /// This is what will show your comment block. 138 - pub fn show( 139 - from attributes: CommentWidget(msg), 140 - data model: ChilpDataInYourModel(msg), 141 - ) -> element.Element(msg) { 142 - let model = model.inner 143 - html.div([attribute.class("chilp-widget")], [ 144 - html.div(attributes.widget, [ 145 - html.h1(attributes.widget_header.1, [ 146 - element.text(attributes.widget_header.0), 147 - ]), 148 - case dict.get(model.stati, attributes.post) { 149 - Ok(status) -> 150 - html.p(attributes.widget_subheader, [ 151 - element.text("Linked to "), 152 - html.a( 153 - attributes.widget_subheader_link 154 - |> list.append([attribute.href(status.url)]), 155 - [element.text("this post")], 156 - ), 157 - element.text(" on Mastodon."), 158 - ]) 159 - Error(_) -> element.none() 160 - }, 161 - html.form(attributes.go_reply_form, [ 162 - html.div([attribute.class("input-group")], [ 163 - html.label(attributes.go_reply_label, [ 164 - html.text("Enter your instance adress to reply or "), 165 - html.a( 166 - [ 167 - attribute.href( 168 - "https://" 169 - <> { 170 - attributes.instancelist 171 - |> list.shuffle 172 - |> list.first 173 - |> result.unwrap(attributes.post.instance) 174 - } 175 - <> "/auth/sign_up", 176 - ), 177 - ] 178 - |> list.append(attributes.or_create_an_account_link), 179 - [element.text("create an account")], 180 - ), 181 - element.text("!"), 182 - ]), 183 - html.p(attributes.or_create_an_account_disclaimer, [ 184 - element.text( 185 - "on an instance reccommended by this site... or one you pick yourself!", 186 - ), 187 - ]), 188 - html.div([attribute.class("form-controls")], [ 189 - html.input(attributes.go_reply_text_box), 190 - html.button(attributes.go_reply_button, [element.text("Go reply")]), 191 - ]), 192 - ]), 193 - ]), 194 - case 195 - dict.get(model.busy, attributes.post), 196 - dict.get(model.stati, attributes.post), 197 - dict.get(model.context, attributes.post) 198 - { 199 - _, Ok(status), Ok(context) -> 200 - view_commentlist(attributes, status, context.0) 201 - 202 - Ok(option.None), Error(_), _ | Ok(option.None), _, Error(_) -> 203 - html.span(attributes.loading_span, [ 204 - element.text("Loading comments..."), 205 - ]) 206 - 207 - Ok(option.Some(errorvalue)), Error(_), _ 208 - | Ok(option.Some(errorvalue)), _, Error(_) 209 - if attributes.emit_error == True 210 - -> html.pre(attributes.error_element, [element.text(errorvalue)]) 211 - 212 - // Post is not 'gotten' yet. 213 - _, _, _ -> 214 - html.button(attributes.load_button, [element.text("Load comments")]) 215 - }, 216 - ]), 217 - ]) 30 + lustre.register(component, "comment-widget") 218 31 } 219 32 220 - fn view_commentlist( 221 - attributes: CommentWidget(msg), 222 - status: api_typing.Status, 223 - context: api_typing.StatusContext, 224 - ) { 225 - let sorted_descendants = 226 - context.descendants 227 - |> list.sort(fn(a, b) { 228 - case 229 - timestamp.parse_rfc3339(a.created_at), 230 - timestamp.parse_rfc3339(b.created_at) 231 - { 232 - Ok(a), Ok(b) -> timestamp.compare(a, b) 233 - _, _ -> order.Eq 234 - } 235 - }) 236 - |> list.sort(fn(a, b) { 237 - int.compare(a.favourites_count, b.favourites_count) 238 - }) 239 - html.section( 240 - attributes.comments_section, 241 - list.map(sorted_descendants, fn(comm: api_typing.Status) -> element.Element( 242 - msg, 243 - ) { 244 - render_comment( 245 - attribs: attributes, 246 - comm_id: comm.id, 247 - recursion: 1, 248 - parent: status, 249 - original_parent: status, 250 - sorted_descendants:, 251 - ) 252 - }), 253 - ) 254 - } 255 - 256 - fn render_comment( 257 - attribs attribs: CommentWidget(msg), 258 - comm_id comm_id: String, 259 - recursion recursion: Int, 260 - parent parent: api_typing.Status, 261 - original_parent original_parent: api_typing.Status, 262 - sorted_descendants descendants: List(api_typing.Status), 263 - ) { 264 - let comm_result = list.find(descendants, fn(comm_) { comm_.id == comm_id }) 265 - case comm_result, recursion <= attribs.recursion_limit { 266 - Ok(comm), True if comm.in_reply_to_id == parent.id -> { 267 - let children = case comm.replies_count == 0 { 268 - True -> [] 269 - False -> { 270 - list.filter(descendants, fn(comm_) { comm_.in_reply_to_id == comm.id }) 271 - |> list.map(fn(c) { 272 - render_comment( 273 - attribs:, 274 - comm_id: c.id, 275 - recursion: recursion + 1, 276 - parent: comm, 277 - original_parent:, 278 - sorted_descendants: descendants, 279 - ) 280 - }) 281 - } 282 - } 283 - view_comment( 284 - comm, 285 - // Is comment by op 286 - original_parent.account.id == comm.account.id 287 - && comm.account.note == parent.account.note, 288 - attribs, 289 - children, 290 - ) 291 - } 292 - _, _ -> element.none() 293 - } 294 - } 295 - 296 - fn view_comment( 297 - comment: api_typing.Status, 298 - is_authors: Bool, 299 - attribs: CommentWidget(msg), 300 - children: List(element.Element(msg)), 301 - ) { 302 - html.article( 303 - case is_authors { 304 - True -> attribs.comment_article_by_op 305 - _ -> attribs.comment_article 306 - }, 307 - [ 308 - html.header(attribs.comment_header, [ 309 - html.img( 310 - list.append(attribs.avatar_img, [ 311 - attribute.src(comment.account.avatar), 312 - ]), 313 - ), 314 - html.div(attribs.metadata_div, [ 315 - html.span(attribs.displayname, [ 316 - element.text(comment.account.display_name), 317 - ]), 318 - html.time( 319 - [attribute("datetime", comment.created_at)] 320 - |> list.append(attribs.written_at), 321 - [ 322 - element.text({ 323 - let b = 324 - case 325 - timestamp.difference( 326 - timestamp.parse_rfc3339(comment.created_at) 327 - |> result.unwrap(timestamp.system_time()), 328 - timestamp.system_time(), 329 - ) 330 - |> duration.approximate 331 - |> pair.map_second(fn(d) { 332 - case d { 333 - duration.Nanosecond -> "nanosecond" 334 - duration.Microsecond -> "microsecond" 335 - duration.Millisecond -> "millisecond" 336 - duration.Second -> "second" 337 - duration.Minute -> "minute" 338 - duration.Hour -> "hour" 339 - duration.Day -> "day" 340 - duration.Week -> "week" 341 - duration.Month -> "month" 342 - duration.Year -> "year" 343 - } 344 - }) 345 - { 346 - #(1, x) -> #(1, x) 347 - #(x, d) -> #(x, d <> "s") 348 - } 349 - |> pair.map_first(int.to_string) 350 - 351 - b.0 <> " " <> b.1 <> " ago." 352 - }), 353 - ], 354 - ), 355 - ]), 356 - ]), 357 - html.section(attribs.content_section, [ 358 - element.unsafe_raw_html("", "span", [], comment.content |> sanitize), 359 - ]), 360 - html.footer([], [ 361 - html.a( 362 - [attribute.href(comment.url)] |> list.append(attribs.comment_link), 363 - [ 364 - element.text("View comment on Mastodon"), 365 - ], 366 - ), 367 - html.section(attribs.children_section, children), 368 - ]), 369 - ], 33 + pub fn element(instance instance: String, post_id post: String) -> Element(msg) { 34 + element.element( 35 + "comment-widget", 36 + [attribute.attribute("postpointer", instance <> ":" <> post)], 37 + [], 370 38 ) 371 39 } 372 40 373 - @external(javascript, "./purify_bind_ffi.mjs", "sanitize") 374 - fn sanitize(html: String) -> String { 375 - // On erlang, there's a lot less risk. 376 - html 377 - } 41 + // MODEL ----------------------------------------------------------------------- 378 42 379 - /// Allows you to edit your widget, you can replace or append to any values here. 380 - /// Do note, removing stuff might remove functionality! 381 - /// By default, some Tailwind/DaisyUI classes are added. 382 - pub type CommentWidget(msg) { 383 - CommentWidget( 384 - /// The post this widget is for, you should just keep this. 385 - post: MastodonPost, 386 - /// Limit on comment depth. 387 - recursion_limit: Int, 388 - /// On error, print the error to the DOM? 389 - emit_error: Bool, 390 - /// Widget header value, by default "Comments", and it's attributes 391 - widget_header: #(String, List(attribute.Attribute(msg))), 392 - /// The top element of the widget itself. 393 - widget: List(attribute.Attribute(msg)), 394 - /// [Load comments]-button 395 - load_button: List(attribute.Attribute(msg)), 396 - /// The actual area the comments show up in 397 - comments_section: List(attribute.Attribute(msg)), 398 - children_section: List(attribute.Attribute(msg)), 399 - go_reply_form: List(attribute.Attribute(msg)), 400 - go_reply_label: List(attribute.Attribute(msg)), 401 - go_reply_text_box: List(attribute.Attribute(msg)), 402 - go_reply_button: List(attribute.Attribute(msg)), 403 - /// Applied to the <header> area of a comment. 404 - comment_article: List(attribute.Attribute(msg)), 405 - /// Applied to the <header> area of a comment posted by the parent's poster. 406 - comment_article_by_op: List(attribute.Attribute(msg)), 407 - comment_header: List(attribute.Attribute(msg)), 408 - loading_span: List(attribute.Attribute(msg)), 409 - avatar_img: List(attribute.Attribute(msg)), 410 - error_element: List(attribute.Attribute(msg)), 411 - metadata_div: List(attribute.Attribute(msg)), 412 - displayname: List(attribute.Attribute(msg)), 413 - written_at: List(attribute.Attribute(msg)), 414 - content_section: List(attribute.Attribute(msg)), 415 - comment_link: List(attribute.Attribute(msg)), 416 - /// Footer of the comment, containing the comment url and comment's children. 417 - comment_footer: List(attribute.Attribute(msg)), 418 - widget_subheader: List(attribute.Attribute(msg)), 419 - widget_subheader_link: List(attribute.Attribute(msg)), 420 - or_create_an_account_link: List(attribute.Attribute(msg)), 421 - or_create_an_account_disclaimer: List(attribute.Attribute(msg)), 422 - /// Used to randomnise the 'Or create an account' link. 423 - instancelist: List(String), 43 + type WrappingModel { 44 + WrappingModelSet( 45 + chilp_model: widget.ChilpDataInYourModel(Msg), 46 + widget: widget.CommentWidget(Msg), 424 47 ) 48 + WrappingModelUnset(chilp_model: widget.ChilpDataInYourModel(Msg)) 425 49 } 426 50 427 - /// Trigger forces the widget to load in data before the user clicked the button. 428 - /// This is something you'll want if you know beforehand which post comments to display. 429 - pub fn trigger( 430 - on on: CommentWidget(msg), 431 - chilp_model model: ChilpDataInYourModel(msg), 432 - ) -> msg { 433 - model.message(Get(on.post)) 51 + fn init(_) -> #(WrappingModel, Effect(Msg)) { 52 + #(WrappingModelUnset(widget.init(ChilpMsgWrapper)), effect.none()) 434 53 } 435 54 436 - /// Force is like `trigger`, except returns the Effect instead of the message, allowing you to embed it in your init or update function instead of in your view. 437 - /// This is something you'll want if you know beforehand which post comments to display. 438 - pub fn force( 439 - on on: CommentWidget(msg), 440 - chilp_model model: ChilpDataInYourModel(msg), 441 - ) { 442 - get(on.post, model) 443 - } 55 + // UPDATE ---------------------------------------------------------------------- 444 56 445 - /// This stores metadata that is handled internally by Chilp 446 - /// You should store this on your model! 447 - pub opaque type ChilpDataInYourModel(msg) { 448 - ChilpDataInYourModel(message: fn(ChilpMsg) -> msg, inner: ChilpModel) 57 + type Msg { 58 + PostChanged(instance: String, post: String) 59 + ChilpMsgWrapper(widget.ChilpMsg) 449 60 } 450 61 451 - pub opaque type ChilpModel { 452 - ChilpModel( 453 - stati: dict.Dict(MastodonPost, api_typing.Status), 454 - context: dict.Dict(MastodonPost, #(api_typing.StatusContext, Float)), 455 - busy: dict.Dict(MastodonPost, option.Option(String)), 456 - ) 457 - } 458 - 459 - pub fn init(message message: fn(ChilpMsg) -> msg) { 460 - ChilpDataInYourModel( 461 - message:, 462 - inner: ChilpModel(stati: dict.new(), context: dict.new(), busy: dict.new()), 463 - ) 464 - } 465 - 466 - /// Chilp's widget needs to be able to send these messages to your update function, and you should handle them with `chilp/widget.update(ChilpMsg)`. 467 - pub opaque type ChilpMsg { 468 - Get(MastodonPost) 469 - Save(ChilpModel) 470 - GoAnswer(instance: String, to: MastodonPost) 471 - } 472 - 473 - /// Gets all the metadata to work with in order to show your comments! 474 - fn get( 475 - post: MastodonPost, 476 - data: ChilpDataInYourModel(msg), 477 - ) -> effect.Effect(msg) { 478 - let handles = fn(m) { data.message(Save(m)) } 479 - // Tell `show()` we're on it. 480 - let notify = fn() { 481 - effect.from(fn(dispatch) { 482 - dispatch( 483 - handles(ChilpModel( 484 - stati: dict.new(), 485 - context: dict.new(), 486 - busy: dict.from_list([#(post, option.None)]), 487 - )), 62 + fn update(model: WrappingModel, msg: Msg) -> #(WrappingModel, Effect(Msg)) { 63 + case msg { 64 + PostChanged(instance, post) -> { 65 + let chilp_model = model.chilp_model 66 + let widget = widget.new(instance, post, chilp_model) 67 + #( 68 + WrappingModelSet(widget:, chilp_model:), 69 + widget.force(widget, chilp_model:), 488 70 ) 489 - }) 490 - } 491 - effect.batch([ 492 - notify(), 493 - get_post(post, handles), 494 - get_context(post, handles), 495 - ]) 496 - } 497 - 498 - fn get_post( 499 - post: MastodonPost, 500 - message: fn(ChilpModel) -> msg, 501 - ) -> effect.Effect(msg) { 502 - let url = "https://" <> post.instance <> "/api/v1/statuses/" <> post.postid 503 - let handle_response = fn(s) { 504 - case s { 505 - Ok(status) -> { 506 - ChilpModel( 507 - stati: dict.from_list([#(post, status)]), 508 - context: dict.new(), 509 - busy: dict.new(), 510 - ) 511 - } 512 - Error(e) -> 513 - ChilpModel( 514 - dict.new(), 515 - dict.new(), 516 - dict.from_list([ 517 - #( 518 - post, 519 - option.Some(string.inspect(e) <> "\n\nWhile looking at: " <> url), 520 - ), 521 - ]), 522 - ) 523 71 } 524 - |> message 525 - } 526 - let handler = rsvp.expect_json(api_typing.status_decoder(), handle_response) 527 - rsvp.get(url, handler) 528 - } 529 - 530 - fn get_context( 531 - post: MastodonPost, 532 - message: fn(ChilpModel) -> msg, 533 - ) -> effect.Effect(msg) { 534 - let url = 535 - "https://" 536 - <> post.instance 537 - <> "/api/v1/statuses/" 538 - <> post.postid 539 - <> "/context" 540 - let handle_response = fn(c) { 541 - case c { 542 - Ok(context) -> { 543 - let now = timestamp.system_time() |> timestamp.to_unix_seconds() 544 - ChilpModel( 545 - stati: dict.new(), 546 - context: dict.from_list([#(post, #(context, now))]), 547 - busy: dict.new(), 548 - ) 72 + ChilpMsgWrapper(message) -> { 73 + let #(chilp_model, effects) = 74 + widget.update(message, model.chilp_model, browse) 75 + let new_model = case model { 76 + WrappingModelSet(_, widget) -> WrappingModelSet(chilp_model:, widget:) 77 + WrappingModelUnset(_) -> WrappingModelUnset(chilp_model:) 549 78 } 550 - Error(e) -> 551 - ChilpModel( 552 - dict.new(), 553 - dict.new(), 554 - dict.from_list([ 555 - #( 556 - post, 557 - option.Some( 558 - string.inspect(e) 559 - <> "\n\nWhile looking at: " 560 - <> url 561 - <> "\n\nWant to report this? File an issue ", 562 - ), 563 - ), 564 - ]), 565 - ) 79 + #(new_model, effects) 566 80 } 567 - |> message 568 81 } 82 + } 569 83 570 - let handler = 571 - rsvp.expect_json(api_typing.status_context_decoder(), handle_response) 572 - rsvp.get(url, handler) 84 + @external(javascript, "./ffi.mjs", "lassign") 85 + fn js_browse(_: String) -> Nil { 86 + Nil 573 87 } 574 88 575 - /// The update handler for chilp-specific messages! 576 - /// 577 - /// It takes in three values: 578 - /// - `message`: The message it handles 579 - /// - `model`: the chilp data from your model 580 - /// - `change_url`: A side-effect! This may not ever be called, but when it does, know that it should take that string, and browse the user to it. 581 - pub fn update( 582 - message: ChilpMsg, 583 - model: ChilpDataInYourModel(msg), 584 - change_url: fn(String) -> effect.Effect(msg), 585 - ) -> #(ChilpDataInYourModel(msg), effect.Effect(msg)) { 586 - case message { 587 - Get(post) -> { 588 - #(model, get(post, model)) 589 - } 590 - Save(addedmodel) -> { 591 - let #(o_stati, o_context) = uncloth(model.inner) 592 - let #(n_stati, n_context) = uncloth(addedmodel) 593 - let stati = list.append(o_stati, n_stati) |> dict.from_list 594 - // I just loved overcomplicating it too much. 595 - let context = list.append(o_context, n_context) |> dict.from_list 89 + @external(javascript, "./ffi.mjs", "inline_styles") 90 + fn js_inline_styles() -> String { 91 + "" 92 + } 596 93 597 - let busy = dict.combine(addedmodel.busy, model.inner.busy, option.or) 94 + fn browse(to: String) { 95 + let Nil = js_browse(to) 96 + effect.none() 97 + } 598 98 599 - #( 600 - ChilpDataInYourModel( 601 - ..model, 602 - inner: ChilpModel(stati:, context:, busy:), 603 - ), 604 - effect.none(), 605 - ) 606 - } 607 - GoAnswer(instance:, to:) -> { 608 - let s = dict.get(model.inner.stati, to) 609 - case s { 610 - Ok(post) -> { 611 - change_url({ 612 - "https://" 613 - <> instance 614 - <> "/authorize_interaction?uri=" 615 - <> { post.url |> uri.percent_encode } 616 - }) 617 - #(model, effect.none()) 618 - } 619 - Error(_) -> #(model, effect.none()) 620 - } 621 - } 622 - } 623 - } 99 + // VIEW ------------------------------------------------------------------------ 624 100 625 - fn uncloth( 626 - m: ChilpModel, 627 - ) -> #( 628 - List(#(MastodonPost, api_typing.Status)), 629 - List(#(MastodonPost, #(api_typing.StatusContext, Float))), 630 - ) { 631 - case m { 632 - ChilpModel(stati:, context:, ..) -> { 633 - #(dict.to_list(stati), dict.to_list(context)) 634 - } 635 - } 101 + fn view(model: WrappingModel) -> Element(Msg) { 102 + html.div([attribute.class("chilp-widget-component")], [ 103 + html.style([], js_inline_styles()), 104 + case model { 105 + WrappingModelSet(chilp_model:, widget:) -> 106 + widget.show(from: widget, data: chilp_model) 107 + WrappingModelUnset(chilp_model: _) -> element.none() 108 + }, 109 + ]) 636 110 }
+650
src/chilp/widget/base.gleam
··· 1 + //// No-component widget 2 + //// This is the Mastodon Widget except much more customisable. This version might also influence your application logic a bit much. 3 + //// If you really want to customise, use this module, otherwise default to 4 + 5 + import chilp/api_typing 6 + import gleam/dict 7 + import gleam/int 8 + import gleam/list 9 + import gleam/option 10 + import gleam/order 11 + import gleam/pair 12 + import gleam/result 13 + import gleam/string 14 + import gleam/time/duration 15 + import gleam/time/timestamp 16 + import gleam/uri 17 + import lustre/attribute.{attribute} 18 + import lustre/effect 19 + import lustre/element 20 + import lustre/element/html 21 + import lustre/event 22 + import rsvp 23 + 24 + pub opaque type MastodonPost { 25 + MastodonPost(instance: String, postid: String) 26 + } 27 + 28 + /// Creates a comment widget, this is where you should probably start! 29 + /// 30 + /// This function takes three arguments: 31 + /// - `instance`: The instance name, e.g. mastodon.social 32 + /// - `postid`: A post id to bind to, you'll find this in a post url `https://mastodon.social/@<username>/[postid]`. 33 + /// - `messages`: Some messages that chilp needs to be able to send 34 + /// The resulting comment widget can be edited however you'd like, but is 35 + pub fn new( 36 + instance instance: String, 37 + post_id postid: String, 38 + chilp_model model: ChilpDataInYourModel(msg), 39 + ) -> CommentWidget(msg) { 40 + construct_new(instance, postid, model.message) 41 + } 42 + 43 + @internal 44 + pub fn construct_new( 45 + instance: String, 46 + postid: String, 47 + message_wrap: fn(ChilpMsg) -> a, 48 + ) -> CommentWidget(a) { 49 + let instancelist = [ 50 + instance, 51 + "mastodon.social", 52 + instance, 53 + "pony.social", 54 + instance, 55 + "todon.nl", 56 + instance, 57 + "mstdn.social", 58 + instance, 59 + ] 60 + let instanceplaceholder = { 61 + instancelist 62 + |> list.shuffle 63 + |> list.first 64 + |> result.unwrap("myinstance.social") 65 + } 66 + let post = MastodonPost(instance:, postid:) 67 + let set_message_get = Get(post) |> message_wrap 68 + let go_answer = fn(n) { 69 + let value = 70 + list.key_find(n, "userinstance") 71 + |> result.unwrap(instanceplaceholder) 72 + GoAnswer(value, post) |> message_wrap 73 + } 74 + CommentWidget( 75 + post:, 76 + instancelist:, 77 + recursion_limit: 3, 78 + emit_error: True, 79 + widget_header: #("Comments", [ 80 + attribute.classes([#("widget-header h1", True)]), 81 + ]), 82 + widget: [ 83 + attribute.classes([#("widget", True)]), 84 + ], 85 + load_button: [ 86 + event.on_click(set_message_get), 87 + attribute.classes([#("btn-get-comments", True)]), 88 + ], 89 + comments_section: [], 90 + go_reply_form: [ 91 + event.on_submit(go_answer), 92 + attribute.classes([#("go-reply-form", True)]), 93 + ], 94 + go_reply_text_box: [ 95 + attribute.type_("text"), 96 + attribute.placeholder(instanceplaceholder), 97 + attribute.name("userinstance"), 98 + attribute.classes([#("go-reply-form-input", True)]), 99 + attribute.pattern("^([a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}$"), 100 + attribute.required(True), 101 + ], 102 + go_reply_button: [ 103 + attribute.type_("submit"), 104 + attribute.classes([ 105 + #("go-reply-form-button", True), 106 + ]), 107 + ], 108 + comment_article_by_op: [attribute.class("comment comment-by-op")], 109 + comment_article: [attribute.class("comment")], 110 + comment_header: [], 111 + children_section: [], 112 + loading_span: [], 113 + avatar_img: [ 114 + attribute.class("avatar"), 115 + attribute.alt("@"), 116 + ], 117 + error_element: [attribute.class("chilp-error")], 118 + metadata_div: [attribute.class("meta")], 119 + displayname: [attribute.class("display-name")], 120 + written_at: [], 121 + content_section: [attribute.class("content")], 122 + comment_link: [], 123 + comment_footer: [], 124 + widget_subheader: [ 125 + attribute.classes([ 126 + #("subheader", True), 127 + ]), 128 + ], 129 + widget_subheader_link: [ 130 + attribute.classes([ 131 + #("post-link", True), 132 + ]), 133 + ], 134 + go_reply_label: [ 135 + attribute.class("go-reply-label"), 136 + attribute.for("userinstance"), 137 + ], 138 + or_create_an_account_link: [ 139 + attribute.classes([ 140 + #("post-link", True), 141 + ]), 142 + ], 143 + or_create_an_account_disclaimer: [ 144 + attribute.class("or-create-an-account-disclaimer"), 145 + attribute.for("userinstance"), 146 + ], 147 + ) 148 + } 149 + 150 + /// This is what will show your comment block. 151 + pub fn show( 152 + from attributes: CommentWidget(msg), 153 + data model: ChilpDataInYourModel(msg), 154 + ) -> element.Element(msg) { 155 + let model = model.inner 156 + html.div([attribute.class("chilp-widget")], [ 157 + html.div(attributes.widget, [ 158 + html.h1(attributes.widget_header.1, [ 159 + element.text(attributes.widget_header.0), 160 + ]), 161 + case dict.get(model.stati, attributes.post) { 162 + Ok(status) -> 163 + html.p(attributes.widget_subheader, [ 164 + element.text("Linked to "), 165 + html.a( 166 + attributes.widget_subheader_link 167 + |> list.append([attribute.href(status.url)]), 168 + [element.text("this post")], 169 + ), 170 + element.text(" on Mastodon."), 171 + ]) 172 + Error(_) -> element.none() 173 + }, 174 + html.form(attributes.go_reply_form, [ 175 + html.div([attribute.class("input-group")], [ 176 + html.label(attributes.go_reply_label, [ 177 + html.text("Enter your instance adress to reply or "), 178 + html.a( 179 + [ 180 + attribute.href( 181 + "https://" 182 + <> { 183 + attributes.instancelist 184 + |> list.shuffle 185 + |> list.first 186 + |> result.unwrap(attributes.post.instance) 187 + } 188 + <> "/auth/sign_up", 189 + ), 190 + ] 191 + |> list.append(attributes.or_create_an_account_link), 192 + [element.text("create an account")], 193 + ), 194 + element.text("!"), 195 + ]), 196 + html.p(attributes.or_create_an_account_disclaimer, [ 197 + element.text( 198 + "on an instance reccommended by this site... or one you pick yourself!", 199 + ), 200 + ]), 201 + html.div([attribute.class("form-controls")], [ 202 + html.input(attributes.go_reply_text_box), 203 + html.button(attributes.go_reply_button, [element.text("Go reply")]), 204 + ]), 205 + ]), 206 + ]), 207 + case 208 + dict.get(model.busy, attributes.post), 209 + dict.get(model.stati, attributes.post), 210 + dict.get(model.context, attributes.post) 211 + { 212 + _, Ok(status), Ok(context) -> 213 + view_commentlist(attributes, status, context.0) 214 + 215 + Ok(option.None), Error(_), _ | Ok(option.None), _, Error(_) -> 216 + html.span(attributes.loading_span, [ 217 + element.text("Loading comments..."), 218 + ]) 219 + 220 + Ok(option.Some(errorvalue)), Error(_), _ 221 + | Ok(option.Some(errorvalue)), _, Error(_) 222 + if attributes.emit_error == True 223 + -> html.pre(attributes.error_element, [element.text(errorvalue)]) 224 + 225 + // Post is not 'gotten' yet. 226 + _, _, _ -> 227 + html.button(attributes.load_button, [element.text("Load comments")]) 228 + }, 229 + ]), 230 + ]) 231 + } 232 + 233 + fn view_commentlist( 234 + attributes: CommentWidget(msg), 235 + status: api_typing.Status, 236 + context: api_typing.StatusContext, 237 + ) { 238 + let sorted_descendants = 239 + context.descendants 240 + |> list.sort(fn(a, b) { 241 + case 242 + timestamp.parse_rfc3339(a.created_at), 243 + timestamp.parse_rfc3339(b.created_at) 244 + { 245 + Ok(a), Ok(b) -> timestamp.compare(a, b) 246 + _, _ -> order.Eq 247 + } 248 + }) 249 + |> list.sort(fn(a, b) { 250 + int.compare(a.favourites_count, b.favourites_count) 251 + }) 252 + html.section( 253 + attributes.comments_section, 254 + list.map(sorted_descendants, fn(comm: api_typing.Status) -> element.Element( 255 + msg, 256 + ) { 257 + render_comment( 258 + attribs: attributes, 259 + comm_id: comm.id, 260 + recursion: 1, 261 + parent: status, 262 + original_parent: status, 263 + sorted_descendants:, 264 + ) 265 + }), 266 + ) 267 + } 268 + 269 + fn render_comment( 270 + attribs attribs: CommentWidget(msg), 271 + comm_id comm_id: String, 272 + recursion recursion: Int, 273 + parent parent: api_typing.Status, 274 + original_parent original_parent: api_typing.Status, 275 + sorted_descendants descendants: List(api_typing.Status), 276 + ) { 277 + let comm_result = list.find(descendants, fn(comm_) { comm_.id == comm_id }) 278 + case comm_result, recursion <= attribs.recursion_limit { 279 + Ok(comm), True if comm.in_reply_to_id == parent.id -> { 280 + let children = case comm.replies_count == 0 { 281 + True -> [] 282 + False -> { 283 + list.filter(descendants, fn(comm_) { comm_.in_reply_to_id == comm.id }) 284 + |> list.map(fn(c) { 285 + render_comment( 286 + attribs:, 287 + comm_id: c.id, 288 + recursion: recursion + 1, 289 + parent: comm, 290 + original_parent:, 291 + sorted_descendants: descendants, 292 + ) 293 + }) 294 + } 295 + } 296 + view_comment( 297 + comm, 298 + // Is comment by op 299 + original_parent.account.id == comm.account.id 300 + && comm.account.note == parent.account.note, 301 + attribs, 302 + children, 303 + ) 304 + } 305 + _, _ -> element.none() 306 + } 307 + } 308 + 309 + fn view_comment( 310 + comment: api_typing.Status, 311 + is_authors: Bool, 312 + attribs: CommentWidget(msg), 313 + children: List(element.Element(msg)), 314 + ) { 315 + html.article( 316 + case is_authors { 317 + True -> attribs.comment_article_by_op 318 + _ -> attribs.comment_article 319 + }, 320 + [ 321 + html.header(attribs.comment_header, [ 322 + html.img( 323 + list.append(attribs.avatar_img, [ 324 + attribute.src(comment.account.avatar), 325 + ]), 326 + ), 327 + html.div(attribs.metadata_div, [ 328 + html.span(attribs.displayname, [ 329 + element.text(comment.account.display_name), 330 + ]), 331 + html.time( 332 + [attribute("datetime", comment.created_at)] 333 + |> list.append(attribs.written_at), 334 + [ 335 + element.text({ 336 + let b = 337 + case 338 + timestamp.difference( 339 + timestamp.parse_rfc3339(comment.created_at) 340 + |> result.unwrap(timestamp.system_time()), 341 + timestamp.system_time(), 342 + ) 343 + |> duration.approximate 344 + |> pair.map_second(fn(d) { 345 + case d { 346 + duration.Nanosecond -> "nanosecond" 347 + duration.Microsecond -> "microsecond" 348 + duration.Millisecond -> "millisecond" 349 + duration.Second -> "second" 350 + duration.Minute -> "minute" 351 + duration.Hour -> "hour" 352 + duration.Day -> "day" 353 + duration.Week -> "week" 354 + duration.Month -> "month" 355 + duration.Year -> "year" 356 + } 357 + }) 358 + { 359 + #(1, x) -> #(1, x) 360 + #(x, d) -> #(x, d <> "s") 361 + } 362 + |> pair.map_first(int.to_string) 363 + 364 + b.0 <> " " <> b.1 <> " ago." 365 + }), 366 + ], 367 + ), 368 + ]), 369 + ]), 370 + html.section(attribs.content_section, [ 371 + element.unsafe_raw_html("", "span", [], comment.content |> sanitize), 372 + ]), 373 + html.footer([], [ 374 + html.a( 375 + [attribute.href(comment.url)] |> list.append(attribs.comment_link), 376 + [ 377 + element.text("View comment on Mastodon"), 378 + ], 379 + ), 380 + html.section(attribs.children_section, children), 381 + ]), 382 + ], 383 + ) 384 + } 385 + 386 + @external(javascript, "../ffi.mjs", "sanitize") 387 + fn sanitize(html: String) -> String { 388 + // On erlang, there's a lot less risk. 389 + html 390 + } 391 + 392 + /// Allows you to edit your widget, you can replace or append to any values here. 393 + /// Do note, removing stuff might remove functionality! 394 + /// By default, some Tailwind/DaisyUI classes are added. 395 + pub type CommentWidget(msg) { 396 + CommentWidget( 397 + /// The post this widget is for, you should just keep this. 398 + post: MastodonPost, 399 + /// Limit on comment depth. 400 + recursion_limit: Int, 401 + /// On error, print the error to the DOM? 402 + emit_error: Bool, 403 + /// Widget header value, by default "Comments", and it's attributes 404 + widget_header: #(String, List(attribute.Attribute(msg))), 405 + /// The top element of the widget itself. 406 + widget: List(attribute.Attribute(msg)), 407 + /// [Load comments]-button 408 + load_button: List(attribute.Attribute(msg)), 409 + /// The actual area the comments show up in 410 + comments_section: List(attribute.Attribute(msg)), 411 + children_section: List(attribute.Attribute(msg)), 412 + go_reply_form: List(attribute.Attribute(msg)), 413 + go_reply_label: List(attribute.Attribute(msg)), 414 + go_reply_text_box: List(attribute.Attribute(msg)), 415 + go_reply_button: List(attribute.Attribute(msg)), 416 + /// Applied to the <header> area of a comment. 417 + comment_article: List(attribute.Attribute(msg)), 418 + /// Applied to the <header> area of a comment posted by the parent's poster. 419 + comment_article_by_op: List(attribute.Attribute(msg)), 420 + comment_header: List(attribute.Attribute(msg)), 421 + loading_span: List(attribute.Attribute(msg)), 422 + avatar_img: List(attribute.Attribute(msg)), 423 + error_element: List(attribute.Attribute(msg)), 424 + metadata_div: List(attribute.Attribute(msg)), 425 + displayname: List(attribute.Attribute(msg)), 426 + written_at: List(attribute.Attribute(msg)), 427 + content_section: List(attribute.Attribute(msg)), 428 + comment_link: List(attribute.Attribute(msg)), 429 + /// Footer of the comment, containing the comment url and comment's children. 430 + comment_footer: List(attribute.Attribute(msg)), 431 + widget_subheader: List(attribute.Attribute(msg)), 432 + widget_subheader_link: List(attribute.Attribute(msg)), 433 + or_create_an_account_link: List(attribute.Attribute(msg)), 434 + or_create_an_account_disclaimer: List(attribute.Attribute(msg)), 435 + /// Used to randomnise the 'Or create an account' link. 436 + instancelist: List(String), 437 + ) 438 + } 439 + 440 + /// Trigger forces the widget to load in data before the user clicked the button. 441 + /// This is something you'll want if you know beforehand which post comments to display. 442 + pub fn trigger( 443 + on on: CommentWidget(msg), 444 + chilp_model model: ChilpDataInYourModel(msg), 445 + ) -> msg { 446 + model.message(Get(on.post)) 447 + } 448 + 449 + /// Force is like `trigger`, except returns the Effect instead of the message, allowing you to embed it in your init or update function instead of in your view. 450 + /// This is something you'll want if you know beforehand which post comments to display. 451 + pub fn force( 452 + on on: CommentWidget(msg), 453 + chilp_model model: ChilpDataInYourModel(msg), 454 + ) { 455 + get(on.post, model) 456 + } 457 + 458 + /// This stores metadata that is handled internally by Chilp 459 + /// You should store this on your model! 460 + /// 461 + pub opaque type ChilpDataInYourModel(msg) { 462 + ChilpDataInYourModel(message: fn(ChilpMsg) -> msg, inner: ChilpModel) 463 + } 464 + 465 + pub opaque type ChilpModel { 466 + ChilpModel( 467 + stati: dict.Dict(MastodonPost, api_typing.Status), 468 + context: dict.Dict(MastodonPost, #(api_typing.StatusContext, Float)), 469 + busy: dict.Dict(MastodonPost, option.Option(String)), 470 + ) 471 + } 472 + 473 + pub fn init(message message: fn(ChilpMsg) -> msg) { 474 + ChilpDataInYourModel( 475 + message:, 476 + inner: ChilpModel(stati: dict.new(), context: dict.new(), busy: dict.new()), 477 + ) 478 + } 479 + 480 + /// Chilp's widget needs to be able to send these messages to your update function, and you should handle them with `chilp/widget.update(ChilpMsg)`. 481 + pub opaque type ChilpMsg { 482 + Get(MastodonPost) 483 + Save(ChilpModel) 484 + GoAnswer(instance: String, to: MastodonPost) 485 + } 486 + 487 + /// Gets all the metadata to work with in order to show your comments! 488 + fn get( 489 + post: MastodonPost, 490 + data: ChilpDataInYourModel(msg), 491 + ) -> effect.Effect(msg) { 492 + let handles = fn(m) { data.message(Save(m)) } 493 + // Tell `show()` we're on it. 494 + let notify = fn() { 495 + effect.from(fn(dispatch) { 496 + dispatch( 497 + handles(ChilpModel( 498 + stati: dict.new(), 499 + context: dict.new(), 500 + busy: dict.from_list([#(post, option.None)]), 501 + )), 502 + ) 503 + }) 504 + } 505 + effect.batch([ 506 + notify(), 507 + get_post(post, handles), 508 + get_context(post, handles), 509 + ]) 510 + } 511 + 512 + fn get_post( 513 + post: MastodonPost, 514 + message: fn(ChilpModel) -> msg, 515 + ) -> effect.Effect(msg) { 516 + let url = "https://" <> post.instance <> "/api/v1/statuses/" <> post.postid 517 + let handle_response = fn(s) { 518 + case s { 519 + Ok(status) -> { 520 + ChilpModel( 521 + stati: dict.from_list([#(post, status)]), 522 + context: dict.new(), 523 + busy: dict.new(), 524 + ) 525 + } 526 + Error(e) -> 527 + ChilpModel( 528 + dict.new(), 529 + dict.new(), 530 + dict.from_list([ 531 + #( 532 + post, 533 + option.Some(string.inspect(e) <> "\n\nWhile looking at: " <> url), 534 + ), 535 + ]), 536 + ) 537 + } 538 + |> message 539 + } 540 + let handler = rsvp.expect_json(api_typing.status_decoder(), handle_response) 541 + rsvp.get(url, handler) 542 + } 543 + 544 + fn get_context( 545 + post: MastodonPost, 546 + message: fn(ChilpModel) -> msg, 547 + ) -> effect.Effect(msg) { 548 + let url = 549 + "https://" 550 + <> post.instance 551 + <> "/api/v1/statuses/" 552 + <> post.postid 553 + <> "/context" 554 + let handle_response = fn(c) { 555 + case c { 556 + Ok(context) -> { 557 + let now = timestamp.system_time() |> timestamp.to_unix_seconds() 558 + ChilpModel( 559 + stati: dict.new(), 560 + context: dict.from_list([#(post, #(context, now))]), 561 + busy: dict.new(), 562 + ) 563 + } 564 + Error(e) -> 565 + ChilpModel( 566 + dict.new(), 567 + dict.new(), 568 + dict.from_list([ 569 + #( 570 + post, 571 + option.Some( 572 + string.inspect(e) 573 + <> "\n\nWhile looking at: " 574 + <> url 575 + <> "\n\nWant to report this? File an issue ", 576 + ), 577 + ), 578 + ]), 579 + ) 580 + } 581 + |> message 582 + } 583 + 584 + let handler = 585 + rsvp.expect_json(api_typing.status_context_decoder(), handle_response) 586 + rsvp.get(url, handler) 587 + } 588 + 589 + /// The update handler for chilp-specific messages! 590 + /// 591 + /// It takes in three values: 592 + /// - `message`: The message it handles 593 + /// - `model`: the chilp data from your model 594 + /// - `change_url`: A side-effect! This may not ever be called, but when it does, know that it should take that string, and browse the user to it. 595 + pub fn update( 596 + message: ChilpMsg, 597 + model: ChilpDataInYourModel(msg), 598 + change_url: fn(String) -> effect.Effect(msg), 599 + ) -> #(ChilpDataInYourModel(msg), effect.Effect(msg)) { 600 + case message { 601 + Get(post) -> { 602 + #(model, get(post, model)) 603 + } 604 + Save(addedmodel) -> { 605 + let #(o_stati, o_context) = uncloth(model.inner) 606 + let #(n_stati, n_context) = uncloth(addedmodel) 607 + let stati = list.append(o_stati, n_stati) |> dict.from_list 608 + // I just loved overcomplicating it too much. 609 + let context = list.append(o_context, n_context) |> dict.from_list 610 + 611 + let busy = dict.combine(addedmodel.busy, model.inner.busy, option.or) 612 + 613 + #( 614 + ChilpDataInYourModel( 615 + ..model, 616 + inner: ChilpModel(stati:, context:, busy:), 617 + ), 618 + effect.none(), 619 + ) 620 + } 621 + GoAnswer(instance:, to:) -> { 622 + let s = dict.get(model.inner.stati, to) 623 + case s { 624 + Ok(post) -> { 625 + change_url({ 626 + "https://" 627 + <> instance 628 + <> "/authorize_interaction?uri=" 629 + <> { post.url |> uri.percent_encode } 630 + }) 631 + #(model, effect.none()) 632 + } 633 + Error(_) -> #(model, effect.none()) 634 + } 635 + } 636 + } 637 + } 638 + 639 + fn uncloth( 640 + m: ChilpModel, 641 + ) -> #( 642 + List(#(MastodonPost, api_typing.Status)), 643 + List(#(MastodonPost, #(api_typing.StatusContext, Float))), 644 + ) { 645 + case m { 646 + ChilpModel(stati:, context:, ..) -> { 647 + #(dict.to_list(stati), dict.to_list(context)) 648 + } 649 + } 650 + }