···11+# Changelog
22+33+## Changes from v1 to v2
44+55+Change in one line:
66+> Chilp v1 was wrapping itself in a Lustre component, Chilp v2 _is_ a Lustre component.
77+88+- Add Bluesky as a anchor option, equal to Mastodon.
99+- 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.
1010+- 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
···11-*.beam
22-*.ez
33-/build
44-erl_crash.dump
55-66-#Added automatically by Lustre Dev Tools
77-/.lustre
88-/dist
-23
examples/lustre_chilp_app_nocomponent/gleam.toml
···11-name = "lustre_chilp_app"
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-glentities = ">= 6.2.1 and < 7.0.0"
2020-2121-[dev-dependencies]
2222-gleeunit = ">= 1.0.0 and < 2.0.0"
2323-lustre_dev_tools = ">= 2.3.4 and < 3.0.0"
···11-// IMPORTS ---------------------------------------------------------------------
22-33-import chilp/widget/base
44-import lustre
55-import lustre/effect.{type Effect}
66-import lustre/element.{type Element}
77-88-// MAIN ------------------------------------------------------------------------
99-1010-pub fn main() {
1111- // In this example, we're not making much sense. Just Chilp.
1212- let app = lustre.application(init, update, view)
1313- let assert Ok(_) = lustre.start(app, "#app", Nil)
1414-1515- Nil
1616-}
1717-1818-// MODEL -----------------------------------------------------------------------
1919-2020-type Model {
2121- Model(
2222- string: String,
2323- // .. and other things your application would need to know, for chilp we have:
2424- chilp_model: base.ChilpDataInYourModel(Msg),
2525- )
2626-}
2727-2828-fn init(_) -> #(Model, Effect(Msg)) {
2929- let model = Model(string: "Hi", chilp_model: base.init(ChilpMessage))
3030- // No effects, though you could force Chilp to pre-fetch a post with base.force()!
3131- let effect = effect.none()
3232-3333- #(model, effect)
3434-}
3535-3636-// HELPERS----------------------------------------------------------------------
3737-@external(javascript, "./ffi_lustre_chilp_app.mjs", "lassign")
3838-fn js_browse(_: String) -> Nil {
3939- Nil
4040-}
4141-4242-fn browse(to: String) {
4343- use _ <- effect.from
4444- js_browse(to)
4545-}
4646-4747-// UPDATE ----------------------------------------------------------------------
4848-4949-type Msg {
5050- ChilpMessage(base.ChilpMsg)
5151-}
5252-5353-// You can't usually make `ChilpMsg`s, with a few exceptions.
5454-//
5555-// One of them being `base.trigger()`, which does what `base.force()` does but instead of an effect it returns a `ChilpMsg`!
5656-fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
5757- case msg {
5858- // Normally, your own variants would be here too.
5959- ChilpMessage(message) -> {
6060- let #(chilp_model, effect) =
6161- base.update(message, model.chilp_model, browse)
6262- #(Model(..model, chilp_model:), effect)
6363- }
6464- }
6565-}
6666-6767-// VIEW ------------------------------------------------------------------------
6868-6969-fn view(model: Model) -> Element(Msg) {
7070- // Let's render comments under https://pony.social/@strawmelonjuice/115911235653686237 and nothing else
7171- base.new("pony.social", "115911235653686237", model.chilp_model)
7272- |> base.show(model.chilp_model)
7373-}
···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+html_parser = ">= 1.0.1 and < 2.0.0"
14151516[dev-dependencies]
1617gleeunit = ">= 1.0.0 and < 2.0.0"
+2
manifest.toml
···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 = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
1616+ { name = "html_parser", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "html_parser", source = "hex", outer_checksum = "EEC0A3891CE99A49A8BB99086A06F56441D2ACF9436CE33ADBE51CE277D2D607" },
1617 { 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" },
1718 { 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" },
1819]
···2223gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
2324gleam_time = { version = ">= 1.7.0 and < 2.0.0" }
2425gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
2626+html_parser = { version = ">= 1.0.1 and < 2.0.0" }
2527lustre = { version = ">= 5.5.2 and < 6.0.0" }
2628rsvp = { version = ">= 1.2.0 and < 2.0.0" }
+2-1
src/chilp.gleam
···55///
66/// This component adds in inline CSS, to not do this, use `widget.element("bla", "111", False)` instead.
77pub fn widget(instance instance: String, post_id post: String) {
88- widget.element(instance:, post_id: post, with_styles: True)
88+ todo
99+ // widget.element(instance:, post_id: post, with_styles: True)
910}
-228
src/chilp/api_typing.gleam
···11-//// Module to match and type JSON data from the endpoints, I just typed these by hand, so if innacurate, they might bug out.
22-33-import gleam/dynamic/decode
44-import gleam/option.{type Option}
55-66-/// A status, like the ones you get from urls like: https://pony.social/api/v1/statuses/115911235653686237/
77-pub type MastodonStatus {
88- MastodonStatus(
99- id: String,
1010- /// E.g. 2026-01-17T15:51:34.812Z
1111- created_at: String,
1212- in_reply_to_id: String,
1313- // in_reply_to_account_id: String,
1414- sensitive: Bool,
1515- spoiler_text: String,
1616- visibility: String,
1717- language: String,
1818- uri: String,
1919- url: String,
2020- replies_count: Int,
2121- reblogs_count: Int,
2222- favourites_count: Int,
2323- quotes_count: Int,
2424- edited_at: Option(String),
2525- content: String,
2626- // application: StatusApplication,
2727- account: MastodonAccount,
2828- media_attachments: List(String),
2929- mentions: List(MastodonMentions),
3030- tags: List(StatusTag),
3131- )
3232-}
3333-3434-pub fn status_decoder() -> decode.Decoder(MastodonStatus) {
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- )
4242- use sensitive <- decode.field("sensitive", decode.bool)
4343- use spoiler_text <- decode.field("spoiler_text", decode.string)
4444- use visibility <- decode.field("visibility", decode.string)
4545- use language <- field_or("language", decode.string, "")
4646- use uri <- decode.field("uri", decode.string)
4747- use url <- decode.field("url", decode.string)
4848- use replies_count <- decode.field("replies_count", decode.int)
4949- use reblogs_count <- decode.field("reblogs_count", decode.int)
5050- use favourites_count <- decode.field("favourites_count", decode.int)
5151- use quotes_count <- decode.field("quotes_count", decode.int)
5252- use edited_at <- decode.field("edited_at", decode.optional(decode.string))
5353- use content <- decode.field("content", decode.string)
5454- // let content = glentities.decode(content)
5555- // use application <- decode.field("application", status_application_decoder())
5656- use account <- decode.field("account", account_decoder())
5757- use media_attachments <- decode.field(
5858- "media_attachments",
5959- decode.list(decode.string),
6060- )
6161- use mentions <- decode.field("mentions", decode.list(mentions_decoder()))
6262- use tags <- field_or("tags", decode.list(status_tag_decoder()), [])
6363- decode.success(MastodonStatus(
6464- id:,
6565- created_at:,
6666- in_reply_to_id:,
6767- sensitive:,
6868- spoiler_text:,
6969- visibility:,
7070- language:,
7171- uri:,
7272- url:,
7373- replies_count:,
7474- reblogs_count:,
7575- favourites_count:,
7676- quotes_count:,
7777- edited_at:,
7878- content:,
7979- // application:,
8080- account:,
8181- media_attachments:,
8282- mentions:,
8383- tags:,
8484- ))
8585-}
8686-8787-// pub type StatusApplication {
8888-// StatusApplication(name: String, website: String)
8989-// }
9090-9191-// pub fn status_application_decoder() -> decode.Decoder(StatusApplication) {
9292-// use name <- decode.field("name", decode.string)
9393-// use website <- decode.field("website", decode.string)
9494-// decode.success(StatusApplication(name:, website:))
9595-// }
9696-9797-pub type MastodonAccount {
9898- Account(
9999- id: String,
100100- username: String,
101101- acct: String,
102102- display_name: String,
103103- locked: Bool,
104104- bot: Bool,
105105- discoverable: Bool,
106106- indexable: Bool,
107107- group: Bool,
108108- created_at: String,
109109- note: String,
110110- url: String,
111111- uri: String,
112112- avatar: String,
113113- avatar_static: String,
114114- header: String,
115115- header_static: String,
116116- followers_count: Int,
117117- following_count: Int,
118118- statuses_count: Int,
119119- last_status_at: String,
120120- // hide_collections: Bool,
121121- // noindex: Bool,
122122- // emojis: todo[],
123123- // roles: todo[],
124124- // fields: todo[],
125125- )
126126-}
127127-128128-pub fn account_decoder() -> decode.Decoder(MastodonAccount) {
129129- use id <- decode.field("id", decode.string)
130130- use username <- decode.field("username", decode.string)
131131- use acct <- decode.field("acct", decode.string)
132132- use display_name <- decode.field("display_name", decode.string)
133133- use locked <- decode.field("locked", decode.bool)
134134- use bot <- decode.field("bot", decode.bool)
135135- use discoverable <- field_or(
136136- field: "discoverable",
137137- decoder: decode.bool,
138138- otherwise: False,
139139- )
140140- use indexable <- decode.field("indexable", decode.bool)
141141- use group <- decode.field("group", decode.bool)
142142- use created_at <- decode.field("created_at", decode.string)
143143- use note <- decode.field("note", decode.string)
144144- use url <- decode.field("url", decode.string)
145145- use uri <- decode.field("uri", decode.string)
146146- use avatar <- decode.field("avatar", decode.string)
147147- use avatar_static <- decode.field("avatar_static", decode.string)
148148- use header <- decode.field("header", decode.string)
149149- use header_static <- decode.field("header_static", decode.string)
150150- use followers_count <- decode.field("followers_count", decode.int)
151151- use following_count <- decode.field("following_count", decode.int)
152152- use statuses_count <- decode.field("statuses_count", decode.int)
153153- use last_status_at <- decode.field("last_status_at", decode.string)
154154- // use hide_collections <- decode.field("hide_collections", decode.bool)
155155- // use noindex <- decode.field("noindex", decode.bool)
156156- decode.success(Account(
157157- id:,
158158- username:,
159159- acct:,
160160- display_name:,
161161- locked:,
162162- bot:,
163163- discoverable:,
164164- indexable:,
165165- group:,
166166- created_at:,
167167- note:,
168168- url:,
169169- uri:,
170170- avatar:,
171171- avatar_static:,
172172- header:,
173173- header_static:,
174174- followers_count:,
175175- following_count:,
176176- statuses_count:,
177177- last_status_at:,
178178- // hide_collections:,
179179- // noindex:,
180180- ))
181181-}
182182-183183-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-197197-pub type MastodonMentions {
198198- MastodonMentions(id: String, username: String, url: String, acct: String)
199199-}
200200-201201-fn mentions_decoder() -> decode.Decoder(MastodonMentions) {
202202- use id <- decode.field("id", decode.string)
203203- use username <- decode.field("username", decode.string)
204204- use url <- decode.field("url", decode.string)
205205- use acct <- decode.field("acct", decode.string)
206206- decode.success(MastodonMentions(id:, username:, url:, acct:))
207207-}
208208-209209-pub type MastodonStatusContext {
210210- MastodonStatusContext(ancestors: List(MastodonStatus), descendants: List(MastodonStatus))
211211-}
212212-213213-pub fn status_context_decoder() -> decode.Decoder(MastodonStatusContext) {
214214- let dec = status_decoder()
215215- use ancestors <- decode.field("ancestors", decode.list(dec))
216216- use descendants <- decode.field("descendants", decode.list(dec))
217217- decode.success(MastodonStatusContext(ancestors:, descendants:))
218218-}
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-}
+262
src/chilp/api_typing/new.gleam
···11+import chilp/internal
22+import gleam/dynamic/decode
33+import gleam/list
44+import gleam/result
55+import gleam/time/timestamp
66+import lustre/element
77+88+/// 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`.
99+/// This one is very pruned! Why? Because these json responses are huge and we only need a small subset of the data in them!
1010+pub type BskyThreadView {
1111+ BskyThreadView(at_uri: String, replies: List(BskyThreadReply))
1212+}
1313+1414+pub type Message {
1515+ Message
1616+}
1717+1818+pub type BskyThreadReply {
1919+ BskyThreadReply(
2020+ at_uri: String,
2121+ like_count: Int,
2222+ created_at: timestamp.Timestamp,
2323+ body_text: String,
2424+ author_did: String,
2525+ author_handle: String,
2626+ author_displayname: String,
2727+ author_avatar: String,
2828+ children: List(BskyThreadReply),
2929+ )
3030+}
3131+3232+pub fn bsky_thread_view_decoder() -> decode.Decoder(BskyThreadView) {
3333+ use at_uri <- decode.subfield(["thread", "post", "uri"], decode.string)
3434+ use replies <- decode.subfield(
3535+ ["thread", "replies"],
3636+ decode.list(bsky_thread_reply_decoder()),
3737+ )
3838+ decode.success(BskyThreadView(at_uri:, replies:))
3939+}
4040+4141+fn bsky_thread_reply_decoder() -> decode.Decoder(BskyThreadReply) {
4242+ use created_at <- decode.subfield(
4343+ ["post", "record", "created_at"],
4444+ decode.map(decode.string, fn(stringstamp) {
4545+ result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch)
4646+ }),
4747+ )
4848+ use at_uri <- decode.subfield(["post", "uri"], decode.string)
4949+ use body_text <- decode.subfield(["post", "record", "text"], decode.string)
5050+5151+ use author_did <- decode.subfield(["post", "author", "did"], decode.string)
5252+ use author_handle <- decode.subfield(
5353+ ["post", "author", "handle"],
5454+ decode.string,
5555+ )
5656+ use author_displayname <- decode.subfield(
5757+ ["post", "author", "displayName"],
5858+ decode.string,
5959+ )
6060+ use author_avatar <- decode.subfield(
6161+ ["post", "author", "avatar"],
6262+ decode.string,
6363+ )
6464+ use like_count <- decode.subfield(["post", "likeCount"], decode.int)
6565+ use children <- decode.field(
6666+ "replies",
6767+ decode.list(bsky_thread_reply_decoder()),
6868+ )
6969+ decode.success(BskyThreadReply(
7070+ at_uri:,
7171+ created_at:,
7272+ body_text:,
7373+ like_count:,
7474+ author_did:,
7575+ author_handle:,
7676+ author_displayname:,
7777+ author_avatar:,
7878+ children:,
7979+ ))
8080+}
8181+8282+/// Subset of a Mastodon Status-context, like what you get from `https://pony.social/api/v1/statuses/115911235653686237/context`.
8383+pub type MastodonStatusContext {
8484+ MastodonStatusContext(descendants: List(MastodonDescendant))
8585+}
8686+8787+pub fn mastodon_status_context_decoder(
8888+ original_id: String,
8989+) -> decode.Decoder(MastodonStatusContext) {
9090+ use flat_descendants <- decode.field(
9191+ "descendants",
9292+ decode.list(mastodon_descendant_decoder()),
9393+ )
9494+ let descendants: List(MastodonDescendant) =
9595+ list.filter_map(flat_descendants, fn(desc) {
9696+ case desc.1 == original_id {
9797+ True -> {
9898+ // A parent!
9999+100100+ mastodon_decendant_inflater(desc.0, flat_descendants)
101101+ |> Ok
102102+ }
103103+ False -> {
104104+ Error(Nil)
105105+ }
106106+ }
107107+ })
108108+ decode.success(MastodonStatusContext(descendants:))
109109+}
110110+111111+fn mastodon_decendant_inflater(
112112+ parent: MastodonDescendant,
113113+ all_children: List(#(MastodonDescendant, String)),
114114+) {
115115+ MastodonDescendant(
116116+ ..parent,
117117+ children: list.filter_map(all_children, fn(c) {
118118+ case c.1 == parent.id {
119119+ True -> Ok(c.0)
120120+ False -> Error(Nil)
121121+ }
122122+ }),
123123+ )
124124+}
125125+126126+pub type MastodonDescendant {
127127+ MastodonDescendant(
128128+ id: String,
129129+ uri: String,
130130+ content: element.Element(Message),
131131+ created_at: timestamp.Timestamp,
132132+ favourite_count: Int,
133133+ // 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.
134134+ // We gotta iterate over this later to get things nested.
135135+ children: List(MastodonDescendant),
136136+ author_url: String,
137137+ author_avatar_url: String,
138138+ author_username: String,
139139+ author_displayname: String,
140140+ )
141141+}
142142+143143+/// Decodes #(MastodonDescendant, ReplyingTo), to enable the parent decoder to make this into a recursive three.
144144+fn mastodon_descendant_decoder() -> decode.Decoder(
145145+ #(MastodonDescendant, String),
146146+) {
147147+ use replying_to <- decode.field("in_reply_to_id", decode.string)
148148+ use id <- decode.field("id", decode.string)
149149+ use uri <- decode.field("uri", decode.string)
150150+ use created_at <- decode.field(
151151+ "created_at",
152152+ decode.map(decode.string, fn(stringstamp) {
153153+ result.unwrap(timestamp.parse_rfc3339(stringstamp), timestamp.unix_epoch)
154154+ }),
155155+ )
156156+ use favourite_count <- decode.field("favourites_count", decode.int)
157157+ use unescaped_html_content <- decode.field("content", decode.string)
158158+ let content = internal.sanitise_ls(unescaped_html_content)
159159+160160+ let children = []
161161+162162+ use author_url <- decode.subfield(["account", "url"], decode.string)
163163+ use author_avatar_url <- decode.subfield(["account", "avatar"], decode.string)
164164+ use author_displayname <- decode.subfield(
165165+ ["account", "display_name"],
166166+ decode.string,
167167+ )
168168+ use author_username <- decode.subfield(["account", "acct"], decode.string)
169169+170170+ decode.success(#(
171171+ MastodonDescendant(
172172+ id:,
173173+ uri:,
174174+ content:,
175175+ created_at:,
176176+ favourite_count:,
177177+ children:,
178178+ author_url:,
179179+ author_avatar_url:,
180180+ author_username:,
181181+ author_displayname:,
182182+ ),
183183+ replying_to,
184184+ ))
185185+}
186186+187187+pub fn coalesce_views(
188188+ bsky: List(BskyThreadReply),
189189+ mastodon: List(MastodonDescendant),
190190+) {
191191+ let mixed: List(Result(BskyThreadReply, MastodonDescendant)) = {
192192+ list.append(
193193+ list.map(bsky, fn(m) { Ok(m) }),
194194+ list.map(mastodon, fn(m) { Error(m) }),
195195+ )
196196+ |> list.shuffle
197197+ }
198198+ list.map(mixed, fn(item) {
199199+ let coalesced: CoalescedView = case item {
200200+ Ok(BskyThreadReply(
201201+ created_at:,
202202+ at_uri:,
203203+ like_count:,
204204+ body_text:,
205205+ author_did:,
206206+ author_displayname:,
207207+ author_handle:,
208208+ author_avatar:,
209209+ children:,
210210+ )) ->
211211+ CoalescedView(
212212+ created_at:,
213213+ author_profile_link: "https://witchsky.app/profile/" <> author_did,
214214+ source: "Bluesky",
215215+ agreeability: like_count,
216216+ content: element.text(body_text),
217217+ author_username: author_handle,
218218+ author_avatar_url: author_avatar,
219219+ displayname: author_displayname,
220220+ children: { coalesce_views(children, []) },
221221+ )
222222+ Error(MastodonDescendant(
223223+ created_at:,
224224+ id:,
225225+ uri:,
226226+ content:,
227227+ favourite_count:,
228228+ children:,
229229+ author_url:,
230230+ author_avatar_url:,
231231+ author_username:,
232232+ author_displayname:,
233233+ )) ->
234234+ CoalescedView(
235235+ created_at:,
236236+ author_profile_link: author_url,
237237+ source: "Mastodon",
238238+ displayname: author_displayname,
239239+ agreeability: favourite_count,
240240+ author_avatar_url:,
241241+ author_username:,
242242+ content: content,
243243+ children: { coalesce_views([], children) },
244244+ )
245245+ }
246246+ })
247247+}
248248+249249+pub type CoalescedView {
250250+251251+ CoalescedView(
252252+ created_at: timestamp.Timestamp,
253253+ author_profile_link: String,
254254+ author_avatar_url: String,
255255+ author_username: String,
256256+ source: String,
257257+ displayname: String,
258258+ content: element.Element(Message),
259259+ agreeability: Int,
260260+ children: List(CoalescedView),
261261+ )
262262+}
···11+pub type Mastodon {
22+ Mastodon(
33+ /// The instance name, e.g. mastodon.social
44+ instance: String,
55+ /// A post id to bind to, you'll find this in a post url `https://mastodon.social/@<username>/[postid]`.
66+ postid: String,
77+ )
88+}
99+1010+pub type Bluesky {
1111+ Bluesky(
1212+ /// Your DID, for `@strawmelonjuice.com`, this looks like `"did:plc:jgtfsmv25thfs4zmydtbccnn"`.
1313+ ///
1414+ /// Not sure how to find your DID? <https://bsky-did.neocities.org> is one of the many places where you can easily find it.
1515+ did: String,
1616+ /// A post id to bind to, you'll find this in a post url `https://bsky.app/profile/<your-username-or-did>/post/[postid]`
1717+ postid: String,
1818+ )
1919+}
2020+2121+@internal
2222+pub type ConnectionType {
2323+ Bsky(Bluesky)
2424+ Fedi(Mastodon)
2525+}
-650
src/chilp/widget/base.gleam
···11-//// No-component widget
22-//// This is the Mastodon Widget except much more customisable. This version might also influence your application logic a bit much.
33-//// If you really want to customise, use this module, otherwise default to
44-55-import chilp/api_typing
66-import gleam/dict
77-import gleam/int
88-import gleam/list
99-import gleam/option
1010-import gleam/order
1111-import gleam/pair
1212-import gleam/result
1313-import gleam/string
1414-import gleam/time/duration
1515-import gleam/time/timestamp
1616-import gleam/uri
1717-import lustre/attribute.{attribute}
1818-import lustre/effect
1919-import lustre/element
2020-import lustre/element/html
2121-import lustre/event
2222-import rsvp
2323-2424-pub opaque type MastodonPost {
2525- MastodonPost(instance: String, postid: String)
2626-}
2727-2828-/// Creates a comment widget, this is where you should probably start!
2929-///
3030-/// This function takes three arguments:
3131-/// - `instance`: The instance name, e.g. mastodon.social
3232-/// - `postid`: A post id to bind to, you'll find this in a post url `https://mastodon.social/@<username>/[postid]`.
3333-/// - `messages`: Some messages that chilp needs to be able to send
3434-/// The resulting comment widget can be edited however you'd like, but is
3535-pub fn new(
3636- instance instance: String,
3737- post_id postid: String,
3838- chilp_model model: ChilpDataInYourModel(msg),
3939-) -> CommentWidget(msg) {
4040- construct_new(instance, postid, model.message)
4141-}
4242-4343-@internal
4444-pub fn construct_new(
4545- instance: String,
4646- postid: String,
4747- message_wrap: fn(ChilpMsg) -> a,
4848-) -> CommentWidget(a) {
4949- let instancelist = [
5050- instance,
5151- "mastodon.social",
5252- instance,
5353- "pony.social",
5454- instance,
5555- "todon.nl",
5656- instance,
5757- "mstdn.social",
5858- instance,
5959- ]
6060- let instanceplaceholder = {
6161- instancelist
6262- |> list.shuffle
6363- |> list.first
6464- |> result.unwrap("myinstance.social")
6565- }
6666- let post = MastodonPost(instance:, postid:)
6767- let set_message_get = Get(post) |> message_wrap
6868- let go_answer = fn(n) {
6969- let value =
7070- list.key_find(n, "userinstance")
7171- |> result.unwrap(instanceplaceholder)
7272- GoAnswer(value, post) |> message_wrap
7373- }
7474- CommentWidget(
7575- post:,
7676- instancelist:,
7777- recursion_limit: 3,
7878- emit_error: True,
7979- widget_header: #("Comments", [
8080- attribute.classes([#("widget-header h1", True)]),
8181- ]),
8282- widget: [
8383- attribute.classes([#("widget", True)]),
8484- ],
8585- load_button: [
8686- event.on_click(set_message_get),
8787- attribute.classes([#("btn-get-comments", True)]),
8888- ],
8989- comments_section: [],
9090- go_reply_form: [
9191- event.on_submit(go_answer),
9292- attribute.classes([#("go-reply-form", True)]),
9393- ],
9494- go_reply_text_box: [
9595- attribute.type_("text"),
9696- attribute.placeholder(instanceplaceholder),
9797- attribute.name("userinstance"),
9898- attribute.classes([#("go-reply-form-input", True)]),
9999- attribute.pattern("^([a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}$"),
100100- attribute.required(True),
101101- ],
102102- go_reply_button: [
103103- attribute.type_("submit"),
104104- attribute.classes([
105105- #("go-reply-form-button", True),
106106- ]),
107107- ],
108108- comment_article_by_op: [attribute.class("comment comment-by-op")],
109109- comment_article: [attribute.class("comment")],
110110- comment_header: [],
111111- children_section: [],
112112- loading_span: [],
113113- avatar_img: [
114114- attribute.class("avatar"),
115115- attribute.alt("@"),
116116- ],
117117- error_element: [attribute.class("chilp-error")],
118118- metadata_div: [attribute.class("meta")],
119119- displayname: [attribute.class("display-name")],
120120- written_at: [],
121121- content_section: [attribute.class("content")],
122122- comment_link: [],
123123- comment_footer: [],
124124- widget_subheader: [
125125- attribute.classes([
126126- #("subheader", True),
127127- ]),
128128- ],
129129- widget_subheader_link: [
130130- attribute.classes([
131131- #("post-link", True),
132132- ]),
133133- ],
134134- go_reply_label: [
135135- attribute.class("go-reply-label"),
136136- attribute.for("userinstance"),
137137- ],
138138- or_create_an_account_link: [
139139- attribute.classes([
140140- #("post-link", True),
141141- ]),
142142- ],
143143- or_create_an_account_disclaimer: [
144144- attribute.class("or-create-an-account-disclaimer"),
145145- attribute.for("userinstance"),
146146- ],
147147- )
148148-}
149149-150150-/// This is what will show your comment block.
151151-pub fn show(
152152- from attributes: CommentWidget(msg),
153153- data model: ChilpDataInYourModel(msg),
154154-) -> element.Element(msg) {
155155- let model = model.inner
156156- html.div([attribute.class("chilp-widget")], [
157157- html.div(attributes.widget, [
158158- html.h1(attributes.widget_header.1, [
159159- element.text(attributes.widget_header.0),
160160- ]),
161161- case dict.get(model.stati, attributes.post) {
162162- Ok(status) ->
163163- html.p(attributes.widget_subheader, [
164164- element.text("Linked to "),
165165- html.a(
166166- attributes.widget_subheader_link
167167- |> list.append([attribute.href(status.url)]),
168168- [element.text("this post")],
169169- ),
170170- element.text(" on Mastodon."),
171171- ])
172172- Error(_) -> element.none()
173173- },
174174- html.form(attributes.go_reply_form, [
175175- html.div([attribute.class("input-group")], [
176176- html.label(attributes.go_reply_label, [
177177- html.text("Enter your instance adress to reply or "),
178178- html.a(
179179- [
180180- attribute.href(
181181- "https://"
182182- <> {
183183- attributes.instancelist
184184- |> list.shuffle
185185- |> list.first
186186- |> result.unwrap(attributes.post.instance)
187187- }
188188- <> "/auth/sign_up",
189189- ),
190190- ]
191191- |> list.append(attributes.or_create_an_account_link),
192192- [element.text("create an account")],
193193- ),
194194- element.text("!"),
195195- ]),
196196- html.p(attributes.or_create_an_account_disclaimer, [
197197- element.text(
198198- "on an instance reccommended by this site... or one you pick yourself!",
199199- ),
200200- ]),
201201- html.div([attribute.class("form-controls")], [
202202- html.input(attributes.go_reply_text_box),
203203- html.button(attributes.go_reply_button, [element.text("Go reply")]),
204204- ]),
205205- ]),
206206- ]),
207207- case
208208- dict.get(model.busy, attributes.post),
209209- dict.get(model.stati, attributes.post),
210210- dict.get(model.context, attributes.post)
211211- {
212212- _, Ok(status), Ok(context) ->
213213- view_commentlist(attributes, status, context.0)
214214-215215- Ok(option.None), Error(_), _ | Ok(option.None), _, Error(_) ->
216216- html.span(attributes.loading_span, [
217217- element.text("Loading comments..."),
218218- ])
219219-220220- Ok(option.Some(errorvalue)), Error(_), _
221221- | Ok(option.Some(errorvalue)), _, Error(_)
222222- if attributes.emit_error == True
223223- -> html.pre(attributes.error_element, [element.text(errorvalue)])
224224-225225- // Post is not 'gotten' yet.
226226- _, _, _ ->
227227- html.button(attributes.load_button, [element.text("Load comments")])
228228- },
229229- ]),
230230- ])
231231-}
232232-233233-fn view_commentlist(
234234- attributes: CommentWidget(msg),
235235- status: api_typing.MastodonStatus,
236236- context: api_typing.MastodonStatusContext,
237237-) {
238238- let sorted_descendants =
239239- context.descendants
240240- |> list.sort(fn(a, b) {
241241- case
242242- timestamp.parse_rfc3339(a.created_at),
243243- timestamp.parse_rfc3339(b.created_at)
244244- {
245245- Ok(a), Ok(b) -> timestamp.compare(a, b)
246246- _, _ -> order.Eq
247247- }
248248- })
249249- |> list.sort(fn(a, b) {
250250- int.compare(a.favourites_count, b.favourites_count)
251251- })
252252- html.section(
253253- attributes.comments_section,
254254- list.map(sorted_descendants, fn(comm: api_typing.MastodonStatus) -> element.Element(
255255- msg,
256256- ) {
257257- render_comment(
258258- attribs: attributes,
259259- comm_id: comm.id,
260260- recursion: 1,
261261- parent: status,
262262- original_parent: status,
263263- sorted_descendants:,
264264- )
265265- }),
266266- )
267267-}
268268-269269-fn render_comment(
270270- attribs attribs: CommentWidget(msg),
271271- comm_id comm_id: String,
272272- recursion recursion: Int,
273273- parent parent: api_typing.MastodonStatus,
274274- original_parent original_parent: api_typing.MastodonStatus,
275275- sorted_descendants descendants: List(api_typing.MastodonStatus),
276276-) {
277277- let comm_result = list.find(descendants, fn(comm_) { comm_.id == comm_id })
278278- case comm_result, recursion <= attribs.recursion_limit {
279279- Ok(comm), True if comm.in_reply_to_id == parent.id -> {
280280- let children = case comm.replies_count == 0 {
281281- True -> []
282282- False -> {
283283- list.filter(descendants, fn(comm_) { comm_.in_reply_to_id == comm.id })
284284- |> list.map(fn(c) {
285285- render_comment(
286286- attribs:,
287287- comm_id: c.id,
288288- recursion: recursion + 1,
289289- parent: comm,
290290- original_parent:,
291291- sorted_descendants: descendants,
292292- )
293293- })
294294- }
295295- }
296296- view_comment(
297297- comm,
298298- // Is comment by op
299299- original_parent.account.id == comm.account.id
300300- && comm.account.note == parent.account.note,
301301- attribs,
302302- children,
303303- )
304304- }
305305- _, _ -> element.none()
306306- }
307307-}
308308-309309-fn view_comment(
310310- comment: api_typing.MastodonStatus,
311311- is_authors: Bool,
312312- attribs: CommentWidget(msg),
313313- children: List(element.Element(msg)),
314314-) {
315315- html.article(
316316- case is_authors {
317317- True -> attribs.comment_article_by_op
318318- _ -> attribs.comment_article
319319- },
320320- [
321321- html.header(attribs.comment_header, [
322322- html.img(
323323- list.append(attribs.avatar_img, [
324324- attribute.src(comment.account.avatar),
325325- ]),
326326- ),
327327- html.div(attribs.metadata_div, [
328328- html.span(attribs.displayname, [
329329- element.text(comment.account.display_name),
330330- ]),
331331- html.time(
332332- [attribute("datetime", comment.created_at)]
333333- |> list.append(attribs.written_at),
334334- [
335335- element.text({
336336- let b =
337337- case
338338- timestamp.difference(
339339- timestamp.parse_rfc3339(comment.created_at)
340340- |> result.unwrap(timestamp.system_time()),
341341- timestamp.system_time(),
342342- )
343343- |> duration.approximate
344344- |> pair.map_second(fn(d) {
345345- case d {
346346- duration.Nanosecond -> "nanosecond"
347347- duration.Microsecond -> "microsecond"
348348- duration.Millisecond -> "millisecond"
349349- duration.Second -> "second"
350350- duration.Minute -> "minute"
351351- duration.Hour -> "hour"
352352- duration.Day -> "day"
353353- duration.Week -> "week"
354354- duration.Month -> "month"
355355- duration.Year -> "year"
356356- }
357357- })
358358- {
359359- #(1, x) -> #(1, x)
360360- #(x, d) -> #(x, d <> "s")
361361- }
362362- |> pair.map_first(int.to_string)
363363-364364- b.0 <> " " <> b.1 <> " ago."
365365- }),
366366- ],
367367- ),
368368- ]),
369369- ]),
370370- html.section(attribs.content_section, [
371371- element.unsafe_raw_html("", "span", [], comment.content |> sanitize),
372372- ]),
373373- html.footer([], [
374374- html.a(
375375- [attribute.href(comment.url)] |> list.append(attribs.comment_link),
376376- [
377377- element.text("View comment on Mastodon"),
378378- ],
379379- ),
380380- html.section(attribs.children_section, children),
381381- ]),
382382- ],
383383- )
384384-}
385385-386386-@external(javascript, "../ffi.mjs", "sanitize")
387387-fn sanitize(html: String) -> String {
388388- // On erlang, there's a lot less risk.
389389- html
390390-}
391391-392392-/// Allows you to edit your widget, you can replace or append to any values here.
393393-/// Do note, removing stuff might remove functionality!
394394-/// By default, some Tailwind/DaisyUI classes are added.
395395-pub type CommentWidget(msg) {
396396- CommentWidget(
397397- /// The post this widget is for, you should just keep this.
398398- post: MastodonPost,
399399- /// Limit on comment depth.
400400- recursion_limit: Int,
401401- /// On error, print the error to the DOM?
402402- emit_error: Bool,
403403- /// Widget header value, by default "Comments", and it's attributes
404404- widget_header: #(String, List(attribute.Attribute(msg))),
405405- /// The top element of the widget itself.
406406- widget: List(attribute.Attribute(msg)),
407407- /// [Load comments]-button
408408- load_button: List(attribute.Attribute(msg)),
409409- /// The actual area the comments show up in
410410- comments_section: List(attribute.Attribute(msg)),
411411- children_section: List(attribute.Attribute(msg)),
412412- go_reply_form: List(attribute.Attribute(msg)),
413413- go_reply_label: List(attribute.Attribute(msg)),
414414- go_reply_text_box: List(attribute.Attribute(msg)),
415415- go_reply_button: List(attribute.Attribute(msg)),
416416- /// Applied to the <header> area of a comment.
417417- comment_article: List(attribute.Attribute(msg)),
418418- /// Applied to the <header> area of a comment posted by the parent's poster.
419419- comment_article_by_op: List(attribute.Attribute(msg)),
420420- comment_header: List(attribute.Attribute(msg)),
421421- loading_span: List(attribute.Attribute(msg)),
422422- avatar_img: List(attribute.Attribute(msg)),
423423- error_element: List(attribute.Attribute(msg)),
424424- metadata_div: List(attribute.Attribute(msg)),
425425- displayname: List(attribute.Attribute(msg)),
426426- written_at: List(attribute.Attribute(msg)),
427427- content_section: List(attribute.Attribute(msg)),
428428- comment_link: List(attribute.Attribute(msg)),
429429- /// Footer of the comment, containing the comment url and comment's children.
430430- comment_footer: List(attribute.Attribute(msg)),
431431- widget_subheader: List(attribute.Attribute(msg)),
432432- widget_subheader_link: List(attribute.Attribute(msg)),
433433- or_create_an_account_link: List(attribute.Attribute(msg)),
434434- or_create_an_account_disclaimer: List(attribute.Attribute(msg)),
435435- /// Used to randomnise the 'Or create an account' link.
436436- instancelist: List(String),
437437- )
438438-}
439439-440440-/// Trigger forces the widget to load in data before the user clicked the button.
441441-/// This is something you'll want if you know beforehand which post comments to display.
442442-pub fn trigger(
443443- on on: CommentWidget(msg),
444444- chilp_model model: ChilpDataInYourModel(msg),
445445-) -> msg {
446446- model.message(Get(on.post))
447447-}
448448-449449-/// 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.
450450-/// This is something you'll want if you know beforehand which post comments to display.
451451-pub fn force(
452452- on on: CommentWidget(msg),
453453- chilp_model model: ChilpDataInYourModel(msg),
454454-) {
455455- get(on.post, model)
456456-}
457457-458458-/// This stores metadata that is handled internally by Chilp
459459-/// You should store this on your model!
460460-///
461461-pub opaque type ChilpDataInYourModel(msg) {
462462- ChilpDataInYourModel(message: fn(ChilpMsg) -> msg, inner: ChilpModel)
463463-}
464464-465465-pub opaque type ChilpModel {
466466- ChilpModel(
467467- stati: dict.Dict(MastodonPost, api_typing.MastodonStatus),
468468- context: dict.Dict(MastodonPost, #(api_typing.MastodonStatusContext, Float)),
469469- busy: dict.Dict(MastodonPost, option.Option(String)),
470470- )
471471-}
472472-473473-pub fn init(message message: fn(ChilpMsg) -> msg) {
474474- ChilpDataInYourModel(
475475- message:,
476476- inner: ChilpModel(stati: dict.new(), context: dict.new(), busy: dict.new()),
477477- )
478478-}
479479-480480-/// 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)`.
481481-pub opaque type ChilpMsg {
482482- Get(MastodonPost)
483483- Save(ChilpModel)
484484- GoAnswer(instance: String, to: MastodonPost)
485485-}
486486-487487-/// Gets all the metadata to work with in order to show your comments!
488488-fn get(
489489- post: MastodonPost,
490490- data: ChilpDataInYourModel(msg),
491491-) -> effect.Effect(msg) {
492492- let handles = fn(m) { data.message(Save(m)) }
493493- // Tell `show()` we're on it.
494494- let notify = fn() {
495495- effect.from(fn(dispatch) {
496496- dispatch(
497497- handles(ChilpModel(
498498- stati: dict.new(),
499499- context: dict.new(),
500500- busy: dict.from_list([#(post, option.None)]),
501501- )),
502502- )
503503- })
504504- }
505505- effect.batch([
506506- notify(),
507507- get_post(post, handles),
508508- get_context(post, handles),
509509- ])
510510-}
511511-512512-fn get_post(
513513- post: MastodonPost,
514514- message: fn(ChilpModel) -> msg,
515515-) -> effect.Effect(msg) {
516516- let url = "https://" <> post.instance <> "/api/v1/statuses/" <> post.postid
517517- let handle_response = fn(s) {
518518- case s {
519519- Ok(status) -> {
520520- ChilpModel(
521521- stati: dict.from_list([#(post, status)]),
522522- context: dict.new(),
523523- busy: dict.new(),
524524- )
525525- }
526526- Error(e) ->
527527- ChilpModel(
528528- dict.new(),
529529- dict.new(),
530530- dict.from_list([
531531- #(
532532- post,
533533- option.Some(string.inspect(e) <> "\n\nWhile looking at: " <> url),
534534- ),
535535- ]),
536536- )
537537- }
538538- |> message
539539- }
540540- let handler = rsvp.expect_json(api_typing.status_decoder(), handle_response)
541541- rsvp.get(url, handler)
542542-}
543543-544544-fn get_context(
545545- post: MastodonPost,
546546- message: fn(ChilpModel) -> msg,
547547-) -> effect.Effect(msg) {
548548- let url =
549549- "https://"
550550- <> post.instance
551551- <> "/api/v1/statuses/"
552552- <> post.postid
553553- <> "/context"
554554- let handle_response = fn(c) {
555555- case c {
556556- Ok(context) -> {
557557- let now = timestamp.system_time() |> timestamp.to_unix_seconds()
558558- ChilpModel(
559559- stati: dict.new(),
560560- context: dict.from_list([#(post, #(context, now))]),
561561- busy: dict.new(),
562562- )
563563- }
564564- Error(e) ->
565565- ChilpModel(
566566- dict.new(),
567567- dict.new(),
568568- dict.from_list([
569569- #(
570570- post,
571571- option.Some(
572572- string.inspect(e)
573573- <> "\n\nWhile looking at: "
574574- <> url
575575- <> "\n\nWant to report this? File an issue ",
576576- ),
577577- ),
578578- ]),
579579- )
580580- }
581581- |> message
582582- }
583583-584584- let handler =
585585- rsvp.expect_json(api_typing.status_context_decoder(), handle_response)
586586- rsvp.get(url, handler)
587587-}
588588-589589-/// The update handler for chilp-specific messages!
590590-///
591591-/// It takes in three values:
592592-/// - `message`: The message it handles
593593-/// - `model`: the chilp data from your model
594594-/// - `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.
595595-pub fn update(
596596- message: ChilpMsg,
597597- model: ChilpDataInYourModel(msg),
598598- change_url: fn(String) -> effect.Effect(msg),
599599-) -> #(ChilpDataInYourModel(msg), effect.Effect(msg)) {
600600- case message {
601601- Get(post) -> {
602602- #(model, get(post, model))
603603- }
604604- Save(addedmodel) -> {
605605- let #(o_stati, o_context) = uncloth(model.inner)
606606- let #(n_stati, n_context) = uncloth(addedmodel)
607607- let stati = list.append(o_stati, n_stati) |> dict.from_list
608608- // I just loved overcomplicating it too much.
609609- let context = list.append(o_context, n_context) |> dict.from_list
610610-611611- let busy = dict.combine(addedmodel.busy, model.inner.busy, option.or)
612612-613613- #(
614614- ChilpDataInYourModel(
615615- ..model,
616616- inner: ChilpModel(stati:, context:, busy:),
617617- ),
618618- effect.none(),
619619- )
620620- }
621621- GoAnswer(instance:, to:) -> {
622622- let s = dict.get(model.inner.stati, to)
623623- case s {
624624- Ok(post) -> {
625625- change_url({
626626- "https://"
627627- <> instance
628628- <> "/authorize_interaction?uri="
629629- <> { post.url |> uri.percent_encode }
630630- })
631631- #(model, effect.none())
632632- }
633633- Error(_) -> #(model, effect.none())
634634- }
635635- }
636636- }
637637-}
638638-639639-fn uncloth(
640640- m: ChilpModel,
641641-) -> #(
642642- List(#(MastodonPost, api_typing.MastodonStatus)),
643643- List(#(MastodonPost, #(api_typing.MastodonStatusContext, Float))),
644644-) {
645645- case m {
646646- ChilpModel(stati:, context:, ..) -> {
647647- #(dict.to_list(stati), dict.to_list(context))
648648- }
649649- }
650650-}