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.

feat: everything

+1142 -138
+5 -1
README.md
··· 16 16 17 17 Further documentation can be found at <https://hexdocs.pm/chilp>. 18 18 19 + ## Styling 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! 22 + 19 23 ## Development 20 24 21 25 ```sh 22 26 gleam run # Run the project 23 27 gleam test # Run the tests 24 - ``` 28 + ```
+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 + ]
+8
examples/lustre_chilp_app_autoloading/.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump 5 + 6 + #Added automatically by Lustre Dev Tools 7 + /.lustre 8 + /dist
+252
examples/lustre_chilp_app_autoloading/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 + }
+26
examples/lustre_chilp_app_autoloading/gleam.toml
··· 1 + name = "lustre_chilp_app_autoload" 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 + 20 + [dev-dependencies] 21 + gleeunit = ">= 1.0.0 and < 2.0.0" 22 + lustre_dev_tools = ">= 2.3.4 and < 3.0.0" 23 + [tools.lustre.html] 24 + stylesheets = [ 25 + { href = "styles.css" } 26 + ]
+55
examples/lustre_chilp_app_autoloading/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 = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 41 + { 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 + { 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" }, 43 + { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, 44 + { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" }, 45 + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 46 + { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" }, 47 + { 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" }, 48 + ] 49 + 50 + [requirements] 51 + chilp = { path = "../.." } 52 + gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 53 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 54 + lustre = { version = ">= 5.5.2 and < 6.0.0" } 55 + 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 + }
-1
gleam.toml
··· 11 11 lustre = ">= 5.5.2 and < 6.0.0" 12 12 rsvp = ">= 1.2.0 and < 2.0.0" 13 13 gleam_time = ">= 1.7.0 and < 2.0.0" 14 - glentities = ">= 6.2.1 and < 7.0.0" 15 14 16 15 [dev-dependencies] 17 16 gleeunit = ">= 1.0.0 and < 2.0.0"
-2
manifest.toml
··· 12 12 { name = "gleam_stdlib", version = "0.68.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F7FAEBD8EF260664E86A46C8DBA23508D1D11BB3BCC6EE1B89B3BC3E5C83FF1E" }, 13 13 { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" }, 14 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 15 { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, 17 16 { 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 17 { 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" }, ··· 23 22 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 24 23 gleam_time = { version = ">= 1.7.0 and < 2.0.0" } 25 24 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 26 - glentities = { version = ">= 6.2.1 and < 7.0.0" } 27 25 lustre = { version = ">= 5.5.2 and < 6.0.0" } 28 26 rsvp = { version = ">= 1.2.0 and < 2.0.0" }
+44 -9
src/chilp/api_typing.gleam
··· 9 9 id: String, 10 10 /// E.g. 2026-01-17T15:51:34.812Z 11 11 created_at: String, 12 - // in_reply_to_id: String, 12 + in_reply_to_id: String, 13 13 // in_reply_to_account_id: String, 14 14 sensitive: Bool, 15 15 spoiler_text: String, ··· 27 27 account: Account, 28 28 media_attachments: List(String), 29 29 mentions: List(Mentions), 30 - tags: List(String), 30 + tags: List(StatusTag), 31 31 ) 32 32 } 33 33 34 34 pub fn status_decoder() -> decode.Decoder(Status) { 35 35 use id <- decode.field("id", decode.string) 36 36 use created_at <- decode.field("created_at", decode.string) 37 + use in_reply_to_id <- field_or( 38 + field: "in_reply_to_id", 39 + decoder: decode.string, 40 + otherwise: "", 41 + ) 37 42 use sensitive <- decode.field("sensitive", decode.bool) 38 43 use spoiler_text <- decode.field("spoiler_text", decode.string) 39 44 use visibility <- decode.field("visibility", decode.string) 40 - use language <- decode.field("language", decode.string) 45 + use language <- field_or("language", decode.string, "") 41 46 use uri <- decode.field("uri", decode.string) 42 47 use url <- decode.field("url", decode.string) 43 48 use replies_count <- decode.field("replies_count", decode.int) ··· 46 51 use quotes_count <- decode.field("quotes_count", decode.int) 47 52 use edited_at <- decode.field("edited_at", decode.optional(decode.string)) 48 53 use content <- decode.field("content", decode.string) 54 + // let content = glentities.decode(content) 49 55 // use application <- decode.field("application", status_application_decoder()) 50 56 use account <- decode.field("account", account_decoder()) 51 57 use media_attachments <- decode.field( ··· 53 59 decode.list(decode.string), 54 60 ) 55 61 use mentions <- decode.field("mentions", decode.list(mentions_decoder())) 56 - use tags <- decode.field("tags", decode.list(decode.string)) 62 + use tags <- field_or("tags", decode.list(status_tag_decoder()), []) 57 63 decode.success(Status( 58 64 id:, 59 65 created_at:, 66 + in_reply_to_id:, 60 67 sensitive:, 61 68 spoiler_text:, 62 69 visibility:, ··· 110 117 following_count: Int, 111 118 statuses_count: Int, 112 119 last_status_at: String, 113 - hide_collections: Bool, 120 + // hide_collections: Bool, 114 121 // noindex: Bool, 115 122 // emojis: todo[], 116 123 // roles: todo[], ··· 125 132 use display_name <- decode.field("display_name", decode.string) 126 133 use locked <- decode.field("locked", decode.bool) 127 134 use bot <- decode.field("bot", decode.bool) 128 - use discoverable <- decode.field("discoverable", decode.bool) 135 + use discoverable <- field_or( 136 + field: "discoverable", 137 + decoder: decode.bool, 138 + otherwise: False, 139 + ) 129 140 use indexable <- decode.field("indexable", decode.bool) 130 141 use group <- decode.field("group", decode.bool) 131 142 use created_at <- decode.field("created_at", decode.string) ··· 140 151 use following_count <- decode.field("following_count", decode.int) 141 152 use statuses_count <- decode.field("statuses_count", decode.int) 142 153 use last_status_at <- decode.field("last_status_at", decode.string) 143 - use hide_collections <- decode.field("hide_collections", decode.bool) 154 + // use hide_collections <- decode.field("hide_collections", decode.bool) 144 155 // use noindex <- decode.field("noindex", decode.bool) 145 156 decode.success(Account( 146 157 id:, ··· 164 175 following_count:, 165 176 statuses_count:, 166 177 last_status_at:, 167 - hide_collections:, 168 - // noindex:, 178 + // hide_collections:, 179 + // noindex:, 169 180 )) 170 181 } 171 182 183 + fn field_or( 184 + field field: String, 185 + decoder field_decoder: decode.Decoder(t), 186 + otherwise default: t, 187 + next next: fn(t) -> decode.Decoder(final), 188 + ) -> decode.Decoder(final) { 189 + use val <- decode.optional_field( 190 + field, 191 + option.None, 192 + decode.optional(field_decoder), 193 + ) 194 + next(val |> option.unwrap(default)) 195 + } 196 + 172 197 pub type Mentions { 173 198 Mentions(id: String, username: String, url: String, acct: String) 174 199 } ··· 191 216 use descendants <- decode.field("descendants", decode.list(dec)) 192 217 decode.success(StatusContext(ancestors:, descendants:)) 193 218 } 219 + 220 + pub type StatusTag { 221 + StatusTag(name: String, url: String) 222 + } 223 + 224 + fn status_tag_decoder() -> decode.Decoder(StatusTag) { 225 + use name <- decode.field("name", decode.string) 226 + use url <- decode.field("url", decode.string) 227 + decode.success(StatusTag(name:, url:)) 228 + }
+407 -125
src/chilp/widget.gleam
··· 2 2 import gleam/dict 3 3 import gleam/int 4 4 import gleam/list 5 + import gleam/option 5 6 import gleam/order 6 7 import gleam/pair 7 8 import gleam/result 9 + import gleam/string 8 10 import gleam/time/duration 9 11 import gleam/time/timestamp 10 - import glentities 12 + import gleam/uri 11 13 import lustre/attribute.{attribute} 12 14 import lustre/effect 13 15 import lustre/element ··· 25 27 /// - `instance`: The instance name, e.g. mastodon.social 26 28 /// - `postid`: A post id to bind to, you'll find this in a post url `https://mastodon.social/@<username>/[postid]`. 27 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 28 31 pub fn new( 29 32 instance instance: String, 30 - postid postid: String, 33 + post_id postid: String, 31 34 chilp_model model: ChilpDataInYourModel(msg), 32 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 + } 33 53 let post = MastodonPost(instance:, postid:) 34 54 let set_message_get = Get(post) |> model.message 35 55 let go_answer = fn(n) { 36 56 let value = 37 57 list.key_find(n, "userinstance") 38 - |> result.unwrap("mastodon.social") 58 + |> result.unwrap(instanceplaceholder) 39 59 GoAnswer(value, post) |> model.message 40 60 } 41 61 CommentWidget( 42 62 post:, 43 - widget: [attribute.classes([])], 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 + ], 44 72 load_button: [ 45 73 event.on_click(set_message_get), 46 - attribute.classes([#("btn btn-outline btn-primary", True)]), 74 + attribute.classes([#("btn-get-comments", True)]), 47 75 ], 48 76 comments_section: [], 49 - go_reply_form: [event.on_submit(go_answer)], 50 - go_reply_text_box: [attribute.type_("text"), attribute.name("userinstance")], 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 + ], 51 89 go_reply_button: [ 52 90 attribute.type_("submit"), 53 - attribute.classes([#("btn btn-outline btn-primary", True)]), 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"), 54 133 ], 55 134 ) 56 135 } ··· 61 140 data model: ChilpDataInYourModel(msg), 62 141 ) -> element.Element(msg) { 63 142 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, [ 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")], [ 75 189 html.input(attributes.go_reply_text_box), 76 - html.button(attributes.go_reply_button, [html.text("Go reply")]), 190 + html.button(attributes.go_reply_button, [element.text("Go reply")]), 77 191 ]), 78 - view_commentlist(attributes, status, context.0), 79 - ] 80 - } 81 - _, _ -> { 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 + 82 212 // Post is not 'gotten' yet. 83 - [html.button(attributes.load_button, [html.text("Load comments")])] 84 - } 85 - }, 86 - ) 213 + _, _, _ -> 214 + html.button(attributes.load_button, [element.text("Load comments")]) 215 + }, 216 + ]), 217 + ]) 87 218 } 88 219 89 220 fn view_commentlist( ··· 91 222 status: api_typing.Status, 92 223 context: api_typing.StatusContext, 93 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 + }) 94 239 html.section( 95 240 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 - ), 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 + }), 118 253 ) 119 254 } 120 255 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" 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") 155 348 } 156 - }) 157 - { 158 - #(1, x) -> #(1, x) 159 - #(x, d) -> #(x, d) 160 - } 161 - |> pair.map_first(int.to_string) 349 + |> pair.map_first(int.to_string) 162 350 163 - b.0 <> " " <> b.1 <> " ago." 164 - }), 351 + b.0 <> " " <> b.1 <> " ago." 352 + }), 353 + ], 354 + ), 165 355 ]), 166 356 ]), 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, 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 + ], 175 366 ), 176 - ]), 177 - ]), 178 - html.footer([], [ 179 - html.a([attribute.href(comment.url)], [ 180 - html.text("View on Mastodon"), 367 + html.section(attribs.children_section, children), 181 368 ]), 182 - ]), 183 - ]) 369 + ], 370 + ) 184 371 } 185 372 186 373 @external(javascript, "./purify_bind_ffi.mjs", "sanitize") ··· 196 383 CommentWidget( 197 384 /// The post this widget is for, you should just keep this. 198 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))), 199 392 /// The top element of the widget itself. 200 393 widget: List(attribute.Attribute(msg)), 201 394 /// [Load comments]-button 202 395 load_button: List(attribute.Attribute(msg)), 203 396 /// The actual area the comments show up in 204 397 comments_section: List(attribute.Attribute(msg)), 398 + children_section: List(attribute.Attribute(msg)), 205 399 go_reply_form: List(attribute.Attribute(msg)), 400 + go_reply_label: List(attribute.Attribute(msg)), 206 401 go_reply_text_box: List(attribute.Attribute(msg)), 207 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), 208 424 ) 209 425 } 210 426 211 - /// 212 427 /// Trigger forces the widget to load in data before the user clicked the button. 213 428 /// This is something you'll want if you know beforehand which post comments to display. 214 429 pub fn trigger( 215 430 on on: CommentWidget(msg), 216 431 chilp_model model: ChilpDataInYourModel(msg), 432 + ) -> msg { 433 + model.message(Get(on.post)) 434 + } 435 + 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), 217 441 ) { 218 - model.message(Get(on.post)) 442 + get(on.post, model) 219 443 } 220 444 221 445 /// This stores metadata that is handled internally by Chilp ··· 228 452 ChilpModel( 229 453 stati: dict.Dict(MastodonPost, api_typing.Status), 230 454 context: dict.Dict(MastodonPost, #(api_typing.StatusContext, Float)), 455 + busy: dict.Dict(MastodonPost, option.Option(String)), 231 456 ) 232 457 } 233 458 234 459 pub fn init(message message: fn(ChilpMsg) -> msg) { 235 460 ChilpDataInYourModel( 236 461 message:, 237 - inner: ChilpModel(stati: dict.new(), context: dict.new()), 462 + inner: ChilpModel(stati: dict.new(), context: dict.new(), busy: dict.new()), 238 463 ) 239 464 } 240 465 ··· 246 471 } 247 472 248 473 /// Gets all the metadata to work with in order to show your comments! 249 - pub fn get( 474 + fn get( 250 475 post: MastodonPost, 251 476 data: ChilpDataInYourModel(msg), 252 477 ) -> effect.Effect(msg) { 253 478 let handles = fn(m) { data.message(Save(m)) } 254 - effect.batch([get_post(post, handles), get_context(post, handles)]) 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 + )), 488 + ) 489 + }) 490 + } 491 + effect.batch([ 492 + notify(), 493 + get_post(post, handles), 494 + get_context(post, handles), 495 + ]) 255 496 } 256 497 257 498 fn get_post( 258 499 post: MastodonPost, 259 500 message: fn(ChilpModel) -> msg, 260 501 ) -> effect.Effect(msg) { 502 + let url = "https://" <> post.instance <> "/api/v1/statuses/" <> post.postid 261 503 let handle_response = fn(s) { 262 504 case s { 263 505 Ok(status) -> { 264 506 ChilpModel( 265 507 stati: dict.from_list([#(post, status)]), 266 508 context: dict.new(), 509 + busy: dict.new(), 267 510 ) 268 511 } 269 - Error(_) -> ChilpModel(dict.new(), dict.new()) 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 + ) 270 523 } 271 524 |> message 272 525 } 273 - let url = "https://" <> post.instance <> "/api/v1/statuses/" <> post.postid 274 526 let handler = rsvp.expect_json(api_typing.status_decoder(), handle_response) 275 527 rsvp.get(url, handler) 276 528 } ··· 279 531 post: MastodonPost, 280 532 message: fn(ChilpModel) -> msg, 281 533 ) -> effect.Effect(msg) { 534 + let url = 535 + "https://" 536 + <> post.instance 537 + <> "/api/v1/statuses/" 538 + <> post.postid 539 + <> "/context" 282 540 let handle_response = fn(c) { 283 541 case c { 284 542 Ok(context) -> { ··· 286 544 ChilpModel( 287 545 stati: dict.new(), 288 546 context: dict.from_list([#(post, #(context, now))]), 547 + busy: dict.new(), 289 548 ) 290 549 } 291 - Error(_) -> ChilpModel(dict.new(), dict.new()) 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 + ) 292 566 } 293 567 |> message 294 568 } 295 - let url = 296 - "https://" 297 - <> post.instance 298 - <> "/api/v1/statuses/" 299 - <> post.postid 300 - <> "/context" 569 + 301 570 let handler = 302 571 rsvp.expect_json(api_typing.status_context_decoder(), handle_response) 303 572 rsvp.get(url, handler) ··· 325 594 // I just loved overcomplicating it too much. 326 595 let context = list.append(o_context, n_context) |> dict.from_list 327 596 597 + let busy = dict.combine(addedmodel.busy, model.inner.busy, option.or) 598 + 328 599 #( 329 - ChilpDataInYourModel(..model, inner: ChilpModel(stati:, context:)), 600 + ChilpDataInYourModel( 601 + ..model, 602 + inner: ChilpModel(stati:, context:, busy:), 603 + ), 330 604 effect.none(), 331 605 ) 332 606 } ··· 335 609 case s { 336 610 Ok(post) -> { 337 611 change_url({ 338 - "https://" <> instance <> "/authorize_interaction?uri=" <> post.url 612 + "https://" 613 + <> instance 614 + <> "/authorize_interaction?uri=" 615 + <> { post.url |> uri.percent_encode } 339 616 }) 340 617 #(model, effect.none()) 341 618 } ··· 345 622 } 346 623 } 347 624 348 - fn uncloth(m: ChilpModel) { 625 + fn uncloth( 626 + m: ChilpModel, 627 + ) -> #( 628 + List(#(MastodonPost, api_typing.Status)), 629 + List(#(MastodonPost, #(api_typing.StatusContext, Float))), 630 + ) { 349 631 case m { 350 - ChilpModel(stati:, context:) -> { 632 + ChilpModel(stati:, context:, ..) -> { 351 633 #(dict.to_list(stati), dict.to_list(context)) 352 634 } 353 635 }