···16161717Further documentation can be found at <https://hexdocs.pm/chilp>.
18181919+## Styling
2020+2121+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!
2222+1923## Development
20242125```sh
2226gleam run # Run the project
2327gleam test # Run the tests
2424-```2828+```
···11+name = "lustre_chilp_app_autoload"
22+version = "1.0.0"
33+44+# Fill out these fields if you intend to generate HTML documentation or publish
55+# your project to the Hex package manager.
66+#
77+# description = ""
88+# licences = ["Apache-2.0"]
99+# repository = { type = "github", user = "", repo = "" }
1010+# links = [{ title = "Website", href = "" }]
1111+#
1212+# For a full reference of all the available options, you can have a look at
1313+# https://gleam.run/writing-gleam/gleam-toml/.
1414+1515+[dependencies]
1616+gleam_stdlib = ">= 0.44.0 and < 2.0.0"
1717+chilp = {path = "../.."}
1818+lustre = ">= 5.5.2 and < 6.0.0"
1919+2020+[dev-dependencies]
2121+gleeunit = ">= 1.0.0 and < 2.0.0"
2222+lustre_dev_tools = ">= 2.3.4 and < 3.0.0"
2323+[tools.lustre.html]
2424+stylesheets = [
2525+ { href = "styles.css" }
2626+]
···11+// Same example as
22+33+// IMPORTS ---------------------------------------------------------------------
44+55+import chilp/widget
66+import lustre
77+import lustre/effect.{type Effect}
88+import lustre/element.{type Element}
99+1010+// MAIN ------------------------------------------------------------------------
1111+1212+pub fn main() {
1313+ // In this example, we're not making much sense. Just Chilp.
1414+ let app = lustre.application(init, update, view)
1515+ let assert Ok(_) = lustre.start(app, "#app", Nil)
1616+1717+ Nil
1818+}
1919+2020+// MODEL -----------------------------------------------------------------------
2121+2222+type Model {
2323+ Model(
2424+ string: String,
2525+ // .. and other things your application would need to know, for chilp we have:
2626+ chilp_model: widget.ChilpDataInYourModel(Msg),
2727+ // A widget we pre-create in the init function, this could also be done inside of
2828+ // your update function, and you don't NEED to store the widget data itself in your
2929+ // model, you are allowed to call `widget.new()` twice and it'll create the same widget.
3030+ my_widget: widget.CommentWidget(Msg),
3131+ )
3232+}
3333+3434+fn init(_) -> #(Model, Effect(Msg)) {
3535+ // Let's create a widget!
3636+ // In this case we create the widget and let it travel with the model, but just creating it twice works too!
3737+ let chilp_model = widget.init(ChilpMessage)
3838+ let my_widget =
3939+ widget.new(
4040+ instance: "mastodon.social",
4141+ post_id: "115978549407058619",
4242+ chilp_model:,
4343+ )
4444+ let model = Model(string: "Hi", chilp_model:, my_widget:)
4545+ let effect = widget.force(chilp_model:, on: my_widget)
4646+4747+ #(model, effect)
4848+}
4949+5050+// HELPERS----------------------------------------------------------------------
5151+@external(javascript, "./ffi_lustre_chilp_app_autoload.mjs", "lassign")
5252+fn js_browse(to: String) -> Nil {
5353+ Nil
5454+}
5555+5656+fn browse(to: String) {
5757+ let _ = js_browse(to) == Nil
5858+ effect.none()
5959+}
6060+6161+// UPDATE ----------------------------------------------------------------------
6262+6363+type Msg {
6464+ ChilpMessage(widget.ChilpMsg)
6565+}
6666+6767+// You can't usually make `ChilpMsg`s, with a few exceptions.
6868+//
6969+// One of them being `widget.trigger()`, which does what `widget.force()` does but instead of an effect it returns a `ChilpMsg`!
7070+fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
7171+ case msg {
7272+ // Normally, your own variants would be here too.
7373+ ChilpMessage(message) -> {
7474+ let #(chilp_model, effect) =
7575+ widget.update(message, model.chilp_model, browse)
7676+ #(Model(..model, chilp_model:), effect)
7777+ }
7878+ }
7979+}
8080+8181+// VIEW ------------------------------------------------------------------------
8282+8383+fn view(model: Model) -> Element(Msg) {
8484+ // Render the widget we made in init().
8585+ widget.show(model.my_widget, model.chilp_model)
8686+}
-1
gleam.toml
···1111lustre = ">= 5.5.2 and < 6.0.0"
1212rsvp = ">= 1.2.0 and < 2.0.0"
1313gleam_time = ">= 1.7.0 and < 2.0.0"
1414-glentities = ">= 6.2.1 and < 7.0.0"
15141615[dev-dependencies]
1716gleeunit = ">= 1.0.0 and < 2.0.0"
-2
manifest.toml
···1212 { name = "gleam_stdlib", version = "0.68.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F7FAEBD8EF260664E86A46C8DBA23508D1D11BB3BCC6EE1B89B3BC3E5C83FF1E" },
1313 { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" },
1414 { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
1515- { name = "glentities", version = "6.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glentities", source = "hex", outer_checksum = "78A0B28789C1A7840468C683FC9588B0B59AA38BE8CF5DACD1AF2E60A91AE638" },
1615 { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
1716 { 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" },
1817 { 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" },
···2322gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
2423gleam_time = { version = ">= 1.7.0 and < 2.0.0" }
2524gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
2626-glentities = { version = ">= 6.2.1 and < 7.0.0" }
2725lustre = { version = ">= 5.5.2 and < 6.0.0" }
2826rsvp = { version = ">= 1.2.0 and < 2.0.0" }
+44-9
src/chilp/api_typing.gleam
···99 id: String,
1010 /// E.g. 2026-01-17T15:51:34.812Z
1111 created_at: String,
1212- // in_reply_to_id: String,
1212+ in_reply_to_id: String,
1313 // in_reply_to_account_id: String,
1414 sensitive: Bool,
1515 spoiler_text: String,
···2727 account: Account,
2828 media_attachments: List(String),
2929 mentions: List(Mentions),
3030- tags: List(String),
3030+ tags: List(StatusTag),
3131 )
3232}
33333434pub fn status_decoder() -> decode.Decoder(Status) {
3535 use id <- decode.field("id", decode.string)
3636 use created_at <- decode.field("created_at", decode.string)
3737+ use in_reply_to_id <- field_or(
3838+ field: "in_reply_to_id",
3939+ decoder: decode.string,
4040+ otherwise: "",
4141+ )
3742 use sensitive <- decode.field("sensitive", decode.bool)
3843 use spoiler_text <- decode.field("spoiler_text", decode.string)
3944 use visibility <- decode.field("visibility", decode.string)
4040- use language <- decode.field("language", decode.string)
4545+ use language <- field_or("language", decode.string, "")
4146 use uri <- decode.field("uri", decode.string)
4247 use url <- decode.field("url", decode.string)
4348 use replies_count <- decode.field("replies_count", decode.int)
···4651 use quotes_count <- decode.field("quotes_count", decode.int)
4752 use edited_at <- decode.field("edited_at", decode.optional(decode.string))
4853 use content <- decode.field("content", decode.string)
5454+ // let content = glentities.decode(content)
4955 // use application <- decode.field("application", status_application_decoder())
5056 use account <- decode.field("account", account_decoder())
5157 use media_attachments <- decode.field(
···5359 decode.list(decode.string),
5460 )
5561 use mentions <- decode.field("mentions", decode.list(mentions_decoder()))
5656- use tags <- decode.field("tags", decode.list(decode.string))
6262+ use tags <- field_or("tags", decode.list(status_tag_decoder()), [])
5763 decode.success(Status(
5864 id:,
5965 created_at:,
6666+ in_reply_to_id:,
6067 sensitive:,
6168 spoiler_text:,
6269 visibility:,
···110117 following_count: Int,
111118 statuses_count: Int,
112119 last_status_at: String,
113113- hide_collections: Bool,
120120+ // hide_collections: Bool,
114121 // noindex: Bool,
115122 // emojis: todo[],
116123 // roles: todo[],
···125132 use display_name <- decode.field("display_name", decode.string)
126133 use locked <- decode.field("locked", decode.bool)
127134 use bot <- decode.field("bot", decode.bool)
128128- use discoverable <- decode.field("discoverable", decode.bool)
135135+ use discoverable <- field_or(
136136+ field: "discoverable",
137137+ decoder: decode.bool,
138138+ otherwise: False,
139139+ )
129140 use indexable <- decode.field("indexable", decode.bool)
130141 use group <- decode.field("group", decode.bool)
131142 use created_at <- decode.field("created_at", decode.string)
···140151 use following_count <- decode.field("following_count", decode.int)
141152 use statuses_count <- decode.field("statuses_count", decode.int)
142153 use last_status_at <- decode.field("last_status_at", decode.string)
143143- use hide_collections <- decode.field("hide_collections", decode.bool)
154154+ // use hide_collections <- decode.field("hide_collections", decode.bool)
144155 // use noindex <- decode.field("noindex", decode.bool)
145156 decode.success(Account(
146157 id:,
···164175 following_count:,
165176 statuses_count:,
166177 last_status_at:,
167167- hide_collections:,
168168- // noindex:,
178178+ // hide_collections:,
179179+ // noindex:,
169180 ))
170181}
171182183183+fn field_or(
184184+ field field: String,
185185+ decoder field_decoder: decode.Decoder(t),
186186+ otherwise default: t,
187187+ next next: fn(t) -> decode.Decoder(final),
188188+) -> decode.Decoder(final) {
189189+ use val <- decode.optional_field(
190190+ field,
191191+ option.None,
192192+ decode.optional(field_decoder),
193193+ )
194194+ next(val |> option.unwrap(default))
195195+}
196196+172197pub type Mentions {
173198 Mentions(id: String, username: String, url: String, acct: String)
174199}
···191216 use descendants <- decode.field("descendants", decode.list(dec))
192217 decode.success(StatusContext(ancestors:, descendants:))
193218}
219219+220220+pub type StatusTag {
221221+ StatusTag(name: String, url: String)
222222+}
223223+224224+fn status_tag_decoder() -> decode.Decoder(StatusTag) {
225225+ use name <- decode.field("name", decode.string)
226226+ use url <- decode.field("url", decode.string)
227227+ decode.success(StatusTag(name:, url:))
228228+}
+407-125
src/chilp/widget.gleam
···22import gleam/dict
33import gleam/int
44import gleam/list
55+import gleam/option
56import gleam/order
67import gleam/pair
78import gleam/result
99+import gleam/string
810import gleam/time/duration
911import gleam/time/timestamp
1010-import glentities
1212+import gleam/uri
1113import lustre/attribute.{attribute}
1214import lustre/effect
1315import lustre/element
···2527/// - `instance`: The instance name, e.g. mastodon.social
2628/// - `postid`: A post id to bind to, you'll find this in a post url `https://mastodon.social/@<username>/[postid]`.
2729/// - `messages`: Some messages that chilp needs to be able to send
3030+/// The resulting comment widget can be edited however you'd like, but is
2831pub fn new(
2932 instance instance: String,
3030- postid postid: String,
3333+ post_id postid: String,
3134 chilp_model model: ChilpDataInYourModel(msg),
3235) -> CommentWidget(msg) {
3636+ let instancelist = [
3737+ instance,
3838+ "mastodon.social",
3939+ instance,
4040+ "pony.social",
4141+ instance,
4242+ "todon.nl",
4343+ instance,
4444+ "mstdn.social",
4545+ instance,
4646+ ]
4747+ let instanceplaceholder = {
4848+ instancelist
4949+ |> list.shuffle
5050+ |> list.first
5151+ |> result.unwrap("myinstance.social")
5252+ }
3353 let post = MastodonPost(instance:, postid:)
3454 let set_message_get = Get(post) |> model.message
3555 let go_answer = fn(n) {
3656 let value =
3757 list.key_find(n, "userinstance")
3838- |> result.unwrap("mastodon.social")
5858+ |> result.unwrap(instanceplaceholder)
3959 GoAnswer(value, post) |> model.message
4060 }
4161 CommentWidget(
4262 post:,
4343- widget: [attribute.classes([])],
6363+ instancelist:,
6464+ recursion_limit: 3,
6565+ emit_error: True,
6666+ widget_header: #("Comments", [
6767+ attribute.classes([#("widget-header h1", True)]),
6868+ ]),
6969+ widget: [
7070+ attribute.classes([#("widget", True)]),
7171+ ],
4472 load_button: [
4573 event.on_click(set_message_get),
4646- attribute.classes([#("btn btn-outline btn-primary", True)]),
7474+ attribute.classes([#("btn-get-comments", True)]),
4775 ],
4876 comments_section: [],
4949- go_reply_form: [event.on_submit(go_answer)],
5050- go_reply_text_box: [attribute.type_("text"), attribute.name("userinstance")],
7777+ go_reply_form: [
7878+ event.on_submit(go_answer),
7979+ attribute.classes([#("go-reply-form", True)]),
8080+ ],
8181+ go_reply_text_box: [
8282+ attribute.type_("text"),
8383+ attribute.placeholder(instanceplaceholder),
8484+ attribute.name("userinstance"),
8585+ attribute.classes([#("go-reply-form-input", True)]),
8686+ attribute.pattern("^([a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}$"),
8787+ attribute.required(True),
8888+ ],
5189 go_reply_button: [
5290 attribute.type_("submit"),
5353- attribute.classes([#("btn btn-outline btn-primary", True)]),
9191+ attribute.classes([
9292+ #("go-reply-form-button", True),
9393+ ]),
9494+ ],
9595+ comment_article_by_op: [attribute.class("comment comment-by-op")],
9696+ comment_article: [attribute.class("comment")],
9797+ comment_header: [],
9898+ children_section: [],
9999+ loading_span: [],
100100+ avatar_img: [
101101+ attribute.class("avatar"),
102102+ attribute.alt("@"),
103103+ ],
104104+ error_element: [attribute.class("chilp-error")],
105105+ metadata_div: [attribute.class("meta")],
106106+ displayname: [attribute.class("display-name")],
107107+ written_at: [],
108108+ content_section: [attribute.class("content")],
109109+ comment_link: [],
110110+ comment_footer: [],
111111+ widget_subheader: [
112112+ attribute.classes([
113113+ #("subheader", True),
114114+ ]),
115115+ ],
116116+ widget_subheader_link: [
117117+ attribute.classes([
118118+ #("post-link", True),
119119+ ]),
120120+ ],
121121+ go_reply_label: [
122122+ attribute.class("go-reply-label"),
123123+ attribute.for("userinstance"),
124124+ ],
125125+ or_create_an_account_link: [
126126+ attribute.classes([
127127+ #("post-link", True),
128128+ ]),
129129+ ],
130130+ or_create_an_account_disclaimer: [
131131+ attribute.class("or-create-an-account-disclaimer"),
132132+ attribute.for("userinstance"),
54133 ],
55134 )
56135}
···61140 data model: ChilpDataInYourModel(msg),
62141) -> element.Element(msg) {
63142 let model = model.inner
6464-6565- html.div(
6666- attributes.widget,
6767- case
6868- dict.get(model.stati, attributes.post),
6969- dict.get(model.context, attributes.post)
7070- {
7171- // Yeah so fun thing is, we just want the status itself to compare usernames with
7272- Ok(status), Ok(context) -> {
7373- [
7474- html.form(attributes.go_reply_form, [
143143+ html.div([attribute.class("chilp-widget")], [
144144+ html.div(attributes.widget, [
145145+ html.h1(attributes.widget_header.1, [
146146+ element.text(attributes.widget_header.0),
147147+ ]),
148148+ case dict.get(model.stati, attributes.post) {
149149+ Ok(status) ->
150150+ html.p(attributes.widget_subheader, [
151151+ element.text("Linked to "),
152152+ html.a(
153153+ attributes.widget_subheader_link
154154+ |> list.append([attribute.href(status.url)]),
155155+ [element.text("this post")],
156156+ ),
157157+ element.text(" on Mastodon."),
158158+ ])
159159+ Error(_) -> element.none()
160160+ },
161161+ html.form(attributes.go_reply_form, [
162162+ html.div([attribute.class("input-group")], [
163163+ html.label(attributes.go_reply_label, [
164164+ html.text("Enter your instance adress to reply or "),
165165+ html.a(
166166+ [
167167+ attribute.href(
168168+ "https://"
169169+ <> {
170170+ attributes.instancelist
171171+ |> list.shuffle
172172+ |> list.first
173173+ |> result.unwrap(attributes.post.instance)
174174+ }
175175+ <> "/auth/sign_up",
176176+ ),
177177+ ]
178178+ |> list.append(attributes.or_create_an_account_link),
179179+ [element.text("create an account")],
180180+ ),
181181+ element.text("!"),
182182+ ]),
183183+ html.p(attributes.or_create_an_account_disclaimer, [
184184+ element.text(
185185+ "on an instance reccommended by this site... or one you pick yourself!",
186186+ ),
187187+ ]),
188188+ html.div([attribute.class("form-controls")], [
75189 html.input(attributes.go_reply_text_box),
7676- html.button(attributes.go_reply_button, [html.text("Go reply")]),
190190+ html.button(attributes.go_reply_button, [element.text("Go reply")]),
77191 ]),
7878- view_commentlist(attributes, status, context.0),
7979- ]
8080- }
8181- _, _ -> {
192192+ ]),
193193+ ]),
194194+ case
195195+ dict.get(model.busy, attributes.post),
196196+ dict.get(model.stati, attributes.post),
197197+ dict.get(model.context, attributes.post)
198198+ {
199199+ _, Ok(status), Ok(context) ->
200200+ view_commentlist(attributes, status, context.0)
201201+202202+ Ok(option.None), Error(_), _ | Ok(option.None), _, Error(_) ->
203203+ html.span(attributes.loading_span, [
204204+ element.text("Loading comments..."),
205205+ ])
206206+207207+ Ok(option.Some(errorvalue)), Error(_), _
208208+ | Ok(option.Some(errorvalue)), _, Error(_)
209209+ if attributes.emit_error == True
210210+ -> html.pre(attributes.error_element, [element.text(errorvalue)])
211211+82212 // Post is not 'gotten' yet.
8383- [html.button(attributes.load_button, [html.text("Load comments")])]
8484- }
8585- },
8686- )
213213+ _, _, _ ->
214214+ html.button(attributes.load_button, [element.text("Load comments")])
215215+ },
216216+ ]),
217217+ ])
87218}
8821989220fn view_commentlist(
···91222 status: api_typing.Status,
92223 context: api_typing.StatusContext,
93224) {
225225+ let sorted_descendants =
226226+ context.descendants
227227+ |> list.sort(fn(a, b) {
228228+ case
229229+ timestamp.parse_rfc3339(a.created_at),
230230+ timestamp.parse_rfc3339(b.created_at)
231231+ {
232232+ Ok(a), Ok(b) -> timestamp.compare(a, b)
233233+ _, _ -> order.Eq
234234+ }
235235+ })
236236+ |> list.sort(fn(a, b) {
237237+ int.compare(a.favourites_count, b.favourites_count)
238238+ })
94239 html.section(
95240 attributes.comments_section,
9696- list.map(
9797- context.descendants
9898- |> list.sort(fn(a, b) {
9999- case
100100- timestamp.parse_rfc3339(a.created_at),
101101- timestamp.parse_rfc3339(b.created_at)
102102- {
103103- Ok(a), Ok(b) -> timestamp.compare(a, b)
104104- _, _ -> order.Eq
105105- }
106106- })
107107- |> list.sort(fn(a, b) {
108108- int.compare(a.favourites_count, b.favourites_count)
109109- }),
110110- fn(comm) {
111111- view_comment(comm, {
112112- // Is comment by op
113113- status.account.id == comm.account.id
114114- && comm.account.note == status.account.note
115115- })
116116- },
117117- ),
241241+ list.map(sorted_descendants, fn(comm: api_typing.Status) -> element.Element(
242242+ msg,
243243+ ) {
244244+ render_comment(
245245+ attribs: attributes,
246246+ comm_id: comm.id,
247247+ recursion: 1,
248248+ parent: status,
249249+ original_parent: status,
250250+ sorted_descendants:,
251251+ )
252252+ }),
118253 )
119254}
120255121121-fn view_comment(comment: api_typing.Status, is_authors: Bool) {
122122- html.article([attribute.class("comment")], [
123123- html.header([], [
124124- html.img([
125125- attribute.class("avatar"),
126126- attribute.alt("@"),
127127- attribute.src(comment.account.avatar),
128128- ]),
129129- html.div([attribute.class("meta")], [
130130- html.span([attribute.class("display-name")], [
131131- html.text(comment.account.display_name),
132132- ]),
133133- html.time([attribute("datetime", comment.created_at)], [
134134- html.text({
135135- let b =
136136- case
137137- timestamp.difference(
138138- timestamp.parse_rfc3339(comment.created_at)
139139- |> result.unwrap(timestamp.system_time()),
140140- timestamp.system_time(),
141141- )
142142- |> duration.approximate
143143- |> pair.map_second(fn(d) {
144144- case d {
145145- duration.Nanosecond -> "nanosecond"
146146- duration.Microsecond -> "microsecond"
147147- duration.Millisecond -> "millisecond"
148148- duration.Second -> "second"
149149- duration.Minute -> "minute"
150150- duration.Hour -> "hour"
151151- duration.Day -> "day"
152152- duration.Week -> "week"
153153- duration.Month -> "month"
154154- duration.Year -> "year"
256256+fn render_comment(
257257+ attribs attribs: CommentWidget(msg),
258258+ comm_id comm_id: String,
259259+ recursion recursion: Int,
260260+ parent parent: api_typing.Status,
261261+ original_parent original_parent: api_typing.Status,
262262+ sorted_descendants descendants: List(api_typing.Status),
263263+) {
264264+ let comm_result = list.find(descendants, fn(comm_) { comm_.id == comm_id })
265265+ case comm_result, recursion <= attribs.recursion_limit {
266266+ Ok(comm), True if comm.in_reply_to_id == parent.id -> {
267267+ let children = case comm.replies_count == 0 {
268268+ True -> []
269269+ False -> {
270270+ list.filter(descendants, fn(comm_) { comm_.in_reply_to_id == comm.id })
271271+ |> list.map(fn(c) {
272272+ render_comment(
273273+ attribs:,
274274+ comm_id: c.id,
275275+ recursion: recursion + 1,
276276+ parent: comm,
277277+ original_parent:,
278278+ sorted_descendants: descendants,
279279+ )
280280+ })
281281+ }
282282+ }
283283+ view_comment(
284284+ comm,
285285+ // Is comment by op
286286+ original_parent.account.id == comm.account.id
287287+ && comm.account.note == parent.account.note,
288288+ attribs,
289289+ children,
290290+ )
291291+ }
292292+ _, _ -> element.none()
293293+ }
294294+}
295295+296296+fn view_comment(
297297+ comment: api_typing.Status,
298298+ is_authors: Bool,
299299+ attribs: CommentWidget(msg),
300300+ children: List(element.Element(msg)),
301301+) {
302302+ html.article(
303303+ case is_authors {
304304+ True -> attribs.comment_article_by_op
305305+ _ -> attribs.comment_article
306306+ },
307307+ [
308308+ html.header(attribs.comment_header, [
309309+ html.img(
310310+ list.append(attribs.avatar_img, [
311311+ attribute.src(comment.account.avatar),
312312+ ]),
313313+ ),
314314+ html.div(attribs.metadata_div, [
315315+ html.span(attribs.displayname, [
316316+ element.text(comment.account.display_name),
317317+ ]),
318318+ html.time(
319319+ [attribute("datetime", comment.created_at)]
320320+ |> list.append(attribs.written_at),
321321+ [
322322+ element.text({
323323+ let b =
324324+ case
325325+ timestamp.difference(
326326+ timestamp.parse_rfc3339(comment.created_at)
327327+ |> result.unwrap(timestamp.system_time()),
328328+ timestamp.system_time(),
329329+ )
330330+ |> duration.approximate
331331+ |> pair.map_second(fn(d) {
332332+ case d {
333333+ duration.Nanosecond -> "nanosecond"
334334+ duration.Microsecond -> "microsecond"
335335+ duration.Millisecond -> "millisecond"
336336+ duration.Second -> "second"
337337+ duration.Minute -> "minute"
338338+ duration.Hour -> "hour"
339339+ duration.Day -> "day"
340340+ duration.Week -> "week"
341341+ duration.Month -> "month"
342342+ duration.Year -> "year"
343343+ }
344344+ })
345345+ {
346346+ #(1, x) -> #(1, x)
347347+ #(x, d) -> #(x, d <> "s")
155348 }
156156- })
157157- {
158158- #(1, x) -> #(1, x)
159159- #(x, d) -> #(x, d)
160160- }
161161- |> pair.map_first(int.to_string)
349349+ |> pair.map_first(int.to_string)
162350163163- b.0 <> " " <> b.1 <> " ago."
164164- }),
351351+ b.0 <> " " <> b.1 <> " ago."
352352+ }),
353353+ ],
354354+ ),
165355 ]),
166356 ]),
167167- ]),
168168- html.section([attribute.class("content")], [
169169- html.p([], [
170170- element.unsafe_raw_html(
171171- "",
172172- "span",
173173- [],
174174- glentities.decode(comment.content) |> sanitize,
357357+ html.section(attribs.content_section, [
358358+ element.unsafe_raw_html("", "span", [], comment.content |> sanitize),
359359+ ]),
360360+ html.footer([], [
361361+ html.a(
362362+ [attribute.href(comment.url)] |> list.append(attribs.comment_link),
363363+ [
364364+ element.text("View comment on Mastodon"),
365365+ ],
175366 ),
176176- ]),
177177- ]),
178178- html.footer([], [
179179- html.a([attribute.href(comment.url)], [
180180- html.text("View on Mastodon"),
367367+ html.section(attribs.children_section, children),
181368 ]),
182182- ]),
183183- ])
369369+ ],
370370+ )
184371}
185372186373@external(javascript, "./purify_bind_ffi.mjs", "sanitize")
···196383 CommentWidget(
197384 /// The post this widget is for, you should just keep this.
198385 post: MastodonPost,
386386+ /// Limit on comment depth.
387387+ recursion_limit: Int,
388388+ /// On error, print the error to the DOM?
389389+ emit_error: Bool,
390390+ /// Widget header value, by default "Comments", and it's attributes
391391+ widget_header: #(String, List(attribute.Attribute(msg))),
199392 /// The top element of the widget itself.
200393 widget: List(attribute.Attribute(msg)),
201394 /// [Load comments]-button
202395 load_button: List(attribute.Attribute(msg)),
203396 /// The actual area the comments show up in
204397 comments_section: List(attribute.Attribute(msg)),
398398+ children_section: List(attribute.Attribute(msg)),
205399 go_reply_form: List(attribute.Attribute(msg)),
400400+ go_reply_label: List(attribute.Attribute(msg)),
206401 go_reply_text_box: List(attribute.Attribute(msg)),
207402 go_reply_button: List(attribute.Attribute(msg)),
403403+ /// Applied to the <header> area of a comment.
404404+ comment_article: List(attribute.Attribute(msg)),
405405+ /// Applied to the <header> area of a comment posted by the parent's poster.
406406+ comment_article_by_op: List(attribute.Attribute(msg)),
407407+ comment_header: List(attribute.Attribute(msg)),
408408+ loading_span: List(attribute.Attribute(msg)),
409409+ avatar_img: List(attribute.Attribute(msg)),
410410+ error_element: List(attribute.Attribute(msg)),
411411+ metadata_div: List(attribute.Attribute(msg)),
412412+ displayname: List(attribute.Attribute(msg)),
413413+ written_at: List(attribute.Attribute(msg)),
414414+ content_section: List(attribute.Attribute(msg)),
415415+ comment_link: List(attribute.Attribute(msg)),
416416+ /// Footer of the comment, containing the comment url and comment's children.
417417+ comment_footer: List(attribute.Attribute(msg)),
418418+ widget_subheader: List(attribute.Attribute(msg)),
419419+ widget_subheader_link: List(attribute.Attribute(msg)),
420420+ or_create_an_account_link: List(attribute.Attribute(msg)),
421421+ or_create_an_account_disclaimer: List(attribute.Attribute(msg)),
422422+ /// Used to randomnise the 'Or create an account' link.
423423+ instancelist: List(String),
208424 )
209425}
210426211211-///
212427/// Trigger forces the widget to load in data before the user clicked the button.
213428/// This is something you'll want if you know beforehand which post comments to display.
214429pub fn trigger(
215430 on on: CommentWidget(msg),
216431 chilp_model model: ChilpDataInYourModel(msg),
432432+) -> msg {
433433+ model.message(Get(on.post))
434434+}
435435+436436+/// 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.
437437+/// This is something you'll want if you know beforehand which post comments to display.
438438+pub fn force(
439439+ on on: CommentWidget(msg),
440440+ chilp_model model: ChilpDataInYourModel(msg),
217441) {
218218- model.message(Get(on.post))
442442+ get(on.post, model)
219443}
220444221445/// This stores metadata that is handled internally by Chilp
···228452 ChilpModel(
229453 stati: dict.Dict(MastodonPost, api_typing.Status),
230454 context: dict.Dict(MastodonPost, #(api_typing.StatusContext, Float)),
455455+ busy: dict.Dict(MastodonPost, option.Option(String)),
231456 )
232457}
233458234459pub fn init(message message: fn(ChilpMsg) -> msg) {
235460 ChilpDataInYourModel(
236461 message:,
237237- inner: ChilpModel(stati: dict.new(), context: dict.new()),
462462+ inner: ChilpModel(stati: dict.new(), context: dict.new(), busy: dict.new()),
238463 )
239464}
240465···246471}
247472248473/// Gets all the metadata to work with in order to show your comments!
249249-pub fn get(
474474+fn get(
250475 post: MastodonPost,
251476 data: ChilpDataInYourModel(msg),
252477) -> effect.Effect(msg) {
253478 let handles = fn(m) { data.message(Save(m)) }
254254- effect.batch([get_post(post, handles), get_context(post, handles)])
479479+ // Tell `show()` we're on it.
480480+ let notify = fn() {
481481+ effect.from(fn(dispatch) {
482482+ dispatch(
483483+ handles(ChilpModel(
484484+ stati: dict.new(),
485485+ context: dict.new(),
486486+ busy: dict.from_list([#(post, option.None)]),
487487+ )),
488488+ )
489489+ })
490490+ }
491491+ effect.batch([
492492+ notify(),
493493+ get_post(post, handles),
494494+ get_context(post, handles),
495495+ ])
255496}
256497257498fn get_post(
258499 post: MastodonPost,
259500 message: fn(ChilpModel) -> msg,
260501) -> effect.Effect(msg) {
502502+ let url = "https://" <> post.instance <> "/api/v1/statuses/" <> post.postid
261503 let handle_response = fn(s) {
262504 case s {
263505 Ok(status) -> {
264506 ChilpModel(
265507 stati: dict.from_list([#(post, status)]),
266508 context: dict.new(),
509509+ busy: dict.new(),
267510 )
268511 }
269269- Error(_) -> ChilpModel(dict.new(), dict.new())
512512+ Error(e) ->
513513+ ChilpModel(
514514+ dict.new(),
515515+ dict.new(),
516516+ dict.from_list([
517517+ #(
518518+ post,
519519+ option.Some(string.inspect(e) <> "\n\nWhile looking at: " <> url),
520520+ ),
521521+ ]),
522522+ )
270523 }
271524 |> message
272525 }
273273- let url = "https://" <> post.instance <> "/api/v1/statuses/" <> post.postid
274526 let handler = rsvp.expect_json(api_typing.status_decoder(), handle_response)
275527 rsvp.get(url, handler)
276528}
···279531 post: MastodonPost,
280532 message: fn(ChilpModel) -> msg,
281533) -> effect.Effect(msg) {
534534+ let url =
535535+ "https://"
536536+ <> post.instance
537537+ <> "/api/v1/statuses/"
538538+ <> post.postid
539539+ <> "/context"
282540 let handle_response = fn(c) {
283541 case c {
284542 Ok(context) -> {
···286544 ChilpModel(
287545 stati: dict.new(),
288546 context: dict.from_list([#(post, #(context, now))]),
547547+ busy: dict.new(),
289548 )
290549 }
291291- Error(_) -> ChilpModel(dict.new(), dict.new())
550550+ Error(e) ->
551551+ ChilpModel(
552552+ dict.new(),
553553+ dict.new(),
554554+ dict.from_list([
555555+ #(
556556+ post,
557557+ option.Some(
558558+ string.inspect(e)
559559+ <> "\n\nWhile looking at: "
560560+ <> url
561561+ <> "\n\nWant to report this? File an issue ",
562562+ ),
563563+ ),
564564+ ]),
565565+ )
292566 }
293567 |> message
294568 }
295295- let url =
296296- "https://"
297297- <> post.instance
298298- <> "/api/v1/statuses/"
299299- <> post.postid
300300- <> "/context"
569569+301570 let handler =
302571 rsvp.expect_json(api_typing.status_context_decoder(), handle_response)
303572 rsvp.get(url, handler)
···325594 // I just loved overcomplicating it too much.
326595 let context = list.append(o_context, n_context) |> dict.from_list
327596597597+ let busy = dict.combine(addedmodel.busy, model.inner.busy, option.or)
598598+328599 #(
329329- ChilpDataInYourModel(..model, inner: ChilpModel(stati:, context:)),
600600+ ChilpDataInYourModel(
601601+ ..model,
602602+ inner: ChilpModel(stati:, context:, busy:),
603603+ ),
330604 effect.none(),
331605 )
332606 }
···335609 case s {
336610 Ok(post) -> {
337611 change_url({
338338- "https://" <> instance <> "/authorize_interaction?uri=" <> post.url
612612+ "https://"
613613+ <> instance
614614+ <> "/authorize_interaction?uri="
615615+ <> { post.url |> uri.percent_encode }
339616 })
340617 #(model, effect.none())
341618 }
···345622 }
346623}
347624348348-fn uncloth(m: ChilpModel) {
625625+fn uncloth(
626626+ m: ChilpModel,
627627+) -> #(
628628+ List(#(MastodonPost, api_typing.Status)),
629629+ List(#(MastodonPost, #(api_typing.StatusContext, Float))),
630630+) {
349631 case m {
350350- ChilpModel(stati:, context:) -> {
632632+ ChilpModel(stati:, context:, ..) -> {
351633 #(dict.to_list(stati), dict.to_list(context))
352634 }
353635 }