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.

Got a decent bit working.

+751 -14
+6
README.md
··· 22 22 gleam run # Run the project 23 23 gleam test # Run the tests 24 24 ``` 25 + 26 + And sometimes, maybe. 27 + ```sh 28 + curl https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js -osrc/vendored-purify.min.js 29 + curl https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js.map -osrc/purify.min.js.map 30 + ```
+8
examples/lustre_chilp_app/.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump 5 + 6 + #Added automatically by Lustre Dev Tools 7 + /.lustre 8 + /dist
+23
examples/lustre_chilp_app/gleam.toml
··· 1 + name = "lustre_chilp_app" 2 + version = "1.0.0" 3 + 4 + # Fill out these fields if you intend to generate HTML documentation or publish 5 + # your project to the Hex package manager. 6 + # 7 + # description = "" 8 + # licences = ["Apache-2.0"] 9 + # repository = { type = "github", user = "", repo = "" } 10 + # links = [{ title = "Website", href = "" }] 11 + # 12 + # For a full reference of all the available options, you can have a look at 13 + # https://gleam.run/writing-gleam/gleam-toml/. 14 + 15 + [dependencies] 16 + gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 + chilp = {path = "../.."} 18 + lustre = ">= 5.5.2 and < 6.0.0" 19 + glentities = ">= 6.2.1 and < 7.0.0" 20 + 21 + [dev-dependencies] 22 + gleeunit = ">= 1.0.0 and < 2.0.0" 23 + lustre_dev_tools = ">= 2.3.4 and < 3.0.0"
+57
examples/lustre_chilp_app/manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 + { name = "booklet", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "booklet", source = "hex", outer_checksum = "08E0FDB78DC4D8A5D3C80295B021505C7D2A2E7B6C6D5EAB7286C36F4A53C851" }, 7 + { name = "chilp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_time", "glentities", "lustre", "rsvp"], source = "local", path = "../.." }, 8 + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, 9 + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, 10 + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, 11 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 12 + { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, 13 + { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, 14 + { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, 15 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 16 + { name = "gleam_fetch", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "2CBF9F2E1C71AEBBFB13A9D5720CD8DB4263EB02FE60C5A7A1C6E17B0151C20C" }, 17 + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 18 + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, 19 + { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, 20 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 21 + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 22 + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 23 + { name = "gleam_stdlib", version = "0.68.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F7FAEBD8EF260664E86A46C8DBA23508D1D11BB3BCC6EE1B89B3BC3E5C83FF1E" }, 24 + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, 25 + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 26 + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 27 + { name = "glentities", version = "6.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glentities", source = "hex", outer_checksum = "78A0B28789C1A7840468C683FC9588B0B59AA38BE8CF5DACD1AF2E60A91AE638" }, 28 + { name = "glint", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "2214C7CEFDE457CEE62140C3D4899B964E05236DA74E4243DFADF4AF29C382BB" }, 29 + { name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" }, 30 + { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, 31 + { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" }, 32 + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, 33 + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, 34 + { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 35 + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 36 + { name = "lustre", version = "5.5.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "2DC2973D81C12E63251B636773217B8E09C5C84590A729750F6BCF009420B38E" }, 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 + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 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" }, 41 + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 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" }, 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" }, 44 + { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, 45 + { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" }, 46 + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 47 + { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" }, 48 + { name = "wisp", version = "2.2.0", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "655163D4DE19E3DD4AC75813A991BFD5523CB4FF2FC5F9F58FD6FB39D5D1806D" }, 49 + ] 50 + 51 + [requirements] 52 + chilp = { path = "../.." } 53 + gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 54 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 55 + glentities = { version = ">= 6.2.1 and < 7.0.0" } 56 + lustre = { version = ">= 5.5.2 and < 6.0.0" } 57 + lustre_dev_tools = { version = ">= 2.3.4 and < 3.0.0" }
+3
examples/lustre_chilp_app/src/ffi_lustre_chilp_app.mjs
··· 1 + export function lassign(url) { 2 + window.location.assign(url); 3 + }
+73
examples/lustre_chilp_app/src/lustre_chilp_app.gleam
··· 1 + // IMPORTS --------------------------------------------------------------------- 2 + 3 + import chilp/widget 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: widget.ChilpDataInYourModel(Msg), 25 + ) 26 + } 27 + 28 + fn init(_) -> #(Model, Effect(Msg)) { 29 + let model = Model(string: "Hi", chilp_model: widget.init(ChilpMessage)) 30 + // No effects, though you could force Chilp to pre-fetch a post with widget.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(to: String) -> Nil { 39 + Nil 40 + } 41 + 42 + fn browse(to: String) { 43 + js_browse(to) 44 + effect.none() 45 + } 46 + 47 + // UPDATE ---------------------------------------------------------------------- 48 + 49 + type Msg { 50 + ChilpMessage(widget.ChilpMsg) 51 + } 52 + 53 + // You can't usually make `ChilpMsg`s, with a few exceptions. 54 + // 55 + // One of them being `widget.trigger()`, which does what `widget.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 + widget.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 + widget.new("pony.social", "115911235653686237", model.chilp_model) 72 + |> widget.show(model.chilp_model) 73 + }
+8 -10
gleam.toml
··· 1 1 name = "chilp" 2 + description = "Allows you to use Mastodon comments on your Lustre blog." 2 3 version = "1.0.0" 3 - 4 - # Fill out these fields if you intend to generate HTML documentation or publish 5 - # your project to the Hex package manager. 6 - # 7 - # description = "" 8 - # licences = ["Apache-2.0"] 9 - # repository = { type = "github", user = "", repo = "" } 4 + licences = ["Apache-2.0"] 5 + repository = { type = "forgejo", host = "forge.strawmelonjuice.com", user = "strawmelonjuice", repo = "chilp" } 10 6 # links = [{ title = "Website", href = "" }] 11 - # 12 - # For a full reference of all the available options, you can have a look at 13 - # https://gleam.run/writing-gleam/gleam-toml/. 14 7 15 8 [dependencies] 16 9 gleam_stdlib = ">= 0.44.0 and < 2.0.0" 10 + gleam_json = ">= 3.1.0 and < 4.0.0" 11 + lustre = ">= 5.5.2 and < 6.0.0" 12 + rsvp = ">= 1.2.0 and < 2.0.0" 13 + gleam_time = ">= 1.7.0 and < 2.0.0" 14 + glentities = ">= 6.2.1 and < 7.0.0" 17 15 18 16 [dev-dependencies] 19 17 gleeunit = ">= 1.0.0 and < 2.0.0"
+17
manifest.toml
··· 2 2 # You typically do not need to edit this file 3 3 4 4 packages = [ 5 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 6 + { name = "gleam_fetch", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "2CBF9F2E1C71AEBBFB13A9D5720CD8DB4263EB02FE60C5A7A1C6E17B0151C20C" }, 7 + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 8 + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, 9 + { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, 10 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 11 + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 5 12 { name = "gleam_stdlib", version = "0.68.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F7FAEBD8EF260664E86A46C8DBA23508D1D11BB3BCC6EE1B89B3BC3E5C83FF1E" }, 13 + { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, 6 14 { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 15 + { name = "glentities", version = "6.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glentities", source = "hex", outer_checksum = "78A0B28789C1A7840468C683FC9588B0B59AA38BE8CF5DACD1AF2E60A91AE638" }, 16 + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, 17 + { name = "lustre", version = "5.5.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "2DC2973D81C12E63251B636773217B8E09C5C84590A729750F6BCF009420B38E" }, 18 + { 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" }, 7 19 ] 8 20 9 21 [requirements] 22 + gleam_json = { version = ">= 3.1.0 and < 4.0.0" } 10 23 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 24 + gleam_time = { version = ">= 1.7.0 and < 2.0.0" } 11 25 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 26 + glentities = { version = ">= 6.2.1 and < 7.0.0" } 27 + lustre = { version = ">= 5.5.2 and < 6.0.0" } 28 + rsvp = { version = ">= 1.2.0 and < 2.0.0" }
+1 -4
src/chilp.gleam
··· 1 - import gleam/io 1 + //// More API focussed stuff should go here 2 2 3 - pub fn main() -> Nil { 4 - io.println("Hello from chilp!") 5 - }
+193
src/chilp/api_typing.gleam
··· 1 + //// Module to match and type JSON data from the endpoints, I just typed these by hand, so if innacurate, they might bug out. 2 + 3 + import gleam/dynamic/decode 4 + import gleam/option.{type Option} 5 + 6 + /// A status, like the ones you get from urls like: https://pony.social/api/v1/statuses/115911235653686237/ 7 + pub type Status { 8 + Status( 9 + id: String, 10 + /// E.g. 2026-01-17T15:51:34.812Z 11 + created_at: String, 12 + // in_reply_to_id: String, 13 + // in_reply_to_account_id: String, 14 + sensitive: Bool, 15 + spoiler_text: String, 16 + visibility: String, 17 + language: String, 18 + uri: String, 19 + url: String, 20 + replies_count: Int, 21 + reblogs_count: Int, 22 + favourites_count: Int, 23 + quotes_count: Int, 24 + edited_at: Option(String), 25 + content: String, 26 + // application: StatusApplication, 27 + account: Account, 28 + media_attachments: List(String), 29 + mentions: List(Mentions), 30 + tags: List(String), 31 + ) 32 + } 33 + 34 + pub fn status_decoder() -> decode.Decoder(Status) { 35 + use id <- decode.field("id", decode.string) 36 + use created_at <- decode.field("created_at", decode.string) 37 + use sensitive <- decode.field("sensitive", decode.bool) 38 + use spoiler_text <- decode.field("spoiler_text", decode.string) 39 + use visibility <- decode.field("visibility", decode.string) 40 + use language <- decode.field("language", decode.string) 41 + use uri <- decode.field("uri", decode.string) 42 + use url <- decode.field("url", decode.string) 43 + use replies_count <- decode.field("replies_count", decode.int) 44 + use reblogs_count <- decode.field("reblogs_count", decode.int) 45 + use favourites_count <- decode.field("favourites_count", decode.int) 46 + use quotes_count <- decode.field("quotes_count", decode.int) 47 + use edited_at <- decode.field("edited_at", decode.optional(decode.string)) 48 + use content <- decode.field("content", decode.string) 49 + // use application <- decode.field("application", status_application_decoder()) 50 + use account <- decode.field("account", account_decoder()) 51 + use media_attachments <- decode.field( 52 + "media_attachments", 53 + decode.list(decode.string), 54 + ) 55 + use mentions <- decode.field("mentions", decode.list(mentions_decoder())) 56 + use tags <- decode.field("tags", decode.list(decode.string)) 57 + decode.success(Status( 58 + id:, 59 + created_at:, 60 + sensitive:, 61 + spoiler_text:, 62 + visibility:, 63 + language:, 64 + uri:, 65 + url:, 66 + replies_count:, 67 + reblogs_count:, 68 + favourites_count:, 69 + quotes_count:, 70 + edited_at:, 71 + content:, 72 + // application:, 73 + account:, 74 + media_attachments:, 75 + mentions:, 76 + tags:, 77 + )) 78 + } 79 + 80 + // pub type StatusApplication { 81 + // StatusApplication(name: String, website: String) 82 + // } 83 + 84 + // pub fn status_application_decoder() -> decode.Decoder(StatusApplication) { 85 + // use name <- decode.field("name", decode.string) 86 + // use website <- decode.field("website", decode.string) 87 + // decode.success(StatusApplication(name:, website:)) 88 + // } 89 + 90 + pub type Account { 91 + Account( 92 + id: String, 93 + username: String, 94 + acct: String, 95 + display_name: String, 96 + locked: Bool, 97 + bot: Bool, 98 + discoverable: Bool, 99 + indexable: Bool, 100 + group: Bool, 101 + created_at: String, 102 + note: String, 103 + url: String, 104 + uri: String, 105 + avatar: String, 106 + avatar_static: String, 107 + header: String, 108 + header_static: String, 109 + followers_count: Int, 110 + following_count: Int, 111 + statuses_count: Int, 112 + last_status_at: String, 113 + hide_collections: Bool, 114 + // noindex: Bool, 115 + // emojis: todo[], 116 + // roles: todo[], 117 + // fields: todo[], 118 + ) 119 + } 120 + 121 + pub fn account_decoder() -> decode.Decoder(Account) { 122 + use id <- decode.field("id", decode.string) 123 + use username <- decode.field("username", decode.string) 124 + use acct <- decode.field("acct", decode.string) 125 + use display_name <- decode.field("display_name", decode.string) 126 + use locked <- decode.field("locked", decode.bool) 127 + use bot <- decode.field("bot", decode.bool) 128 + use discoverable <- decode.field("discoverable", decode.bool) 129 + use indexable <- decode.field("indexable", decode.bool) 130 + use group <- decode.field("group", decode.bool) 131 + use created_at <- decode.field("created_at", decode.string) 132 + use note <- decode.field("note", decode.string) 133 + use url <- decode.field("url", decode.string) 134 + use uri <- decode.field("uri", decode.string) 135 + use avatar <- decode.field("avatar", decode.string) 136 + use avatar_static <- decode.field("avatar_static", decode.string) 137 + use header <- decode.field("header", decode.string) 138 + use header_static <- decode.field("header_static", decode.string) 139 + use followers_count <- decode.field("followers_count", decode.int) 140 + use following_count <- decode.field("following_count", decode.int) 141 + use statuses_count <- decode.field("statuses_count", decode.int) 142 + use last_status_at <- decode.field("last_status_at", decode.string) 143 + use hide_collections <- decode.field("hide_collections", decode.bool) 144 + // use noindex <- decode.field("noindex", decode.bool) 145 + decode.success(Account( 146 + id:, 147 + username:, 148 + acct:, 149 + display_name:, 150 + locked:, 151 + bot:, 152 + discoverable:, 153 + indexable:, 154 + group:, 155 + created_at:, 156 + note:, 157 + url:, 158 + uri:, 159 + avatar:, 160 + avatar_static:, 161 + header:, 162 + header_static:, 163 + followers_count:, 164 + following_count:, 165 + statuses_count:, 166 + last_status_at:, 167 + hide_collections:, 168 + // noindex:, 169 + )) 170 + } 171 + 172 + pub type Mentions { 173 + Mentions(id: String, username: String, url: String, acct: String) 174 + } 175 + 176 + fn mentions_decoder() -> decode.Decoder(Mentions) { 177 + use id <- decode.field("id", decode.string) 178 + use username <- decode.field("username", decode.string) 179 + use url <- decode.field("url", decode.string) 180 + use acct <- decode.field("acct", decode.string) 181 + decode.success(Mentions(id:, username:, url:, acct:)) 182 + } 183 + 184 + pub type StatusContext { 185 + StatusContext(ancestors: List(Status), descendants: List(Status)) 186 + } 187 + 188 + pub fn status_context_decoder() -> decode.Decoder(StatusContext) { 189 + let dec = status_decoder() 190 + use ancestors <- decode.field("ancestors", decode.list(dec)) 191 + use descendants <- decode.field("descendants", decode.list(dec)) 192 + decode.success(StatusContext(ancestors:, descendants:)) 193 + }
+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 + }
+354
src/chilp/widget.gleam
··· 1 + import chilp/api_typing 2 + import gleam/dict 3 + import gleam/int 4 + import gleam/list 5 + import gleam/order 6 + import gleam/pair 7 + import gleam/result 8 + import gleam/time/duration 9 + import gleam/time/timestamp 10 + import glentities 11 + import lustre/attribute.{attribute} 12 + import lustre/effect 13 + import lustre/element 14 + import lustre/element/html 15 + import lustre/event 16 + import rsvp 17 + 18 + pub opaque type MastodonPost { 19 + MastodonPost(instance: String, postid: String) 20 + } 21 + 22 + /// Creates a comment widget, this is where you should probably start! 23 + /// 24 + /// This function takes three arguments: 25 + /// - `instance`: The instance name, e.g. mastodon.social 26 + /// - `postid`: A post id to bind to, you'll find this in a post url `https://mastodon.social/@<username>/[postid]`. 27 + /// - `messages`: Some messages that chilp needs to be able to send 28 + pub fn new( 29 + instance instance: String, 30 + postid postid: String, 31 + chilp_model model: ChilpDataInYourModel(msg), 32 + ) -> CommentWidget(msg) { 33 + let post = MastodonPost(instance:, postid:) 34 + let set_message_get = Get(post) |> model.message 35 + let go_answer = fn(n) { 36 + let value = 37 + list.key_find(n, "userinstance") 38 + |> result.unwrap("mastodon.social") 39 + GoAnswer(value, post) |> model.message 40 + } 41 + CommentWidget( 42 + post:, 43 + widget: [attribute.classes([])], 44 + load_button: [ 45 + event.on_click(set_message_get), 46 + attribute.classes([#("btn btn-outline btn-primary", True)]), 47 + ], 48 + comments_section: [], 49 + go_reply_form: [event.on_submit(go_answer)], 50 + go_reply_text_box: [attribute.type_("text"), attribute.name("userinstance")], 51 + go_reply_button: [ 52 + attribute.type_("submit"), 53 + attribute.classes([#("btn btn-outline btn-primary", True)]), 54 + ], 55 + ) 56 + } 57 + 58 + /// This is what will show your comment block. 59 + pub fn show( 60 + from attributes: CommentWidget(msg), 61 + data model: ChilpDataInYourModel(msg), 62 + ) -> element.Element(msg) { 63 + let model = model.inner 64 + 65 + html.div( 66 + attributes.widget, 67 + case 68 + dict.get(model.stati, attributes.post), 69 + dict.get(model.context, attributes.post) 70 + { 71 + // Yeah so fun thing is, we just want the status itself to compare usernames with 72 + Ok(status), Ok(context) -> { 73 + [ 74 + html.form(attributes.go_reply_form, [ 75 + html.input(attributes.go_reply_text_box), 76 + html.button(attributes.go_reply_button, [html.text("Go reply")]), 77 + ]), 78 + view_commentlist(attributes, status, context.0), 79 + ] 80 + } 81 + _, _ -> { 82 + // Post is not 'gotten' yet. 83 + [html.button(attributes.load_button, [html.text("Load comments")])] 84 + } 85 + }, 86 + ) 87 + } 88 + 89 + fn view_commentlist( 90 + attributes: CommentWidget(msg), 91 + status: api_typing.Status, 92 + context: api_typing.StatusContext, 93 + ) { 94 + html.section( 95 + attributes.comments_section, 96 + list.map( 97 + context.descendants 98 + |> list.sort(fn(a, b) { 99 + case 100 + timestamp.parse_rfc3339(a.created_at), 101 + timestamp.parse_rfc3339(b.created_at) 102 + { 103 + Ok(a), Ok(b) -> timestamp.compare(a, b) 104 + _, _ -> order.Eq 105 + } 106 + }) 107 + |> list.sort(fn(a, b) { 108 + int.compare(a.favourites_count, b.favourites_count) 109 + }), 110 + fn(comm) { 111 + view_comment(comm, { 112 + // Is comment by op 113 + status.account.id == comm.account.id 114 + && comm.account.note == status.account.note 115 + }) 116 + }, 117 + ), 118 + ) 119 + } 120 + 121 + fn view_comment(comment: api_typing.Status, is_authors: Bool) { 122 + html.article([attribute.class("comment")], [ 123 + html.header([], [ 124 + html.img([ 125 + attribute.class("avatar"), 126 + attribute.alt("@"), 127 + attribute.src(comment.account.avatar), 128 + ]), 129 + html.div([attribute.class("meta")], [ 130 + html.span([attribute.class("display-name")], [ 131 + html.text(comment.account.display_name), 132 + ]), 133 + html.time([attribute("datetime", comment.created_at)], [ 134 + html.text({ 135 + let b = 136 + case 137 + timestamp.difference( 138 + timestamp.parse_rfc3339(comment.created_at) 139 + |> result.unwrap(timestamp.system_time()), 140 + timestamp.system_time(), 141 + ) 142 + |> duration.approximate 143 + |> pair.map_second(fn(d) { 144 + case d { 145 + duration.Nanosecond -> "nanosecond" 146 + duration.Microsecond -> "microsecond" 147 + duration.Millisecond -> "millisecond" 148 + duration.Second -> "second" 149 + duration.Minute -> "minute" 150 + duration.Hour -> "hour" 151 + duration.Day -> "day" 152 + duration.Week -> "week" 153 + duration.Month -> "month" 154 + duration.Year -> "year" 155 + } 156 + }) 157 + { 158 + #(1, x) -> #(1, x) 159 + #(x, d) -> #(x, d) 160 + } 161 + |> pair.map_first(int.to_string) 162 + 163 + b.0 <> " " <> b.1 <> " ago." 164 + }), 165 + ]), 166 + ]), 167 + ]), 168 + html.section([attribute.class("content")], [ 169 + html.p([], [ 170 + element.unsafe_raw_html( 171 + "", 172 + "span", 173 + [], 174 + glentities.decode(comment.content) |> sanitize, 175 + ), 176 + ]), 177 + ]), 178 + html.footer([], [ 179 + html.a([attribute.href(comment.url)], [ 180 + html.text("View on Mastodon"), 181 + ]), 182 + ]), 183 + ]) 184 + } 185 + 186 + @external(javascript, "./purify_bind_ffi.mjs", "sanitize") 187 + fn sanitize(html: String) -> String { 188 + // On erlang, there's a lot less risk. 189 + html 190 + } 191 + 192 + /// Allows you to edit your widget, you can replace or append to any values here. 193 + /// Do note, removing stuff might remove functionality! 194 + /// By default, some Tailwind/DaisyUI classes are added. 195 + pub type CommentWidget(msg) { 196 + CommentWidget( 197 + /// The post this widget is for, you should just keep this. 198 + post: MastodonPost, 199 + /// The top element of the widget itself. 200 + widget: List(attribute.Attribute(msg)), 201 + /// [Load comments]-button 202 + load_button: List(attribute.Attribute(msg)), 203 + /// The actual area the comments show up in 204 + comments_section: List(attribute.Attribute(msg)), 205 + go_reply_form: List(attribute.Attribute(msg)), 206 + go_reply_text_box: List(attribute.Attribute(msg)), 207 + go_reply_button: List(attribute.Attribute(msg)), 208 + ) 209 + } 210 + 211 + /// 212 + /// Trigger forces the widget to load in data before the user clicked the button. 213 + /// This is something you'll want if you know beforehand which post comments to display. 214 + pub fn trigger( 215 + on on: CommentWidget(msg), 216 + chilp_model model: ChilpDataInYourModel(msg), 217 + ) { 218 + model.message(Get(on.post)) 219 + } 220 + 221 + /// This stores metadata that is handled internally by Chilp 222 + /// You should store this on your model! 223 + pub opaque type ChilpDataInYourModel(msg) { 224 + ChilpDataInYourModel(message: fn(ChilpMsg) -> msg, inner: ChilpModel) 225 + } 226 + 227 + pub opaque type ChilpModel { 228 + ChilpModel( 229 + stati: dict.Dict(MastodonPost, api_typing.Status), 230 + context: dict.Dict(MastodonPost, #(api_typing.StatusContext, Float)), 231 + ) 232 + } 233 + 234 + pub fn init(message message: fn(ChilpMsg) -> msg) { 235 + ChilpDataInYourModel( 236 + message:, 237 + inner: ChilpModel(stati: dict.new(), context: dict.new()), 238 + ) 239 + } 240 + 241 + /// 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)`. 242 + pub opaque type ChilpMsg { 243 + Get(MastodonPost) 244 + Save(ChilpModel) 245 + GoAnswer(instance: String, to: MastodonPost) 246 + } 247 + 248 + /// Gets all the metadata to work with in order to show your comments! 249 + pub fn get( 250 + post: MastodonPost, 251 + data: ChilpDataInYourModel(msg), 252 + ) -> effect.Effect(msg) { 253 + let handles = fn(m) { data.message(Save(m)) } 254 + effect.batch([get_post(post, handles), get_context(post, handles)]) 255 + } 256 + 257 + fn get_post( 258 + post: MastodonPost, 259 + message: fn(ChilpModel) -> msg, 260 + ) -> effect.Effect(msg) { 261 + let handle_response = fn(s) { 262 + case s { 263 + Ok(status) -> { 264 + ChilpModel( 265 + stati: dict.from_list([#(post, status)]), 266 + context: dict.new(), 267 + ) 268 + } 269 + Error(_) -> ChilpModel(dict.new(), dict.new()) 270 + } 271 + |> message 272 + } 273 + let url = "https://" <> post.instance <> "/api/v1/statuses/" <> post.postid 274 + let handler = rsvp.expect_json(api_typing.status_decoder(), handle_response) 275 + rsvp.get(url, handler) 276 + } 277 + 278 + fn get_context( 279 + post: MastodonPost, 280 + message: fn(ChilpModel) -> msg, 281 + ) -> effect.Effect(msg) { 282 + let handle_response = fn(c) { 283 + case c { 284 + Ok(context) -> { 285 + let now = timestamp.system_time() |> timestamp.to_unix_seconds() 286 + ChilpModel( 287 + stati: dict.new(), 288 + context: dict.from_list([#(post, #(context, now))]), 289 + ) 290 + } 291 + Error(_) -> ChilpModel(dict.new(), dict.new()) 292 + } 293 + |> message 294 + } 295 + let url = 296 + "https://" 297 + <> post.instance 298 + <> "/api/v1/statuses/" 299 + <> post.postid 300 + <> "/context" 301 + let handler = 302 + rsvp.expect_json(api_typing.status_context_decoder(), handle_response) 303 + rsvp.get(url, handler) 304 + } 305 + 306 + /// The update handler for chilp-specific messages! 307 + /// 308 + /// It takes in three values: 309 + /// - `message`: The message it handles 310 + /// - `model`: the chilp data from your model 311 + /// - `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. 312 + pub fn update( 313 + message: ChilpMsg, 314 + model: ChilpDataInYourModel(msg), 315 + change_url: fn(String) -> effect.Effect(msg), 316 + ) -> #(ChilpDataInYourModel(msg), effect.Effect(msg)) { 317 + case message { 318 + Get(post) -> { 319 + #(model, get(post, model)) 320 + } 321 + Save(addedmodel) -> { 322 + let #(o_stati, o_context) = uncloth(model.inner) 323 + let #(n_stati, n_context) = uncloth(addedmodel) 324 + let stati = list.append(o_stati, n_stati) |> dict.from_list 325 + // I just loved overcomplicating it too much. 326 + let context = list.append(o_context, n_context) |> dict.from_list 327 + 328 + #( 329 + ChilpDataInYourModel(..model, inner: ChilpModel(stati:, context:)), 330 + effect.none(), 331 + ) 332 + } 333 + GoAnswer(instance:, to:) -> { 334 + let s = dict.get(model.inner.stati, to) 335 + case s { 336 + Ok(post) -> { 337 + change_url({ 338 + "https://" <> instance <> "/authorize_interaction?uri=" <> post.url 339 + }) 340 + #(model, effect.none()) 341 + } 342 + Error(_) -> #(model, effect.none()) 343 + } 344 + } 345 + } 346 + } 347 + 348 + fn uncloth(m: ChilpModel) { 349 + case m { 350 + ChilpModel(stati:, context:) -> { 351 + #(dict.to_list(stati), dict.to_list(context)) 352 + } 353 + } 354 + }