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.

Lots of changes again!

+840 -1554
+1
.gitignore
··· 5 5 6 6 # Ignore direnv cache 7 7 .direnv/ 8 + .vscode
+238 -198
NOTES.md
··· 5 5 - [ ] Bluesky, including a "[Comment via Bluesky]" button, and fetching comments from Bluesky posts in Bluesky-only mode or in a mixed mode with Mastodon. 6 6 - [x] ~~GitHub issues, including a "[Comment via GitHub]" button.~~ For a later release, as this will require a lot of work to implement the GraphQL queries and mutations. 7 7 - [ ] Other platforms? Maybe? Depends on how much time I have, and how much demand there is for other platforms. 8 + - [x] Remove dependency on DOMPurifier 8 9 - [x] [fix] Adding `flex-wrap: wrap;` to `.widget .form-controls` to prevent the buttons from overflowing on smaller screens. 9 - - [ ] Remove the builtin CSS in favour of [DaisyUI](https://daisyui.com) classes which means standardized class names for self-stylers, and site-themed widgets for everyone else! 10 + - [x] Remove the builtin CSS in favour of [DaisyUI](https://daisyui.com) classes which means standardized class names for self-stylers, and site-themed widgets for everyone else! 10 11 11 12 ## [feat] Adding multiple back-ends 12 13 ··· 15 16 16 17 Where for a mastodon post we need `instance` and `postid`, we now need `did` and `postid`. 17 18 18 - Let's say they have been supplied as `did:plc:jgtfsmv25thfs4zmydtbccnn` (strawmelonjuice.com) and `3mgrbiiadws2k` 19 - 20 - Fetch a list of the user's posts: 21 - https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=did:plc:jgtfsmv25thfs4zmydtbccnn&filter=posts_with_replies&limit=100 19 + Let's say they have been supplied as `did:plc:jgtfsmv25thfs4zmydtbccnn` (strawmelonjuice.com) and `3mgt3lymlak2c` 22 20 23 - This may require a few requests if the user has more than 100 posts, but we can paginate through them using the `cursor` parameter. 21 + Fetch the thread: 24 22 25 - Finally, find the post with the matching `post_id` in the list of posts and return it. 23 + https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgrbiiadws2k 26 24 27 - That'd be [this post](https://witchsky.app/profile/did:plc:jgtfsmv25thfs4zmydtbccnn/post/3mgrbiiadws2k), shaped like this (well this is embarrassing): 25 + That'd be [this post](https://witchsky.app/profile/did:plc:jgtfsmv25thfs4zmydtbccnn/post/3mgt3lymlak2c), shaped like this: 28 26 29 27 ```json 30 28 { 31 - "12": { 32 - "post": { 33 - "uri": "at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgrbiiadws2k", 34 - "cid": "bafyreif64xlr7idezspawc36x4dcatl655esh7eenc6mxuudfzymqirkyy", 35 - "author": { 36 - "did": "did:plc:jgtfsmv25thfs4zmydtbccnn", 37 - "handle": "strawmelonjuice.com", 38 - "displayName": "Mar !!", 39 - "pronouns": "she/her", 40 - "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:jgtfsmv25thfs4zmydtbccnn/bafkreia45evsjv2oae3bceh6jezy7am7uobmjhstvmwgdb5fekvs6q5wry", 41 - "associated": { 42 - "chat": { 43 - "allowIncoming": "following" 44 - }, 45 - "activitySubscription": { 46 - "allowSubscriptions": "followers" 47 - } 48 - }, 49 - "labels": [], 50 - "createdAt": "2023-12-03T09:45:34.997Z" 51 - }, 52 - "record": { 53 - "$type": "app.bsky.feed.post", 54 - "createdAt": "2026-03-11T06:36:58.800Z", 55 - "langs": [ 56 - "en" 57 - ], 58 - "reply": { 59 - "parent": { 60 - "cid": "bafyreiagb7e2ia3lkzi2yqpvtu2qvcp52xz2ifspuhisluy6rcgzga3wwu", 61 - "uri": "at://did:plc:wgmcfsuu2o2hnbyf6eon5tw6/app.bsky.feed.post/3mgqujnkpf22n" 62 - }, 63 - "root": { 64 - "cid": "bafyreiagb7e2ia3lkzi2yqpvtu2qvcp52xz2ifspuhisluy6rcgzga3wwu", 65 - "uri": "at://did:plc:wgmcfsuu2o2hnbyf6eon5tw6/app.bsky.feed.post/3mgqujnkpf22n" 66 - } 67 - }, 68 - "text": "penith" 69 - }, 70 - "bookmarkCount": 0, 71 - "replyCount": 0, 72 - "repostCount": 0, 73 - "likeCount": 1, 74 - "quoteCount": 0, 75 - "indexedAt": "2026-03-11T06:36:58.954Z", 76 - "labels": [] 77 - }, 78 - "reply": { 79 - "root": { 80 - "uri": "at://did:plc:wgmcfsuu2o2hnbyf6eon5tw6/app.bsky.feed.post/3mgqujnkpf22n", 81 - "cid": "bafyreiagb7e2ia3lkzi2yqpvtu2qvcp52xz2ifspuhisluy6rcgzga3wwu", 82 - "author": { 83 - "did": "did:plc:wgmcfsuu2o2hnbyf6eon5tw6", 84 - "handle": "chitin.link", 85 - "displayName": "AUBURN", 86 - "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:wgmcfsuu2o2hnbyf6eon5tw6/bafkreighu5b4perorcoytfdqjnbnlm424htrmmqojp5ayvoobhiw47hpma", 87 - "associated": { 88 - "chat": { 89 - "allowIncoming": "all" 90 - }, 91 - "activitySubscription": { 92 - "allowSubscriptions": "followers" 93 - } 94 - }, 95 - "labels": [], 96 - "createdAt": "2023-07-24T21:41:48.499Z" 97 - }, 98 - "record": { 99 - "$type": "app.bsky.feed.post", 100 - "createdAt": "2026-03-11T02:44:59.275Z", 101 - "embed": { 102 - "$type": "app.bsky.embed.images", 103 - "images": [ 104 - { 105 - "alt": "", 106 - "aspectRatio": { 107 - "height": 1125, 108 - "width": 2000 109 - }, 110 - "image": { 111 - "$type": "blob", 112 - "ref": { 113 - "$link": "bafkreif4famkhzes4l6f4t3mald5k5eekmy6vx3rnt76kfar4vxro2iqs4" 114 - }, 115 - "mimeType": "image/jpeg", 116 - "size": 942046 117 - } 118 - } 119 - ] 120 - }, 121 - "langs": [ 122 - "en" 123 - ], 124 - "text": "" 125 - }, 126 - "embed": { 127 - "$type": "app.bsky.embed.images#view", 128 - "images": [ 129 - { 130 - "thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:wgmcfsuu2o2hnbyf6eon5tw6/bafkreif4famkhzes4l6f4t3mald5k5eekmy6vx3rnt76kfar4vxro2iqs4", 131 - "fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:wgmcfsuu2o2hnbyf6eon5tw6/bafkreif4famkhzes4l6f4t3mald5k5eekmy6vx3rnt76kfar4vxro2iqs4", 132 - "alt": "", 133 - "aspectRatio": { 134 - "height": 1125, 135 - "width": 2000 136 - } 137 - } 138 - ] 139 - }, 140 - "bookmarkCount": 0, 141 - "replyCount": 1, 142 - "repostCount": 0, 143 - "likeCount": 2, 144 - "quoteCount": 0, 145 - "indexedAt": "2026-03-11T02:45:05.575Z", 146 - "labels": [], 147 - "$type": "app.bsky.feed.defs#postView" 148 - }, 149 - "parent": { 150 - "uri": "at://did:plc:wgmcfsuu2o2hnbyf6eon5tw6/app.bsky.feed.post/3mgqujnkpf22n", 151 - "cid": "bafyreiagb7e2ia3lkzi2yqpvtu2qvcp52xz2ifspuhisluy6rcgzga3wwu", 152 - "author": { 153 - "did": "did:plc:wgmcfsuu2o2hnbyf6eon5tw6", 154 - "handle": "chitin.link", 155 - "displayName": "AUBURN", 156 - "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:wgmcfsuu2o2hnbyf6eon5tw6/bafkreighu5b4perorcoytfdqjnbnlm424htrmmqojp5ayvoobhiw47hpma", 157 - "associated": { 158 - "chat": { 159 - "allowIncoming": "all" 160 - }, 161 - "activitySubscription": { 162 - "allowSubscriptions": "followers" 163 - } 164 - }, 165 - "labels": [], 166 - "createdAt": "2023-07-24T21:41:48.499Z" 167 - }, 168 - "record": { 169 - "$type": "app.bsky.feed.post", 170 - "createdAt": "2026-03-11T02:44:59.275Z", 171 - "embed": { 172 - "$type": "app.bsky.embed.images", 173 - "images": [ 174 - { 175 - "alt": "", 176 - "aspectRatio": { 177 - "height": 1125, 178 - "width": 2000 179 - }, 180 - "image": { 181 - "$type": "blob", 182 - "ref": { 183 - "$link": "bafkreif4famkhzes4l6f4t3mald5k5eekmy6vx3rnt76kfar4vxro2iqs4" 184 - }, 185 - "mimeType": "image/jpeg", 186 - "size": 942046 187 - } 188 - } 189 - ] 190 - }, 191 - "langs": [ 192 - "en" 193 - ], 194 - "text": "" 195 - }, 196 - "embed": { 197 - "$type": "app.bsky.embed.images#view", 198 - "images": [ 199 - { 200 - "thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:wgmcfsuu2o2hnbyf6eon5tw6/bafkreif4famkhzes4l6f4t3mald5k5eekmy6vx3rnt76kfar4vxro2iqs4", 201 - "fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:wgmcfsuu2o2hnbyf6eon5tw6/bafkreif4famkhzes4l6f4t3mald5k5eekmy6vx3rnt76kfar4vxro2iqs4", 202 - "alt": "", 203 - "aspectRatio": { 204 - "height": 1125, 205 - "width": 2000 206 - } 207 - } 208 - ] 209 - }, 210 - "bookmarkCount": 0, 211 - "replyCount": 1, 212 - "repostCount": 0, 213 - "likeCount": 2, 214 - "quoteCount": 0, 215 - "indexedAt": "2026-03-11T02:45:05.575Z", 216 - "labels": [], 217 - "$type": "app.bsky.feed.defs#postView" 218 - } 219 - } 220 - } 29 + "thread": { 30 + "$type": "app.bsky.feed.defs#threadViewPost", 31 + "post": { 32 + "uri": "at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgt3lymlak2c", 33 + "cid": "bafyreib7cnklabvss4jpi6j2dplahndlb4vpvwpmacqfbu6227wap4xs6i", 34 + "author": { 35 + "did": "did:plc:jgtfsmv25thfs4zmydtbccnn", 36 + "handle": "strawmelonjuice.com", 37 + "displayName": "Mar !!", 38 + "pronouns": "she/her", 39 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:jgtfsmv25thfs4zmydtbccnn/bafkreia45evsjv2oae3bceh6jezy7am7uobmjhstvmwgdb5fekvs6q5wry", 40 + "associated": { 41 + "chat": { 42 + "allowIncoming": "following" 43 + }, 44 + "activitySubscription": { 45 + "allowSubscriptions": "followers" 46 + } 47 + }, 48 + "labels": [], 49 + "createdAt": "2023-12-03T09:45:34.997Z" 50 + }, 51 + "record": { 52 + "$type": "app.bsky.feed.post", 53 + "createdAt": "2026-03-11T23:56:53.666Z", 54 + "embed": { 55 + "$type": "app.bsky.embed.external", 56 + "external": { 57 + "description": "", 58 + "title": "Mar's site", 59 + "uri": "https://strawmelonjuice.com/post/0" 60 + } 61 + }, 62 + "facets": [ 63 + { 64 + "features": [ 65 + { 66 + "$type": "app.bsky.richtext.facet#link", 67 + "uri": "https://strawmelonjuice.com/post/0" 68 + } 69 + ], 70 + "index": { 71 + "byteEnd": 55, 72 + "byteStart": 29 73 + } 74 + } 75 + ], 76 + "langs": [ 77 + "en" 78 + ], 79 + "text": "NEW SITE WHO DIS?!\n\nhttps://\nstrawmelonjuice.com/post/0 :3" 80 + }, 81 + "embed": { 82 + "$type": "app.bsky.embed.external#view", 83 + "external": { 84 + "uri": "https://strawmelonjuice.com/post/0", 85 + "title": "Mar's site", 86 + "description": "" 87 + } 88 + }, 89 + "bookmarkCount": 1, 90 + "replyCount": 3, 91 + "repostCount": 1, 92 + "likeCount": 4, 93 + "quoteCount": 0, 94 + "indexedAt": "2026-03-11T23:56:53.973Z", 95 + "labels": [] 96 + }, 97 + "replies": [ 98 + { 99 + "$type": "app.bsky.feed.defs#threadViewPost", 100 + "post": { 101 + "uri": "at://did:plc:qcwhrvzx6wmi5hz775uyi6fh/app.bsky.feed.post/3mgt3lz2dar2k", 102 + "cid": "bafyreidipzjm3znrh27cj2267t5g2tm75jxq4bvgwsyrgn3f4odqzptsq4", 103 + "author": { 104 + "did": "did:plc:qcwhrvzx6wmi5hz775uyi6fh", 105 + "handle": "bot-tan.suibari.com", 106 + "displayName": "全肯定botたん", 107 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:qcwhrvzx6wmi5hz775uyi6fh/bafkreiafvcttyogza65axx4kknnbgkeflgcz4abz6bcxqsbumtiyzygzjq", 108 + "associated": { 109 + "chat": { 110 + "allowIncoming": "following" 111 + }, 112 + "activitySubscription": { 113 + "allowSubscriptions": "followers" 114 + } 115 + }, 116 + "labels": [], 117 + "createdAt": "2024-02-19T04:43:58.159Z" 118 + }, 119 + "record": { 120 + "$type": "app.bsky.feed.post", 121 + "createdAt": "2026-03-11T23:56:54.045Z", 122 + "reply": { 123 + "parent": { 124 + "cid": "bafyreib7cnklabvss4jpi6j2dplahndlb4vpvwpmacqfbu6227wap4xs6i", 125 + "uri": "at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgt3lymlak2c" 126 + }, 127 + "root": { 128 + "cid": "bafyreib7cnklabvss4jpi6j2dplahndlb4vpvwpmacqfbu6227wap4xs6i", 129 + "uri": "at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgt3lymlak2c" 130 + } 131 + }, 132 + "text": "A quiet storm of excellence." 133 + }, 134 + "bookmarkCount": 0, 135 + "replyCount": 0, 136 + "repostCount": 0, 137 + "likeCount": 1, 138 + "quoteCount": 0, 139 + "indexedAt": "2026-03-11T23:56:54.249Z", 140 + "labels": [] 141 + }, 142 + "replies": [], 143 + "threadContext": { 144 + "rootAuthorLike": "at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.like/3mgtql33lob2w" 145 + } 146 + }, 147 + { 148 + "$type": "app.bsky.feed.defs#threadViewPost", 149 + "post": { 150 + "uri": "at://did:plc:fz7okbhusu5f2gbzx5tyncgf/app.bsky.feed.post/3mgtk3ajb622a", 151 + "cid": "bafyreien7vm2l2e2q6nwnsabvzleuluikrgzgalj4dnj6q7xkdjsrj67na", 152 + "author": { 153 + "did": "did:plc:fz7okbhusu5f2gbzx5tyncgf", 154 + "handle": "cool-handle.ebil.club", 155 + "displayName": "coil door habdle", 156 + "pronouns": "they/she", 157 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:fz7okbhusu5f2gbzx5tyncgf/bafkreif5r27afvsjy5tgcvwj52jw7tnpxmko5rior4wz6ktkdygyumosdy", 158 + "associated": { 159 + "chat": { 160 + "allowIncoming": "all" 161 + }, 162 + "activitySubscription": { 163 + "allowSubscriptions": "mutuals" 164 + } 165 + }, 166 + "labels": [], 167 + "createdAt": "2025-08-11T04:50:51.801Z" 168 + }, 169 + "record": { 170 + "$type": "app.bsky.feed.post", 171 + "createdAt": "2026-03-12T04:15:57.648Z", 172 + "langs": [ 173 + "en" 174 + ], 175 + "reply": { 176 + "parent": { 177 + "cid": "bafyreib7cnklabvss4jpi6j2dplahndlb4vpvwpmacqfbu6227wap4xs6i", 178 + "uri": "at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgt3lymlak2c" 179 + }, 180 + "root": { 181 + "cid": "bafyreib7cnklabvss4jpi6j2dplahndlb4vpvwpmacqfbu6227wap4xs6i", 182 + "uri": "at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgt3lymlak2c" 183 + } 184 + }, 185 + "text": "woag !!" 186 + }, 187 + "bookmarkCount": 0, 188 + "replyCount": 0, 189 + "repostCount": 0, 190 + "likeCount": 1, 191 + "quoteCount": 0, 192 + "indexedAt": "2026-03-12T04:15:58.953Z", 193 + "labels": [] 194 + }, 195 + "replies": [], 196 + "threadContext": { 197 + "rootAuthorLike": "at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.like/3mgtpt6x5722m" 198 + } 199 + }, 200 + { 201 + "$type": "app.bsky.feed.defs#threadViewPost", 202 + "post": { 203 + "uri": "at://did:plc:4vsww6rhxujyrsd3h7z24xj7/app.bsky.feed.post/3mgt3lzkaw72e", 204 + "cid": "bafyreidwpcqfprkf7qhy4s6czekq25zraoasqs25f4t2f6uebb3ujqsrhm", 205 + "author": { 206 + "did": "did:plc:4vsww6rhxujyrsd3h7z24xj7", 207 + "handle": "positivitybot.bsky.social", 208 + "displayName": "Whimsy Miku", 209 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4vsww6rhxujyrsd3h7z24xj7/bafkreieh4gyxzq4okicnox22ptf3dhnpptpo2tty72m4ejaqsd7bcwvg4a", 210 + "associated": { 211 + "chat": { 212 + "allowIncoming": "all" 213 + }, 214 + "activitySubscription": { 215 + "allowSubscriptions": "followers" 216 + } 217 + }, 218 + "labels": [], 219 + "createdAt": "2025-10-24T22:52:38.552Z" 220 + }, 221 + "record": { 222 + "$type": "app.bsky.feed.post", 223 + "createdAt": "2026-03-11T23:56:54.549441+00:00", 224 + "facets": [], 225 + "langs": [ 226 + "en" 227 + ], 228 + "reply": { 229 + "$type": "app.bsky.feed.post#replyRef", 230 + "parent": { 231 + "$type": "com.atproto.repo.strongRef", 232 + "cid": "bafyreib7cnklabvss4jpi6j2dplahndlb4vpvwpmacqfbu6227wap4xs6i", 233 + "uri": "at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgt3lymlak2c" 234 + }, 235 + "root": { 236 + "$type": "com.atproto.repo.strongRef", 237 + "cid": "bafyreib7cnklabvss4jpi6j2dplahndlb4vpvwpmacqfbu6227wap4xs6i", 238 + "uri": "at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgt3lymlak2c" 239 + } 240 + }, 241 + "text": "Yap more often!" 242 + }, 243 + "bookmarkCount": 0, 244 + "replyCount": 0, 245 + "repostCount": 0, 246 + "likeCount": 1, 247 + "quoteCount": 0, 248 + "indexedAt": "2026-03-11T23:56:55.550Z", 249 + "labels": [] 250 + }, 251 + "replies": [], 252 + "threadContext": { 253 + "rootAuthorLike": "at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.like/3mgtql45dip2k" 254 + } 255 + } 256 + ], 257 + "threadContext": { 258 + 259 + } 260 + } 221 261 } 222 262 ``` 223 263
+10
changelog.md
··· 1 + # Changelog 2 + 3 + ## Changes from v1 to v2 4 + 5 + Change in one line: 6 + > Chilp v1 was wrapping itself in a Lustre component, Chilp v2 _is_ a Lustre component. 7 + 8 + - Add Bluesky as a anchor option, equal to Mastodon. 9 + - Change styling to use DaisyUI by default, allowing it to adapt to your site's theme, or for you to just match on classes if you want to use your own css. 10 + - Remove DOMPurify dependency, instead Chilp now parses and rebuilds Mastodon post content by itself, to keep dangerous code from sneaking in.
-8
examples/lustre_chilp_app_nocomponent/.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_nocomponent/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_nocomponent/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_nocomponent/src/ffi_lustre_chilp_app.mjs
··· 1 - export function lassign(url) { 2 - window.location.assign(url); 3 - }
-73
examples/lustre_chilp_app_nocomponent/src/lustre_chilp_app.gleam
··· 1 - // IMPORTS --------------------------------------------------------------------- 2 - 3 - import chilp/widget/base 4 - import lustre 5 - import lustre/effect.{type Effect} 6 - import lustre/element.{type Element} 7 - 8 - // MAIN ------------------------------------------------------------------------ 9 - 10 - pub fn main() { 11 - // In this example, we're not making much sense. Just Chilp. 12 - let app = lustre.application(init, update, view) 13 - let assert Ok(_) = lustre.start(app, "#app", Nil) 14 - 15 - Nil 16 - } 17 - 18 - // MODEL ----------------------------------------------------------------------- 19 - 20 - type Model { 21 - Model( 22 - string: String, 23 - // .. and other things your application would need to know, for chilp we have: 24 - chilp_model: base.ChilpDataInYourModel(Msg), 25 - ) 26 - } 27 - 28 - fn init(_) -> #(Model, Effect(Msg)) { 29 - let model = Model(string: "Hi", chilp_model: base.init(ChilpMessage)) 30 - // No effects, though you could force Chilp to pre-fetch a post with base.force()! 31 - let effect = effect.none() 32 - 33 - #(model, effect) 34 - } 35 - 36 - // HELPERS---------------------------------------------------------------------- 37 - @external(javascript, "./ffi_lustre_chilp_app.mjs", "lassign") 38 - fn js_browse(_: String) -> Nil { 39 - Nil 40 - } 41 - 42 - fn browse(to: String) { 43 - use _ <- effect.from 44 - js_browse(to) 45 - } 46 - 47 - // UPDATE ---------------------------------------------------------------------- 48 - 49 - type Msg { 50 - ChilpMessage(base.ChilpMsg) 51 - } 52 - 53 - // You can't usually make `ChilpMsg`s, with a few exceptions. 54 - // 55 - // One of them being `base.trigger()`, which does what `base.force()` does but instead of an effect it returns a `ChilpMsg`! 56 - fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 57 - case msg { 58 - // Normally, your own variants would be here too. 59 - ChilpMessage(message) -> { 60 - let #(chilp_model, effect) = 61 - base.update(message, model.chilp_model, browse) 62 - #(Model(..model, chilp_model:), effect) 63 - } 64 - } 65 - } 66 - 67 - // VIEW ------------------------------------------------------------------------ 68 - 69 - fn view(model: Model) -> Element(Msg) { 70 - // Let's render comments under https://pony.social/@strawmelonjuice/115911235653686237 and nothing else 71 - base.new("pony.social", "115911235653686237", model.chilp_model) 72 - |> base.show(model.chilp_model) 73 - }
+3 -3
flake.lock
··· 2 2 "nodes": { 3 3 "nixpkgs": { 4 4 "locked": { 5 - "lastModified": 1769170682, 6 - "narHash": "sha256-oMmN1lVQU0F0W2k6OI3bgdzp2YOHWYUAw79qzDSjenU=", 5 + "lastModified": 1776169885, 6 + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", 7 7 "owner": "NixOS", 8 8 "repo": "nixpkgs", 9 - "rev": "c5296fdd05cfa2c187990dd909864da9658df755", 9 + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", 10 10 "type": "github" 11 11 }, 12 12 "original": {
+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 + html_parser = ">= 1.0.1 and < 2.0.0" 14 15 15 16 [dev-dependencies] 16 17 gleeunit = ">= 1.0.0 and < 2.0.0"
+2
manifest.toml
··· 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 15 { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, 16 + { name = "html_parser", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "html_parser", source = "hex", outer_checksum = "EEC0A3891CE99A49A8BB99086A06F56441D2ACF9436CE33ADBE51CE277D2D607" }, 16 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" }, 17 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" }, 18 19 ] ··· 22 23 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 23 24 gleam_time = { version = ">= 1.7.0 and < 2.0.0" } 24 25 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 26 + html_parser = { version = ">= 1.0.1 and < 2.0.0" } 25 27 lustre = { version = ">= 5.5.2 and < 6.0.0" } 26 28 rsvp = { version = ">= 1.2.0 and < 2.0.0" }
+2 -1
src/chilp.gleam
··· 5 5 /// 6 6 /// This component adds in inline CSS, to not do this, use `widget.element("bla", "111", False)` instead. 7 7 pub fn widget(instance instance: String, post_id post: String) { 8 - widget.element(instance:, post_id: post, with_styles: True) 8 + todo 9 + // widget.element(instance:, post_id: post, with_styles: True) 9 10 }
-228
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 MastodonStatus { 8 - MastodonStatus( 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: MastodonAccount, 28 - media_attachments: List(String), 29 - mentions: List(MastodonMentions), 30 - tags: List(StatusTag), 31 - ) 32 - } 33 - 34 - pub fn status_decoder() -> decode.Decoder(MastodonStatus) { 35 - use id <- decode.field("id", decode.string) 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 - ) 42 - use sensitive <- decode.field("sensitive", decode.bool) 43 - use spoiler_text <- decode.field("spoiler_text", decode.string) 44 - use visibility <- decode.field("visibility", decode.string) 45 - use language <- field_or("language", decode.string, "") 46 - use uri <- decode.field("uri", decode.string) 47 - use url <- decode.field("url", decode.string) 48 - use replies_count <- decode.field("replies_count", decode.int) 49 - use reblogs_count <- decode.field("reblogs_count", decode.int) 50 - use favourites_count <- decode.field("favourites_count", decode.int) 51 - use quotes_count <- decode.field("quotes_count", decode.int) 52 - use edited_at <- decode.field("edited_at", decode.optional(decode.string)) 53 - use content <- decode.field("content", decode.string) 54 - // let content = glentities.decode(content) 55 - // use application <- decode.field("application", status_application_decoder()) 56 - use account <- decode.field("account", account_decoder()) 57 - use media_attachments <- decode.field( 58 - "media_attachments", 59 - decode.list(decode.string), 60 - ) 61 - use mentions <- decode.field("mentions", decode.list(mentions_decoder())) 62 - use tags <- field_or("tags", decode.list(status_tag_decoder()), []) 63 - decode.success(MastodonStatus( 64 - id:, 65 - created_at:, 66 - in_reply_to_id:, 67 - sensitive:, 68 - spoiler_text:, 69 - visibility:, 70 - language:, 71 - uri:, 72 - url:, 73 - replies_count:, 74 - reblogs_count:, 75 - favourites_count:, 76 - quotes_count:, 77 - edited_at:, 78 - content:, 79 - // application:, 80 - account:, 81 - media_attachments:, 82 - mentions:, 83 - tags:, 84 - )) 85 - } 86 - 87 - // pub type StatusApplication { 88 - // StatusApplication(name: String, website: String) 89 - // } 90 - 91 - // pub fn status_application_decoder() -> decode.Decoder(StatusApplication) { 92 - // use name <- decode.field("name", decode.string) 93 - // use website <- decode.field("website", decode.string) 94 - // decode.success(StatusApplication(name:, website:)) 95 - // } 96 - 97 - pub type MastodonAccount { 98 - Account( 99 - id: String, 100 - username: String, 101 - acct: String, 102 - display_name: String, 103 - locked: Bool, 104 - bot: Bool, 105 - discoverable: Bool, 106 - indexable: Bool, 107 - group: Bool, 108 - created_at: String, 109 - note: String, 110 - url: String, 111 - uri: String, 112 - avatar: String, 113 - avatar_static: String, 114 - header: String, 115 - header_static: String, 116 - followers_count: Int, 117 - following_count: Int, 118 - statuses_count: Int, 119 - last_status_at: String, 120 - // hide_collections: Bool, 121 - // noindex: Bool, 122 - // emojis: todo[], 123 - // roles: todo[], 124 - // fields: todo[], 125 - ) 126 - } 127 - 128 - pub fn account_decoder() -> decode.Decoder(MastodonAccount) { 129 - use id <- decode.field("id", decode.string) 130 - use username <- decode.field("username", decode.string) 131 - use acct <- decode.field("acct", decode.string) 132 - use display_name <- decode.field("display_name", decode.string) 133 - use locked <- decode.field("locked", decode.bool) 134 - use bot <- decode.field("bot", decode.bool) 135 - use discoverable <- field_or( 136 - field: "discoverable", 137 - decoder: decode.bool, 138 - otherwise: False, 139 - ) 140 - use indexable <- decode.field("indexable", decode.bool) 141 - use group <- decode.field("group", decode.bool) 142 - use created_at <- decode.field("created_at", decode.string) 143 - use note <- decode.field("note", decode.string) 144 - use url <- decode.field("url", decode.string) 145 - use uri <- decode.field("uri", decode.string) 146 - use avatar <- decode.field("avatar", decode.string) 147 - use avatar_static <- decode.field("avatar_static", decode.string) 148 - use header <- decode.field("header", decode.string) 149 - use header_static <- decode.field("header_static", decode.string) 150 - use followers_count <- decode.field("followers_count", decode.int) 151 - use following_count <- decode.field("following_count", decode.int) 152 - use statuses_count <- decode.field("statuses_count", decode.int) 153 - use last_status_at <- decode.field("last_status_at", decode.string) 154 - // use hide_collections <- decode.field("hide_collections", decode.bool) 155 - // use noindex <- decode.field("noindex", decode.bool) 156 - decode.success(Account( 157 - id:, 158 - username:, 159 - acct:, 160 - display_name:, 161 - locked:, 162 - bot:, 163 - discoverable:, 164 - indexable:, 165 - group:, 166 - created_at:, 167 - note:, 168 - url:, 169 - uri:, 170 - avatar:, 171 - avatar_static:, 172 - header:, 173 - header_static:, 174 - followers_count:, 175 - following_count:, 176 - statuses_count:, 177 - last_status_at:, 178 - // hide_collections:, 179 - // noindex:, 180 - )) 181 - } 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 - 197 - pub type MastodonMentions { 198 - MastodonMentions(id: String, username: String, url: String, acct: String) 199 - } 200 - 201 - fn mentions_decoder() -> decode.Decoder(MastodonMentions) { 202 - use id <- decode.field("id", decode.string) 203 - use username <- decode.field("username", decode.string) 204 - use url <- decode.field("url", decode.string) 205 - use acct <- decode.field("acct", decode.string) 206 - decode.success(MastodonMentions(id:, username:, url:, acct:)) 207 - } 208 - 209 - pub type MastodonStatusContext { 210 - MastodonStatusContext(ancestors: List(MastodonStatus), descendants: List(MastodonStatus)) 211 - } 212 - 213 - pub fn status_context_decoder() -> decode.Decoder(MastodonStatusContext) { 214 - let dec = status_decoder() 215 - use ancestors <- decode.field("ancestors", decode.list(dec)) 216 - use descendants <- decode.field("descendants", decode.list(dec)) 217 - decode.success(MastodonStatusContext(ancestors:, descendants:)) 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 - }
+262
src/chilp/api_typing/new.gleam
··· 1 + import chilp/internal 2 + import gleam/dynamic/decode 3 + import gleam/list 4 + import gleam/result 5 + import gleam/time/timestamp 6 + import lustre/element 7 + 8 + /// A Bluesky Threadview, like what you get from `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://did:plc:jgtfsmv25thfs4zmydtbccnn/app.bsky.feed.post/3mgrbiiadws2k`. 9 + /// This one is very pruned! Why? Because these json responses are huge and we only need a small subset of the data in them! 10 + pub type BskyThreadView { 11 + BskyThreadView(at_uri: String, replies: List(BskyThreadReply)) 12 + } 13 + 14 + pub type Message { 15 + Message 16 + } 17 + 18 + pub type BskyThreadReply { 19 + BskyThreadReply( 20 + at_uri: String, 21 + like_count: Int, 22 + created_at: timestamp.Timestamp, 23 + body_text: String, 24 + author_did: String, 25 + author_handle: String, 26 + author_displayname: String, 27 + author_avatar: String, 28 + children: List(BskyThreadReply), 29 + ) 30 + } 31 + 32 + pub fn bsky_thread_view_decoder() -> decode.Decoder(BskyThreadView) { 33 + use at_uri <- decode.subfield(["thread", "post", "uri"], decode.string) 34 + use replies <- decode.subfield( 35 + ["thread", "replies"], 36 + decode.list(bsky_thread_reply_decoder()), 37 + ) 38 + decode.success(BskyThreadView(at_uri:, replies:)) 39 + } 40 + 41 + fn bsky_thread_reply_decoder() -> decode.Decoder(BskyThreadReply) { 42 + use created_at <- decode.subfield( 43 + ["post", "record", "created_at"], 44 + decode.map(decode.string, fn(stringstamp) { 45 + result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) 46 + }), 47 + ) 48 + use at_uri <- decode.subfield(["post", "uri"], decode.string) 49 + use body_text <- decode.subfield(["post", "record", "text"], decode.string) 50 + 51 + use author_did <- decode.subfield(["post", "author", "did"], decode.string) 52 + use author_handle <- decode.subfield( 53 + ["post", "author", "handle"], 54 + decode.string, 55 + ) 56 + use author_displayname <- decode.subfield( 57 + ["post", "author", "displayName"], 58 + decode.string, 59 + ) 60 + use author_avatar <- decode.subfield( 61 + ["post", "author", "avatar"], 62 + decode.string, 63 + ) 64 + use like_count <- decode.subfield(["post", "likeCount"], decode.int) 65 + use children <- decode.field( 66 + "replies", 67 + decode.list(bsky_thread_reply_decoder()), 68 + ) 69 + decode.success(BskyThreadReply( 70 + at_uri:, 71 + created_at:, 72 + body_text:, 73 + like_count:, 74 + author_did:, 75 + author_handle:, 76 + author_displayname:, 77 + author_avatar:, 78 + children:, 79 + )) 80 + } 81 + 82 + /// Subset of a Mastodon Status-context, like what you get from `https://pony.social/api/v1/statuses/115911235653686237/context`. 83 + pub type MastodonStatusContext { 84 + MastodonStatusContext(descendants: List(MastodonDescendant)) 85 + } 86 + 87 + pub fn mastodon_status_context_decoder( 88 + original_id: String, 89 + ) -> decode.Decoder(MastodonStatusContext) { 90 + use flat_descendants <- decode.field( 91 + "descendants", 92 + decode.list(mastodon_descendant_decoder()), 93 + ) 94 + let descendants: List(MastodonDescendant) = 95 + list.filter_map(flat_descendants, fn(desc) { 96 + case desc.1 == original_id { 97 + True -> { 98 + // A parent! 99 + 100 + mastodon_decendant_inflater(desc.0, flat_descendants) 101 + |> Ok 102 + } 103 + False -> { 104 + Error(Nil) 105 + } 106 + } 107 + }) 108 + decode.success(MastodonStatusContext(descendants:)) 109 + } 110 + 111 + fn mastodon_decendant_inflater( 112 + parent: MastodonDescendant, 113 + all_children: List(#(MastodonDescendant, String)), 114 + ) { 115 + MastodonDescendant( 116 + ..parent, 117 + children: list.filter_map(all_children, fn(c) { 118 + case c.1 == parent.id { 119 + True -> Ok(c.0) 120 + False -> Error(Nil) 121 + } 122 + }), 123 + ) 124 + } 125 + 126 + pub type MastodonDescendant { 127 + MastodonDescendant( 128 + id: String, 129 + uri: String, 130 + content: element.Element(Message), 131 + created_at: timestamp.Timestamp, 132 + favourite_count: Int, 133 + // This one is not populated by the decoder, Mastodon API provides the tree flat, with fields (replying_to) to tell you which is child and which is parent. 134 + // We gotta iterate over this later to get things nested. 135 + children: List(MastodonDescendant), 136 + author_url: String, 137 + author_avatar_url: String, 138 + author_username: String, 139 + author_displayname: String, 140 + ) 141 + } 142 + 143 + /// Decodes #(MastodonDescendant, ReplyingTo), to enable the parent decoder to make this into a recursive three. 144 + fn mastodon_descendant_decoder() -> decode.Decoder( 145 + #(MastodonDescendant, String), 146 + ) { 147 + use replying_to <- decode.field("in_reply_to_id", decode.string) 148 + use id <- decode.field("id", decode.string) 149 + use uri <- decode.field("uri", decode.string) 150 + use created_at <- decode.field( 151 + "created_at", 152 + decode.map(decode.string, fn(stringstamp) { 153 + result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch) 154 + }), 155 + ) 156 + use favourite_count <- decode.field("favourites_count", decode.int) 157 + use unescaped_html_content <- decode.field("content", decode.string) 158 + let content = internal.sanitise_ls(unescaped_html_content) 159 + 160 + let children = [] 161 + 162 + use author_url <- decode.subfield(["account", "url"], decode.string) 163 + use author_avatar_url <- decode.subfield(["account", "avatar"], decode.string) 164 + use author_displayname <- decode.subfield( 165 + ["account", "display_name"], 166 + decode.string, 167 + ) 168 + use author_username <- decode.subfield(["account", "acct"], decode.string) 169 + 170 + decode.success(#( 171 + MastodonDescendant( 172 + id:, 173 + uri:, 174 + content:, 175 + created_at:, 176 + favourite_count:, 177 + children:, 178 + author_url:, 179 + author_avatar_url:, 180 + author_username:, 181 + author_displayname:, 182 + ), 183 + replying_to, 184 + )) 185 + } 186 + 187 + pub fn coalesce_views( 188 + bsky: List(BskyThreadReply), 189 + mastodon: List(MastodonDescendant), 190 + ) { 191 + let mixed: List(Result(BskyThreadReply, MastodonDescendant)) = { 192 + list.append( 193 + list.map(bsky, fn(m) { Ok(m) }), 194 + list.map(mastodon, fn(m) { Error(m) }), 195 + ) 196 + |> list.shuffle 197 + } 198 + list.map(mixed, fn(item) { 199 + let coalesced: CoalescedView = case item { 200 + Ok(BskyThreadReply( 201 + created_at:, 202 + at_uri:, 203 + like_count:, 204 + body_text:, 205 + author_did:, 206 + author_displayname:, 207 + author_handle:, 208 + author_avatar:, 209 + children:, 210 + )) -> 211 + CoalescedView( 212 + created_at:, 213 + author_profile_link: "https://witchsky.app/profile/" <> author_did, 214 + source: "Bluesky", 215 + agreeability: like_count, 216 + content: element.text(body_text), 217 + author_username: author_handle, 218 + author_avatar_url: author_avatar, 219 + displayname: author_displayname, 220 + children: { coalesce_views(children, []) }, 221 + ) 222 + Error(MastodonDescendant( 223 + created_at:, 224 + id:, 225 + uri:, 226 + content:, 227 + favourite_count:, 228 + children:, 229 + author_url:, 230 + author_avatar_url:, 231 + author_username:, 232 + author_displayname:, 233 + )) -> 234 + CoalescedView( 235 + created_at:, 236 + author_profile_link: author_url, 237 + source: "Mastodon", 238 + displayname: author_displayname, 239 + agreeability: favourite_count, 240 + author_avatar_url:, 241 + author_username:, 242 + content: content, 243 + children: { coalesce_views([], children) }, 244 + ) 245 + } 246 + }) 247 + } 248 + 249 + pub type CoalescedView { 250 + 251 + CoalescedView( 252 + created_at: timestamp.Timestamp, 253 + author_profile_link: String, 254 + author_avatar_url: String, 255 + author_username: String, 256 + source: String, 257 + displayname: String, 258 + content: element.Element(Message), 259 + agreeability: Int, 260 + children: List(CoalescedView), 261 + ) 262 + }
-9
src/chilp/ffi.mjs
··· 1 1 export function lassign(url) { 2 2 window.location.assign(url); 3 3 } 4 - 5 - import DOMPurify from "https://esm.sh/dompurify@3.2.7"; 6 - 7 - export function sanitize(html) { 8 - return DOMPurify.sanitize(html, { 9 - ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "span", "p", "br"], 10 - ALLOWED_ATTR: ["href", "class", "target"], 11 - }); 12 - }
+115
src/chilp/internal.gleam
··· 1 + import gleam/list 2 + import gleam/string 3 + import html_parser 4 + import lustre/attribute 5 + import lustre/element 6 + import lustre/element/html 7 + 8 + /// Attempts to do what DOMpurify does... 9 + pub fn sanitise(html: String) -> String { 10 + // To a tree is the easy part. 11 + html_parser.as_tree(html) 12 + // Then reconstructing it sanely... 13 + |> sanitise_reconstruct 14 + } 15 + 16 + /// Attempts to do what DOMpurify does... ...while lustre-ifying it! 17 + pub fn sanitise_ls(html: String) -> element.Element(a) { 18 + // To a tree is the easy part. 19 + html_parser.as_tree(html) 20 + // Then reconstructing it sanely... 21 + |> sanitise_reconstruct_ls 22 + } 23 + 24 + fn sanitise_reconstruct(el: html_parser.Element) { 25 + case el { 26 + html_parser.EmptyElement -> "" 27 + html_parser.StartElement(name:, attributes:, children:) -> { 28 + // Opening tag 29 + let opener = case name { 30 + "b" -> "<b" 31 + "i" -> "<i" 32 + "em" -> "<em" 33 + "strong" -> "<strong" 34 + "a" -> "<a" 35 + "p" -> "<p" 36 + "br" -> "<br" 37 + "span" -> "<span" 38 + _ -> "" 39 + } 40 + opener 41 + <> list.map(attributes, fn(attrib) { 42 + case attrib { 43 + _ if opener == "" -> { 44 + "" 45 + } 46 + html_parser.Attribute(key: "href", value: link) -> 47 + " href=\"" <> link <> "\" " 48 + html_parser.Attribute(key: "class", value: classes) -> 49 + " class=\"" <> classes <> "\" " 50 + html_parser.Attribute(key: "target", value: target) -> 51 + " target=\"" <> target <> "\" " 52 + html_parser.Attribute(_, _) -> "" 53 + } 54 + }) 55 + |> string.concat() 56 + <> case opener == "" { 57 + False -> ">" 58 + True -> "" 59 + } 60 + <> list.map(children, sanitise_reconstruct) |> string.concat 61 + // Closing tag 62 + <> case name { 63 + "b" -> "</b>" 64 + "i" -> "</i>" 65 + "em" -> "</em>" 66 + "strong" -> "</strong>" 67 + "a" -> "</a>" 68 + "p" -> "</p>" 69 + "br" -> "</br>" 70 + "span" -> "</span>" 71 + _ -> "" 72 + } 73 + } 74 + // AFAIK we don't have this due to parsing as tree. 75 + html_parser.EndElement(_) -> "ERROR: Did not expect an element end here!" 76 + html_parser.Content(cnt) -> cnt 77 + } 78 + } 79 + 80 + fn sanitise_reconstruct_ls(el: html_parser.Element) -> element.Element(a) { 81 + case el { 82 + html_parser.EmptyElement -> element.none() 83 + html_parser.StartElement(name:, attributes:, children:) -> { 84 + // attributes 85 + let attribs = 86 + list.map(attributes, fn(attrib) { 87 + case attrib { 88 + html_parser.Attribute(key: "href", value: link) -> 89 + attribute.href(link) 90 + html_parser.Attribute(key: "class", value: classes) -> 91 + attribute.class(classes) 92 + html_parser.Attribute(key: "target", value: target) -> 93 + attribute.target(target) 94 + html_parser.Attribute(_, _) -> attribute.none() 95 + } 96 + }) 97 + list.map(children, sanitise_reconstruct_ls) 98 + |> case name { 99 + "b" -> html.b(attribs, _) 100 + "i" -> html.i(attribs, _) 101 + "em" -> html.em(attribs, _) 102 + "strong" -> html.strong(attribs, _) 103 + "a" -> html.a(attribs, _) 104 + "p" -> html.p(attribs, _) 105 + "br" -> fn(_) { html.br(attribs) } 106 + "span" -> html.span(attribs, _) 107 + _ -> element.fragment 108 + } 109 + } 110 + // AFAIK we don't have this due to parsing as tree. 111 + html_parser.EndElement(_) -> 112 + element.text("ERROR: Did not expect an element end here!") 113 + html_parser.Content(cnt) -> element.text(cnt) 114 + } 115 + }
+181 -301
src/chilp/widget.gleam
··· 1 1 // IMPORTS --------------------------------------------------------------------- 2 2 3 - import chilp/widget/base as widget 3 + import chilp/api_typing/new.{type BskyThreadReply, type MastodonDescendant} 4 + import chilp/widget/anchors 4 5 import gleam/bool 6 + import gleam/option.{type Option, None, Some} 5 7 import gleam/result 6 8 import gleam/string 7 9 import lustre ··· 9 11 import lustre/component 10 12 import lustre/effect.{type Effect} 11 13 import lustre/element.{type Element} 12 - import lustre/element/html 14 + import rsvp 13 15 14 16 // MAIN ------------------------------------------------------------------------ 15 17 ··· 18 20 // If you're looking for good examples, look there! 19 21 let component = 20 22 lustre.component(init, update, view, [ 21 - component.on_attribute_change("postpointer", fn(value) { 23 + component.on_attribute_change("mastodon-anchor", fn(value) { 24 + use <- bool.guard(when: value == "", return: Ok(MastodonUnAnchored)) 22 25 value 23 26 |> string.split_once(":") 24 - |> result.map(fn(a) { PostChanged(a.0, a.1) }) 27 + |> result.map(fn(a) { MastodonAnchored(a.0, a.1) }) 28 + }), 29 + component.on_attribute_change("bluesky-anchor", fn(value) { 30 + use <- bool.guard(when: value == "", return: Ok(BskyUnAnchored)) 31 + value 32 + |> string.split_once(":") 33 + |> result.map(fn(a) { BskyAnchored(a.0, a.1) }) 25 34 }), 26 35 ]) 27 36 ··· 29 38 } 30 39 31 40 pub fn element( 32 - instance instance: String, 33 - post_id post: String, 34 - with_styles styles: Bool, 41 + mastodon_anchor: Option(anchors.Mastodon), 42 + bsky_anchor: Option(anchors.Bluesky), 35 43 ) -> Element(msg) { 36 44 element.element( 37 45 "comment-widget", 38 46 [ 39 - attribute.attribute("postpointer", instance <> ":" <> post), 40 - attribute.attribute("styles-enabled", styles |> bool.to_string), 47 + attribute.attribute("mastodon-anchor", case mastodon_anchor { 48 + Some(anchor) -> anchor.instance <> ":" <> anchor.postid 49 + None -> "" 50 + }), 51 + attribute.attribute("bluesky-anchor", case bsky_anchor { 52 + Some(anchor) -> anchor.did <> ":" <> anchor.postid 53 + None -> "" 54 + }), 41 55 ], 42 56 [], 43 57 ) ··· 45 59 46 60 // MODEL ----------------------------------------------------------------------- 47 61 48 - type WrappingModel { 49 - WrappingModelSet( 50 - chilp_model: widget.ChilpDataInYourModel(Msg), 51 - styles: Bool, 52 - widget: widget.CommentWidget(Msg), 53 - ) 54 - WrappingModelUnset( 55 - chilp_model: widget.ChilpDataInYourModel(Msg), 56 - styles: Bool, 62 + type Model { 63 + Model( 64 + mastodon_anchor: Option(anchors.Mastodon), 65 + cached_mastodon_descendants: List(MastodonDescendant), 66 + bluesky_anchor: Option(anchors.Bluesky), 67 + cached_bluesky_replies: List(BskyThreadReply), 68 + all_stopping_error: Option(String), 69 + cached_coalesced_view: List(new.CoalescedView), 70 + /// For those not using DaisyUI, using it's hacky way of creating tabs is... hacky. 71 + /// So we do this the old school way. Tabs in DOM. 72 + /// This value will be ignored if the model only has one anchor. 73 + open_tab: Int, 57 74 ) 58 75 } 59 76 60 - fn init(_) -> #(WrappingModel, Effect(Msg)) { 61 - #(WrappingModelUnset(widget.init(ChilpMsgWrapper), True), effect.none()) 77 + fn init(_) -> #(Model, Effect(Msg)) { 78 + #( 79 + Model( 80 + mastodon_anchor: None, 81 + cached_mastodon_descendants: [], 82 + bluesky_anchor: None, 83 + cached_bluesky_replies: [], 84 + all_stopping_error: None, 85 + cached_coalesced_view: [], 86 + open_tab: 1, 87 + ), 88 + effect.none(), 89 + ) 62 90 } 63 91 64 92 // UPDATE ---------------------------------------------------------------------- 65 93 66 94 type Msg { 67 - PostChanged(instance: String, post: String) 68 - ChilpMsgWrapper(widget.ChilpMsg) 95 + MastodonUnAnchored 96 + MastodonAnchored(instance: String, post: String) 97 + BskyUnAnchored 98 + BskyAnchored(did: String, post: String) 99 + AllStoppingError(String) 100 + BskyIncomingThreadView(new.BskyThreadView) 101 + IncomingCoalescedView(List(new.CoalescedView)) 102 + MastodonIncomingStatus(new.MastodonStatusContext) 69 103 } 70 104 71 - fn update(model: WrappingModel, msg: Msg) -> #(WrappingModel, Effect(Msg)) { 105 + fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 72 106 case msg { 73 - PostChanged(instance, post) -> { 74 - let chilp_model = model.chilp_model 75 - let widget = widget.new(instance, post, chilp_model) 107 + AllStoppingError(msg) -> #( 108 + Model(..model, all_stopping_error: Some(msg)), 109 + effect.none(), 110 + ) 111 + MastodonUnAnchored -> #( 112 + Model(..model, mastodon_anchor: None), 113 + effect.none(), 114 + ) 115 + MastodonAnchored(instance:, post:) -> #( 116 + Model( 117 + ..model, 118 + mastodon_anchor: Some(anchors.Mastodon(instance:, postid: post)), 119 + ), 120 + refresh_mastodon(model), 121 + ) 122 + MastodonIncomingStatus(status) -> { 76 123 #( 77 - WrappingModelSet(widget:, chilp_model:, styles: model.styles), 78 - widget.force(widget, chilp_model:), 124 + Model(..model, cached_mastodon_descendants: status.descendants), 125 + new_coalesced_view(model.cached_bluesky_replies, status.descendants), 79 126 ) 80 127 } 81 - ChilpMsgWrapper(message) -> { 82 - let #(chilp_model, effects) = 83 - widget.update(message, model.chilp_model, browse) 84 - let new_model = case model { 85 - WrappingModelSet(_, styles, widget) -> 86 - WrappingModelSet(chilp_model:, styles:, widget:) 87 - WrappingModelUnset(_, styles:) -> 88 - WrappingModelUnset(chilp_model:, styles:) 89 - } 90 - #(new_model, effects) 91 - } 92 - } 93 - } 94 - 95 - @external(javascript, "./ffi.mjs", "lassign") 96 - fn js_browse(_: String) -> Nil { 97 - Nil 98 - } 99 128 100 - pub const css = ".chilp-widget { 101 - --highlight: #595aff; 102 - transition: all 0.5s ease; 103 - overflow: hidden; 104 - ::selection { 105 - background-color: rgba(89, 90, 255, 0.2); 106 - color: var(--highlight); 107 - } 108 - 109 - .widget::-webkit-scrollbar { 110 - width: 6px; 111 - } 112 - .widget::-webkit-scrollbar-thumb { 113 - background-color: #e2e8f0; 114 - border-radius: 10px; 115 - } 116 - 117 - .widget { 118 - max-width: 600px; 119 - margin: 2rem auto; 120 - background-color: floralwhite; 121 - font-family: sans-serif; 122 - padding: 2rem; 123 - border-radius: 12px; 124 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); 125 - } 126 - 127 - .widget > .btn-get-comments { 128 - display: block; 129 - width: 100%; 130 - max-width: 200px; 131 - margin: 2rem auto 0 auto; 132 - padding: 12px 24px; 133 - background-color: transparent; 134 - color: #595aff; 135 - border: 2px solid #595aff; 136 - border-radius: 8px; 137 - font-weight: 600; 138 - cursor: pointer; 139 - transition: all 0.2s ease-in-out; 140 - } 141 - 142 - .widget > .btn-get-comments:hover { 143 - background-color: #595aff; 144 - color: white; 145 - box-shadow: 0 4px 12px rgba(89, 90, 255, 0.3); 146 - transform: translateY(-1px); 147 - } 148 - 149 - h1.widget-header { 150 - font-size: 1.75rem; 151 - font-weight: 800; 152 - color: #1a202c; 153 - margin: 0 0 0.5rem 0; 154 - letter-spacing: -0.025em; 155 - } 156 - 157 - .subheader { 158 - font-size: 0.95rem; 159 - color: #718096; 160 - margin-bottom: 1.5rem; 161 - display: flex; 162 - align-items: center; 163 - gap: 6px; 164 - } 165 - 166 - .post-link { 167 - color: #595aff; 168 - text-decoration: none; 169 - font-weight: 500; 170 - border-bottom: 1px solid transparent; 171 - transition: border-color 0.2s; 172 - } 173 - 174 - .post-link:hover { 175 - border-bottom-color: #595aff; 176 - } 177 - 178 - .or-create-an-account-disclaimer { 179 - font-size: 0.73rem; 180 - color: #a0aec0; 181 - margin: -4px 0 12px 2px; 182 - font-style: italic; 183 - margin-bottom: 1.5rem; 184 - display: flex; 185 - align-items: center; 186 - gap: 6px; 187 - } 188 - 189 - .go-reply-form { 190 - display: flex; 191 - gap: 0; 192 - border-bottom: 1px solid #edf2f7; 193 - padding-bottom: 1.5rem; 194 - margin-bottom: 2rem; 195 - max-width: 500px; 196 - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); 197 - } 198 - 199 - .go-reply-form-input { 200 - flex: 1; 201 - padding: 10px 15px; 202 - border: 2px solid #e2e8f0; 203 - flex: 1; 204 - border-right: none; 205 - border-radius: 8px 0 0 8px; 206 - font-size: 0.95rem; 207 - outline: none; 208 - transition: border-color 0.2s; 209 - } 210 - 211 - .go-reply-form-input:focus { 212 - border-color: var(--highlight); 213 - } 214 - 215 - .go-reply-form-input::placeholder { 216 - color: #a0aec0; 217 - } 218 - .input-group { 219 - display: flex; 220 - flex-direction: column; 221 - width: 100%; 222 - gap: 6px; 129 + BskyUnAnchored -> #(Model(..model, bluesky_anchor: None), effect.none()) 130 + BskyAnchored(did:, post:) -> { 131 + let model = 132 + Model( 133 + ..model, 134 + bluesky_anchor: Some(anchors.Bluesky(did:, postid: post)), 135 + ) 136 + #(model, refresh_bsky(model)) 223 137 } 224 - 225 - .go-reply-label { 226 - font-size: 0.85rem; 227 - font-weight: 600; 228 - color: #4a5568; 229 - letter-spacing: 0.05em; 230 - margin-left: 2px; 138 + BskyIncomingThreadView(threadview) -> { 139 + #( 140 + Model(..model, cached_bluesky_replies: threadview.replies), 141 + new_coalesced_view( 142 + threadview.replies, 143 + model.cached_mastodon_descendants, 144 + ), 145 + ) 231 146 } 147 + // And then... everything comes together! 148 + IncomingCoalescedView(new) -> #( 149 + Model(..model, cached_coalesced_view: new), 150 + effect.none(), 151 + ) 152 + } 153 + } 232 154 233 - .form-controls { 234 - display: flex; 235 - width: 100%; 236 - flex-wrap: wrap; 237 - } 155 + // VIEW ------------------------------------------------------------------------ 238 156 239 - .go-reply-form-button { 240 - padding: 10px 20px; 241 - border: 2px solid var(--highlight); 242 - border-radius: 0 8px 8px 0; 243 - background-color: var(--highlight); 244 - color: white; 245 - font-weight: 600; 246 - cursor: pointer; 247 - transition: all 0.2s; 248 - white-space: nowrap; 249 - } 157 + fn view(model: Model) -> Element(Msg) { 158 + todo 159 + } 250 160 251 - .go-reply-form-button:hover { 252 - background-color: var(--highlight); 253 - border-color: var(--highlight); 254 - } 161 + // EFFECTS --------------------------------------------------------------------- 255 162 256 - @media (max-width: 480px) { 257 - .go-reply-form { 258 - flex-direction: column; 259 - gap: 8px; 260 - } 261 - .go-reply-form-input, 262 - .go-reply-form-button { 263 - border-radius: 8px; 264 - border: 2px solid #e2e8f0; 265 - } 266 - } 163 + fn new_coalesced_view( 164 + bsky_replies: List(BskyThreadReply), 165 + mastodon_descendants: List(MastodonDescendant), 166 + ) { 167 + use dispatch <- effect.from 168 + new.coalesce_views(bsky_replies, mastodon_descendants) 169 + |> IncomingCoalescedView 170 + |> dispatch 171 + } 267 172 268 - .comment-widget form { 269 - display: flex; 270 - gap: 10px; 271 - margin-bottom: 1.5rem; 272 - } 173 + fn refresh_bsky(model: Model) -> Effect(Msg) { 174 + case model.bluesky_anchor { 175 + Some(anchor) -> { 176 + let url = 177 + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://" 178 + <> anchor.did 179 + <> "/app.bsky.feed.post/" 180 + <> anchor.postid 273 181 274 - .comment-widget input[name=\"userinstance\"] { 275 - flex-grow: 1; 276 - padding: 8px 12px; 277 - border: 1px solid #ccc; 278 - border-radius: 4px; 182 + let handler = 183 + rsvp.expect_json(new.bsky_thread_view_decoder(), fn(response) { 184 + case response { 185 + Ok(threadview) -> BskyIncomingThreadView(threadview) 186 + Error(rsvperror) -> 187 + case rsvperror { 188 + rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 189 + AllStoppingError( 190 + "The response body we got back from Bluesky was misformed.", 191 + ) 192 + rsvp.BadUrl(_) -> 193 + AllStoppingError( 194 + "The API call to Bluesky failed. Did you enter the DID and post id correctly?", 195 + ) 196 + rsvp.HttpError(_) | rsvp.NetworkError -> 197 + AllStoppingError("Could not fetch comments from Bluesky.") 198 + } 199 + } 200 + }) 201 + rsvp.get(url, handler) 279 202 } 203 + None -> effect.none() 204 + } 205 + } 280 206 281 - .comment { 282 - border-left: 2px solid #eee; 283 - padding-left: 1rem; 284 - margin-top: 1.5rem; 285 - display: flex; 286 - flex-direction: column; 287 - } 288 - 289 - .comment header { 290 - display: flex; 291 - align-items: center; 292 - gap: 12px; 293 - margin-bottom: 8px; 294 - } 295 - 296 - .comment .avatar { 297 - width: 36px; 298 - height: 36px; 299 - border-radius: 4px; 300 - object-fit: cover; 301 - background: #eee; 302 - border: 1px solid rgba(0, 0, 0, 0.05); 303 - } 304 - 305 - .comment .display-name { 306 - font-weight: bold; 307 - display: block; 308 - } 309 - 310 - .comment time { 311 - font-size: 0.85rem; 312 - color: #666; 313 - } 314 - 315 - .comment .content { 316 - line-height: 1.5; 317 - } 318 - 319 - .comment .content p { 320 - margin: 0.5rem 0; 321 - } 322 - 323 - .comment .mention { 324 - color: var(--highlight); 325 - text-decoration: none; 326 - } 327 - 328 - .comment footer { 329 - margin-top: 8px; 330 - } 331 - 332 - .comment footer a { 333 - font-size: 0.8rem; 334 - color: #888; 335 - text-decoration: none; 336 - } 337 - 338 - .comment footer a:hover { 339 - text-decoration: underline; 340 - } 207 + fn refresh_mastodon(model: Model) -> Effect(Msg) { 208 + case model.mastodon_anchor { 209 + Some(anchor) -> { 210 + let url = 211 + "https://" 212 + <> anchor.instance 213 + <> "/api/v1/statuses/" 214 + <> anchor.postid 215 + <> "/context" 341 216 342 - .comment .comment { 343 - margin-left: 10px; 344 - border-left: 2px solid #ddd; 345 - } 346 - 347 - .error { 348 - color: red; 349 - font-size: smaller; 217 + let handler = 218 + rsvp.expect_json( 219 + new.mastodon_status_context_decoder(anchor.postid), 220 + fn(response) { 221 + case response { 222 + Ok(status) -> MastodonIncomingStatus(status) 223 + Error(rsvperror) -> 224 + case rsvperror { 225 + rsvp.UnhandledResponse(_) | rsvp.JsonError(_) | rsvp.BadBody -> 226 + AllStoppingError( 227 + "The response body we got back from Bluesky was misformed.", 228 + ) 229 + rsvp.BadUrl(_) -> 230 + AllStoppingError( 231 + "The API call to Bluesky failed. Did you enter the DID and post id correctly?", 232 + ) 233 + rsvp.HttpError(_) | rsvp.NetworkError -> 234 + AllStoppingError("Could not fetch comments from Bluesky.") 235 + } 236 + } 237 + }, 238 + ) 239 + rsvp.get(url, handler) 350 240 } 241 + None -> effect.none() 242 + } 351 243 } 352 - " 353 244 354 245 fn browse(to: String) { 355 246 use _ <- effect.from 356 247 js_browse(to) 357 248 } 358 249 359 - // VIEW ------------------------------------------------------------------------ 360 - 361 - fn view(model: WrappingModel) -> Element(Msg) { 362 - case model { 363 - WrappingModelSet(chilp_model:, widget:, styles: True) -> 364 - html.div([attribute.class("chilp-widget-component")], [ 365 - html.style([], css), 366 - widget.show(from: widget, data: chilp_model), 367 - ]) 368 - WrappingModelSet(chilp_model:, widget:, styles: False) -> 369 - html.div([attribute.class("chilp-widget-component")], [ 370 - widget.show(from: widget, data: chilp_model), 371 - ]) 372 - WrappingModelUnset(..) -> element.none() 373 - } 250 + // HELPERS --------------------------------------------------------------------- 251 + @external(javascript, "./ffi.mjs", "lassign") 252 + fn js_browse(_: String) -> Nil { 253 + Nil 374 254 }
+25
src/chilp/widget/anchors.gleam
··· 1 + pub type Mastodon { 2 + Mastodon( 3 + /// The instance name, e.g. mastodon.social 4 + instance: String, 5 + /// A post id to bind to, you'll find this in a post url `https://mastodon.social/@<username>/[postid]`. 6 + postid: String, 7 + ) 8 + } 9 + 10 + pub type Bluesky { 11 + Bluesky( 12 + /// Your DID, for `@strawmelonjuice.com`, this looks like `"did:plc:jgtfsmv25thfs4zmydtbccnn"`. 13 + /// 14 + /// Not sure how to find your DID? <https://bsky-did.neocities.org> is one of the many places where you can easily find it. 15 + did: String, 16 + /// A post id to bind to, you'll find this in a post url `https://bsky.app/profile/<your-username-or-did>/post/[postid]` 17 + postid: String, 18 + ) 19 + } 20 + 21 + @internal 22 + pub type ConnectionType { 23 + Bsky(Bluesky) 24 + Fedi(Mastodon) 25 + }
-650
src/chilp/widget/base.gleam
··· 1 - //// No-component widget 2 - //// This is the Mastodon Widget except much more customisable. This version might also influence your application logic a bit much. 3 - //// If you really want to customise, use this module, otherwise default to 4 - 5 - import chilp/api_typing 6 - import gleam/dict 7 - import gleam/int 8 - import gleam/list 9 - import gleam/option 10 - import gleam/order 11 - import gleam/pair 12 - import gleam/result 13 - import gleam/string 14 - import gleam/time/duration 15 - import gleam/time/timestamp 16 - import gleam/uri 17 - import lustre/attribute.{attribute} 18 - import lustre/effect 19 - import lustre/element 20 - import lustre/element/html 21 - import lustre/event 22 - import rsvp 23 - 24 - pub opaque type MastodonPost { 25 - MastodonPost(instance: String, postid: String) 26 - } 27 - 28 - /// Creates a comment widget, this is where you should probably start! 29 - /// 30 - /// This function takes three arguments: 31 - /// - `instance`: The instance name, e.g. mastodon.social 32 - /// - `postid`: A post id to bind to, you'll find this in a post url `https://mastodon.social/@<username>/[postid]`. 33 - /// - `messages`: Some messages that chilp needs to be able to send 34 - /// The resulting comment widget can be edited however you'd like, but is 35 - pub fn new( 36 - instance instance: String, 37 - post_id postid: String, 38 - chilp_model model: ChilpDataInYourModel(msg), 39 - ) -> CommentWidget(msg) { 40 - construct_new(instance, postid, model.message) 41 - } 42 - 43 - @internal 44 - pub fn construct_new( 45 - instance: String, 46 - postid: String, 47 - message_wrap: fn(ChilpMsg) -> a, 48 - ) -> CommentWidget(a) { 49 - let instancelist = [ 50 - instance, 51 - "mastodon.social", 52 - instance, 53 - "pony.social", 54 - instance, 55 - "todon.nl", 56 - instance, 57 - "mstdn.social", 58 - instance, 59 - ] 60 - let instanceplaceholder = { 61 - instancelist 62 - |> list.shuffle 63 - |> list.first 64 - |> result.unwrap("myinstance.social") 65 - } 66 - let post = MastodonPost(instance:, postid:) 67 - let set_message_get = Get(post) |> message_wrap 68 - let go_answer = fn(n) { 69 - let value = 70 - list.key_find(n, "userinstance") 71 - |> result.unwrap(instanceplaceholder) 72 - GoAnswer(value, post) |> message_wrap 73 - } 74 - CommentWidget( 75 - post:, 76 - instancelist:, 77 - recursion_limit: 3, 78 - emit_error: True, 79 - widget_header: #("Comments", [ 80 - attribute.classes([#("widget-header h1", True)]), 81 - ]), 82 - widget: [ 83 - attribute.classes([#("widget", True)]), 84 - ], 85 - load_button: [ 86 - event.on_click(set_message_get), 87 - attribute.classes([#("btn-get-comments", True)]), 88 - ], 89 - comments_section: [], 90 - go_reply_form: [ 91 - event.on_submit(go_answer), 92 - attribute.classes([#("go-reply-form", True)]), 93 - ], 94 - go_reply_text_box: [ 95 - attribute.type_("text"), 96 - attribute.placeholder(instanceplaceholder), 97 - attribute.name("userinstance"), 98 - attribute.classes([#("go-reply-form-input", True)]), 99 - attribute.pattern("^([a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}$"), 100 - attribute.required(True), 101 - ], 102 - go_reply_button: [ 103 - attribute.type_("submit"), 104 - attribute.classes([ 105 - #("go-reply-form-button", True), 106 - ]), 107 - ], 108 - comment_article_by_op: [attribute.class("comment comment-by-op")], 109 - comment_article: [attribute.class("comment")], 110 - comment_header: [], 111 - children_section: [], 112 - loading_span: [], 113 - avatar_img: [ 114 - attribute.class("avatar"), 115 - attribute.alt("@"), 116 - ], 117 - error_element: [attribute.class("chilp-error")], 118 - metadata_div: [attribute.class("meta")], 119 - displayname: [attribute.class("display-name")], 120 - written_at: [], 121 - content_section: [attribute.class("content")], 122 - comment_link: [], 123 - comment_footer: [], 124 - widget_subheader: [ 125 - attribute.classes([ 126 - #("subheader", True), 127 - ]), 128 - ], 129 - widget_subheader_link: [ 130 - attribute.classes([ 131 - #("post-link", True), 132 - ]), 133 - ], 134 - go_reply_label: [ 135 - attribute.class("go-reply-label"), 136 - attribute.for("userinstance"), 137 - ], 138 - or_create_an_account_link: [ 139 - attribute.classes([ 140 - #("post-link", True), 141 - ]), 142 - ], 143 - or_create_an_account_disclaimer: [ 144 - attribute.class("or-create-an-account-disclaimer"), 145 - attribute.for("userinstance"), 146 - ], 147 - ) 148 - } 149 - 150 - /// This is what will show your comment block. 151 - pub fn show( 152 - from attributes: CommentWidget(msg), 153 - data model: ChilpDataInYourModel(msg), 154 - ) -> element.Element(msg) { 155 - let model = model.inner 156 - html.div([attribute.class("chilp-widget")], [ 157 - html.div(attributes.widget, [ 158 - html.h1(attributes.widget_header.1, [ 159 - element.text(attributes.widget_header.0), 160 - ]), 161 - case dict.get(model.stati, attributes.post) { 162 - Ok(status) -> 163 - html.p(attributes.widget_subheader, [ 164 - element.text("Linked to "), 165 - html.a( 166 - attributes.widget_subheader_link 167 - |> list.append([attribute.href(status.url)]), 168 - [element.text("this post")], 169 - ), 170 - element.text(" on Mastodon."), 171 - ]) 172 - Error(_) -> element.none() 173 - }, 174 - html.form(attributes.go_reply_form, [ 175 - html.div([attribute.class("input-group")], [ 176 - html.label(attributes.go_reply_label, [ 177 - html.text("Enter your instance adress to reply or "), 178 - html.a( 179 - [ 180 - attribute.href( 181 - "https://" 182 - <> { 183 - attributes.instancelist 184 - |> list.shuffle 185 - |> list.first 186 - |> result.unwrap(attributes.post.instance) 187 - } 188 - <> "/auth/sign_up", 189 - ), 190 - ] 191 - |> list.append(attributes.or_create_an_account_link), 192 - [element.text("create an account")], 193 - ), 194 - element.text("!"), 195 - ]), 196 - html.p(attributes.or_create_an_account_disclaimer, [ 197 - element.text( 198 - "on an instance reccommended by this site... or one you pick yourself!", 199 - ), 200 - ]), 201 - html.div([attribute.class("form-controls")], [ 202 - html.input(attributes.go_reply_text_box), 203 - html.button(attributes.go_reply_button, [element.text("Go reply")]), 204 - ]), 205 - ]), 206 - ]), 207 - case 208 - dict.get(model.busy, attributes.post), 209 - dict.get(model.stati, attributes.post), 210 - dict.get(model.context, attributes.post) 211 - { 212 - _, Ok(status), Ok(context) -> 213 - view_commentlist(attributes, status, context.0) 214 - 215 - Ok(option.None), Error(_), _ | Ok(option.None), _, Error(_) -> 216 - html.span(attributes.loading_span, [ 217 - element.text("Loading comments..."), 218 - ]) 219 - 220 - Ok(option.Some(errorvalue)), Error(_), _ 221 - | Ok(option.Some(errorvalue)), _, Error(_) 222 - if attributes.emit_error == True 223 - -> html.pre(attributes.error_element, [element.text(errorvalue)]) 224 - 225 - // Post is not 'gotten' yet. 226 - _, _, _ -> 227 - html.button(attributes.load_button, [element.text("Load comments")]) 228 - }, 229 - ]), 230 - ]) 231 - } 232 - 233 - fn view_commentlist( 234 - attributes: CommentWidget(msg), 235 - status: api_typing.MastodonStatus, 236 - context: api_typing.MastodonStatusContext, 237 - ) { 238 - let sorted_descendants = 239 - context.descendants 240 - |> list.sort(fn(a, b) { 241 - case 242 - timestamp.parse_rfc3339(a.created_at), 243 - timestamp.parse_rfc3339(b.created_at) 244 - { 245 - Ok(a), Ok(b) -> timestamp.compare(a, b) 246 - _, _ -> order.Eq 247 - } 248 - }) 249 - |> list.sort(fn(a, b) { 250 - int.compare(a.favourites_count, b.favourites_count) 251 - }) 252 - html.section( 253 - attributes.comments_section, 254 - list.map(sorted_descendants, fn(comm: api_typing.MastodonStatus) -> element.Element( 255 - msg, 256 - ) { 257 - render_comment( 258 - attribs: attributes, 259 - comm_id: comm.id, 260 - recursion: 1, 261 - parent: status, 262 - original_parent: status, 263 - sorted_descendants:, 264 - ) 265 - }), 266 - ) 267 - } 268 - 269 - fn render_comment( 270 - attribs attribs: CommentWidget(msg), 271 - comm_id comm_id: String, 272 - recursion recursion: Int, 273 - parent parent: api_typing.MastodonStatus, 274 - original_parent original_parent: api_typing.MastodonStatus, 275 - sorted_descendants descendants: List(api_typing.MastodonStatus), 276 - ) { 277 - let comm_result = list.find(descendants, fn(comm_) { comm_.id == comm_id }) 278 - case comm_result, recursion <= attribs.recursion_limit { 279 - Ok(comm), True if comm.in_reply_to_id == parent.id -> { 280 - let children = case comm.replies_count == 0 { 281 - True -> [] 282 - False -> { 283 - list.filter(descendants, fn(comm_) { comm_.in_reply_to_id == comm.id }) 284 - |> list.map(fn(c) { 285 - render_comment( 286 - attribs:, 287 - comm_id: c.id, 288 - recursion: recursion + 1, 289 - parent: comm, 290 - original_parent:, 291 - sorted_descendants: descendants, 292 - ) 293 - }) 294 - } 295 - } 296 - view_comment( 297 - comm, 298 - // Is comment by op 299 - original_parent.account.id == comm.account.id 300 - && comm.account.note == parent.account.note, 301 - attribs, 302 - children, 303 - ) 304 - } 305 - _, _ -> element.none() 306 - } 307 - } 308 - 309 - fn view_comment( 310 - comment: api_typing.MastodonStatus, 311 - is_authors: Bool, 312 - attribs: CommentWidget(msg), 313 - children: List(element.Element(msg)), 314 - ) { 315 - html.article( 316 - case is_authors { 317 - True -> attribs.comment_article_by_op 318 - _ -> attribs.comment_article 319 - }, 320 - [ 321 - html.header(attribs.comment_header, [ 322 - html.img( 323 - list.append(attribs.avatar_img, [ 324 - attribute.src(comment.account.avatar), 325 - ]), 326 - ), 327 - html.div(attribs.metadata_div, [ 328 - html.span(attribs.displayname, [ 329 - element.text(comment.account.display_name), 330 - ]), 331 - html.time( 332 - [attribute("datetime", comment.created_at)] 333 - |> list.append(attribs.written_at), 334 - [ 335 - element.text({ 336 - let b = 337 - case 338 - timestamp.difference( 339 - timestamp.parse_rfc3339(comment.created_at) 340 - |> result.unwrap(timestamp.system_time()), 341 - timestamp.system_time(), 342 - ) 343 - |> duration.approximate 344 - |> pair.map_second(fn(d) { 345 - case d { 346 - duration.Nanosecond -> "nanosecond" 347 - duration.Microsecond -> "microsecond" 348 - duration.Millisecond -> "millisecond" 349 - duration.Second -> "second" 350 - duration.Minute -> "minute" 351 - duration.Hour -> "hour" 352 - duration.Day -> "day" 353 - duration.Week -> "week" 354 - duration.Month -> "month" 355 - duration.Year -> "year" 356 - } 357 - }) 358 - { 359 - #(1, x) -> #(1, x) 360 - #(x, d) -> #(x, d <> "s") 361 - } 362 - |> pair.map_first(int.to_string) 363 - 364 - b.0 <> " " <> b.1 <> " ago." 365 - }), 366 - ], 367 - ), 368 - ]), 369 - ]), 370 - html.section(attribs.content_section, [ 371 - element.unsafe_raw_html("", "span", [], comment.content |> sanitize), 372 - ]), 373 - html.footer([], [ 374 - html.a( 375 - [attribute.href(comment.url)] |> list.append(attribs.comment_link), 376 - [ 377 - element.text("View comment on Mastodon"), 378 - ], 379 - ), 380 - html.section(attribs.children_section, children), 381 - ]), 382 - ], 383 - ) 384 - } 385 - 386 - @external(javascript, "../ffi.mjs", "sanitize") 387 - fn sanitize(html: String) -> String { 388 - // On erlang, there's a lot less risk. 389 - html 390 - } 391 - 392 - /// Allows you to edit your widget, you can replace or append to any values here. 393 - /// Do note, removing stuff might remove functionality! 394 - /// By default, some Tailwind/DaisyUI classes are added. 395 - pub type CommentWidget(msg) { 396 - CommentWidget( 397 - /// The post this widget is for, you should just keep this. 398 - post: MastodonPost, 399 - /// Limit on comment depth. 400 - recursion_limit: Int, 401 - /// On error, print the error to the DOM? 402 - emit_error: Bool, 403 - /// Widget header value, by default "Comments", and it's attributes 404 - widget_header: #(String, List(attribute.Attribute(msg))), 405 - /// The top element of the widget itself. 406 - widget: List(attribute.Attribute(msg)), 407 - /// [Load comments]-button 408 - load_button: List(attribute.Attribute(msg)), 409 - /// The actual area the comments show up in 410 - comments_section: List(attribute.Attribute(msg)), 411 - children_section: List(attribute.Attribute(msg)), 412 - go_reply_form: List(attribute.Attribute(msg)), 413 - go_reply_label: List(attribute.Attribute(msg)), 414 - go_reply_text_box: List(attribute.Attribute(msg)), 415 - go_reply_button: List(attribute.Attribute(msg)), 416 - /// Applied to the <header> area of a comment. 417 - comment_article: List(attribute.Attribute(msg)), 418 - /// Applied to the <header> area of a comment posted by the parent's poster. 419 - comment_article_by_op: List(attribute.Attribute(msg)), 420 - comment_header: List(attribute.Attribute(msg)), 421 - loading_span: List(attribute.Attribute(msg)), 422 - avatar_img: List(attribute.Attribute(msg)), 423 - error_element: List(attribute.Attribute(msg)), 424 - metadata_div: List(attribute.Attribute(msg)), 425 - displayname: List(attribute.Attribute(msg)), 426 - written_at: List(attribute.Attribute(msg)), 427 - content_section: List(attribute.Attribute(msg)), 428 - comment_link: List(attribute.Attribute(msg)), 429 - /// Footer of the comment, containing the comment url and comment's children. 430 - comment_footer: List(attribute.Attribute(msg)), 431 - widget_subheader: List(attribute.Attribute(msg)), 432 - widget_subheader_link: List(attribute.Attribute(msg)), 433 - or_create_an_account_link: List(attribute.Attribute(msg)), 434 - or_create_an_account_disclaimer: List(attribute.Attribute(msg)), 435 - /// Used to randomnise the 'Or create an account' link. 436 - instancelist: List(String), 437 - ) 438 - } 439 - 440 - /// Trigger forces the widget to load in data before the user clicked the button. 441 - /// This is something you'll want if you know beforehand which post comments to display. 442 - pub fn trigger( 443 - on on: CommentWidget(msg), 444 - chilp_model model: ChilpDataInYourModel(msg), 445 - ) -> msg { 446 - model.message(Get(on.post)) 447 - } 448 - 449 - /// Force is like `trigger`, except returns the Effect instead of the message, allowing you to embed it in your init or update function instead of in your view. 450 - /// This is something you'll want if you know beforehand which post comments to display. 451 - pub fn force( 452 - on on: CommentWidget(msg), 453 - chilp_model model: ChilpDataInYourModel(msg), 454 - ) { 455 - get(on.post, model) 456 - } 457 - 458 - /// This stores metadata that is handled internally by Chilp 459 - /// You should store this on your model! 460 - /// 461 - pub opaque type ChilpDataInYourModel(msg) { 462 - ChilpDataInYourModel(message: fn(ChilpMsg) -> msg, inner: ChilpModel) 463 - } 464 - 465 - pub opaque type ChilpModel { 466 - ChilpModel( 467 - stati: dict.Dict(MastodonPost, api_typing.MastodonStatus), 468 - context: dict.Dict(MastodonPost, #(api_typing.MastodonStatusContext, Float)), 469 - busy: dict.Dict(MastodonPost, option.Option(String)), 470 - ) 471 - } 472 - 473 - pub fn init(message message: fn(ChilpMsg) -> msg) { 474 - ChilpDataInYourModel( 475 - message:, 476 - inner: ChilpModel(stati: dict.new(), context: dict.new(), busy: dict.new()), 477 - ) 478 - } 479 - 480 - /// Chilp's widget needs to be able to send these messages to your update function, and you should handle them with `chilp/widget.update(ChilpMsg)`. 481 - pub opaque type ChilpMsg { 482 - Get(MastodonPost) 483 - Save(ChilpModel) 484 - GoAnswer(instance: String, to: MastodonPost) 485 - } 486 - 487 - /// Gets all the metadata to work with in order to show your comments! 488 - fn get( 489 - post: MastodonPost, 490 - data: ChilpDataInYourModel(msg), 491 - ) -> effect.Effect(msg) { 492 - let handles = fn(m) { data.message(Save(m)) } 493 - // Tell `show()` we're on it. 494 - let notify = fn() { 495 - effect.from(fn(dispatch) { 496 - dispatch( 497 - handles(ChilpModel( 498 - stati: dict.new(), 499 - context: dict.new(), 500 - busy: dict.from_list([#(post, option.None)]), 501 - )), 502 - ) 503 - }) 504 - } 505 - effect.batch([ 506 - notify(), 507 - get_post(post, handles), 508 - get_context(post, handles), 509 - ]) 510 - } 511 - 512 - fn get_post( 513 - post: MastodonPost, 514 - message: fn(ChilpModel) -> msg, 515 - ) -> effect.Effect(msg) { 516 - let url = "https://" <> post.instance <> "/api/v1/statuses/" <> post.postid 517 - let handle_response = fn(s) { 518 - case s { 519 - Ok(status) -> { 520 - ChilpModel( 521 - stati: dict.from_list([#(post, status)]), 522 - context: dict.new(), 523 - busy: dict.new(), 524 - ) 525 - } 526 - Error(e) -> 527 - ChilpModel( 528 - dict.new(), 529 - dict.new(), 530 - dict.from_list([ 531 - #( 532 - post, 533 - option.Some(string.inspect(e) <> "\n\nWhile looking at: " <> url), 534 - ), 535 - ]), 536 - ) 537 - } 538 - |> message 539 - } 540 - let handler = rsvp.expect_json(api_typing.status_decoder(), handle_response) 541 - rsvp.get(url, handler) 542 - } 543 - 544 - fn get_context( 545 - post: MastodonPost, 546 - message: fn(ChilpModel) -> msg, 547 - ) -> effect.Effect(msg) { 548 - let url = 549 - "https://" 550 - <> post.instance 551 - <> "/api/v1/statuses/" 552 - <> post.postid 553 - <> "/context" 554 - let handle_response = fn(c) { 555 - case c { 556 - Ok(context) -> { 557 - let now = timestamp.system_time() |> timestamp.to_unix_seconds() 558 - ChilpModel( 559 - stati: dict.new(), 560 - context: dict.from_list([#(post, #(context, now))]), 561 - busy: dict.new(), 562 - ) 563 - } 564 - Error(e) -> 565 - ChilpModel( 566 - dict.new(), 567 - dict.new(), 568 - dict.from_list([ 569 - #( 570 - post, 571 - option.Some( 572 - string.inspect(e) 573 - <> "\n\nWhile looking at: " 574 - <> url 575 - <> "\n\nWant to report this? File an issue ", 576 - ), 577 - ), 578 - ]), 579 - ) 580 - } 581 - |> message 582 - } 583 - 584 - let handler = 585 - rsvp.expect_json(api_typing.status_context_decoder(), handle_response) 586 - rsvp.get(url, handler) 587 - } 588 - 589 - /// The update handler for chilp-specific messages! 590 - /// 591 - /// It takes in three values: 592 - /// - `message`: The message it handles 593 - /// - `model`: the chilp data from your model 594 - /// - `change_url`: A side-effect! This may not ever be called, but when it does, know that it should take that string, and browse the user to it. 595 - pub fn update( 596 - message: ChilpMsg, 597 - model: ChilpDataInYourModel(msg), 598 - change_url: fn(String) -> effect.Effect(msg), 599 - ) -> #(ChilpDataInYourModel(msg), effect.Effect(msg)) { 600 - case message { 601 - Get(post) -> { 602 - #(model, get(post, model)) 603 - } 604 - Save(addedmodel) -> { 605 - let #(o_stati, o_context) = uncloth(model.inner) 606 - let #(n_stati, n_context) = uncloth(addedmodel) 607 - let stati = list.append(o_stati, n_stati) |> dict.from_list 608 - // I just loved overcomplicating it too much. 609 - let context = list.append(o_context, n_context) |> dict.from_list 610 - 611 - let busy = dict.combine(addedmodel.busy, model.inner.busy, option.or) 612 - 613 - #( 614 - ChilpDataInYourModel( 615 - ..model, 616 - inner: ChilpModel(stati:, context:, busy:), 617 - ), 618 - effect.none(), 619 - ) 620 - } 621 - GoAnswer(instance:, to:) -> { 622 - let s = dict.get(model.inner.stati, to) 623 - case s { 624 - Ok(post) -> { 625 - change_url({ 626 - "https://" 627 - <> instance 628 - <> "/authorize_interaction?uri=" 629 - <> { post.url |> uri.percent_encode } 630 - }) 631 - #(model, effect.none()) 632 - } 633 - Error(_) -> #(model, effect.none()) 634 - } 635 - } 636 - } 637 - } 638 - 639 - fn uncloth( 640 - m: ChilpModel, 641 - ) -> #( 642 - List(#(MastodonPost, api_typing.MastodonStatus)), 643 - List(#(MastodonPost, #(api_typing.MastodonStatusContext, Float))), 644 - ) { 645 - case m { 646 - ChilpModel(stati:, context:, ..) -> { 647 - #(dict.to_list(stati), dict.to_list(context)) 648 - } 649 - } 650 - }